v0.12.50 Updated to Family Manager and Events modal
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.49",
|
"version": "0.12.50",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 017_partner_respond_separately.sql
|
||||||
|
-- Adds respond_separately flag to guardian_partners.
|
||||||
|
-- When true, linked partners can each respond to events on behalf of children
|
||||||
|
-- in the shared alias list, but cannot respond on behalf of each other.
|
||||||
|
|
||||||
|
ALTER TABLE guardian_partners ADD COLUMN IF NOT EXISTS respond_separately BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -395,10 +395,12 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
|||||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||||
`, [event.id, partnerId]);
|
`, [event.id, partnerId]);
|
||||||
if (partnerInGroup) {
|
if (partnerInGroup) {
|
||||||
event.my_partner = await queryOne(req.schema,
|
const pUser = await queryOne(req.schema, 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', [partnerId]);
|
||||||
'SELECT id,name,display_name,avatar FROM users WHERE id=$1',
|
const pGp = await queryOne(req.schema,
|
||||||
[partnerId]
|
'SELECT respond_separately FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||||
|
[Math.min(req.user.id, partnerId), Math.max(req.user.id, partnerId)]
|
||||||
);
|
);
|
||||||
|
event.my_partner = pUser ? { ...pUser, respond_separately: pGp?.respond_separately || false } : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, re
|
|||||||
router.get('/me/partner', authMiddleware, async (req, res) => {
|
router.get('/me/partner', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const partner = await queryOne(req.schema,
|
const partner = await queryOne(req.schema,
|
||||||
`SELECT u.id, u.name, u.display_name, u.avatar
|
`SELECT u.id, u.name, u.display_name, u.avatar, gp.respond_separately
|
||||||
FROM guardian_partners gp
|
FROM guardian_partners gp
|
||||||
JOIN users u ON u.id = CASE WHEN gp.user_id_1=$1 THEN gp.user_id_2 ELSE gp.user_id_1 END
|
JOIN users u ON u.id = CASE WHEN gp.user_id_1=$1 THEN gp.user_id_2 ELSE gp.user_id_1 END
|
||||||
WHERE gp.user_id_1=$1 OR gp.user_id_2=$1`,
|
WHERE gp.user_id_1=$1 OR gp.user_id_2=$1`,
|
||||||
@@ -466,26 +466,52 @@ router.get('/me/partner', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set partner (replaces any existing partnership for this user)
|
// Set partner (replaces any existing partnership for this user)
|
||||||
|
// If the partner is changing to a different person, the user's child aliases are also removed.
|
||||||
router.post('/me/partner', authMiddleware, async (req, res) => {
|
router.post('/me/partner', authMiddleware, async (req, res) => {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const partnerId = parseInt(req.body.partnerId);
|
const partnerId = parseInt(req.body.partnerId);
|
||||||
|
const respondSeparately = !!req.body.respondSeparately;
|
||||||
if (!partnerId || partnerId === userId) return res.status(400).json({ error: 'Invalid partner' });
|
if (!partnerId || partnerId === userId) return res.status(400).json({ error: 'Invalid partner' });
|
||||||
const uid1 = Math.min(userId, partnerId);
|
const uid1 = Math.min(userId, partnerId);
|
||||||
const uid2 = Math.max(userId, partnerId);
|
const uid2 = Math.max(userId, partnerId);
|
||||||
try {
|
try {
|
||||||
|
// Check current partner before replacing
|
||||||
|
const currentRow = await queryOne(req.schema,
|
||||||
|
`SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id
|
||||||
|
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const currentPartnerId = currentRow?.partner_id ? parseInt(currentRow.partner_id) : null;
|
||||||
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [userId]);
|
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [userId]);
|
||||||
await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2) VALUES ($1,$2)', [uid1, uid2]);
|
// If switching to a different partner, remove user's own child aliases
|
||||||
|
if (currentPartnerId && currentPartnerId !== partnerId) {
|
||||||
|
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [userId]);
|
||||||
|
}
|
||||||
|
await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2,respond_separately) VALUES ($1,$2,$3)', [uid1, uid2, respondSeparately]);
|
||||||
const partner = await queryOne(req.schema,
|
const partner = await queryOne(req.schema,
|
||||||
'SELECT id,name,display_name,avatar FROM users WHERE id=$1',
|
'SELECT id,name,display_name,avatar FROM users WHERE id=$1',
|
||||||
[partnerId]
|
[partnerId]
|
||||||
);
|
);
|
||||||
res.json({ partner });
|
res.json({ partner: { ...partner, respond_separately: respondSeparately } });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove partner
|
// Update respond_separately on existing partnership
|
||||||
|
router.patch('/me/partner', authMiddleware, async (req, res) => {
|
||||||
|
const respondSeparately = !!req.body.respondSeparately;
|
||||||
|
try {
|
||||||
|
await exec(req.schema,
|
||||||
|
'UPDATE guardian_partners SET respond_separately=$1 WHERE user_id_1=$2 OR user_id_2=$2',
|
||||||
|
[respondSeparately, req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove partner — also removes the requesting user's child aliases
|
||||||
router.delete('/me/partner', authMiddleware, async (req, res) => {
|
router.delete('/me/partner', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [req.user.id]);
|
||||||
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [req.user.id]);
|
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [req.user.id]);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.49}"
|
VERSION="${1:-0.12.50}"
|
||||||
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.49",
|
"version": "0.12.50",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
// Partner state
|
// Partner state
|
||||||
const [partner, setPartner] = useState(null);
|
const [partner, setPartner] = useState(null);
|
||||||
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
||||||
|
const [respondSeparately, setRespondSeparately] = useState(false);
|
||||||
const [allUsers, setAllUsers] = useState([]);
|
const [allUsers, setAllUsers] = useState([]);
|
||||||
const [savingPartner, setSavingPartner] = useState(false);
|
const [savingPartner, setSavingPartner] = useState(false);
|
||||||
|
|
||||||
@@ -25,8 +26,10 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
api.searchUsers(''),
|
api.searchUsers(''),
|
||||||
]).then(([aliasRes, partnerRes, usersRes]) => {
|
]).then(([aliasRes, partnerRes, usersRes]) => {
|
||||||
setAliases(aliasRes.aliases || []);
|
setAliases(aliasRes.aliases || []);
|
||||||
setPartner(partnerRes.partner || null);
|
const p = partnerRes.partner || null;
|
||||||
setSelectedPartnerId(partnerRes.partner?.id?.toString() || '');
|
setPartner(p);
|
||||||
|
setSelectedPartnerId(p?.id?.toString() || '');
|
||||||
|
setRespondSeparately(p?.respond_separately || false);
|
||||||
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id));
|
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id));
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -58,13 +61,18 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
if (!selectedPartnerId) {
|
if (!selectedPartnerId) {
|
||||||
await api.removePartner();
|
await api.removePartner();
|
||||||
setPartner(null);
|
setPartner(null);
|
||||||
toast('Spouse/Partner removed', 'success');
|
setRespondSeparately(false);
|
||||||
} else {
|
|
||||||
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId));
|
|
||||||
setPartner(p);
|
|
||||||
const { aliases: fresh } = await api.getAliases();
|
const { aliases: fresh } = await api.getAliases();
|
||||||
setAliases(fresh || []);
|
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) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
@@ -139,9 +147,9 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spouse/Partner section */}
|
{/* Spouse/Partner/Co-Parent section */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
{lbl('Spouse/Partner')}
|
{lbl('Spouse/Partner/Co-Parent')}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
@@ -163,8 +171,17 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
{savingPartner ? 'Saving…' : 'Save'}
|
{savingPartner ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{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}
|
Linked with {partner.display_name || partner.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -789,11 +789,12 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
const [noteSaving,setNoteSaving]=useState(false);
|
const [noteSaving,setNoteSaving]=useState(false);
|
||||||
const [avail,setAvail]=useState(event.availability||[]);
|
const [avail,setAvail]=useState(event.availability||[]);
|
||||||
const [expandedNotes,setExpandedNotes]=useState(new Set());
|
const [expandedNotes,setExpandedNotes]=useState(new Set());
|
||||||
|
const [responsesExpanded,setResponsesExpanded]=useState(false);
|
||||||
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>' | 'partner:<id>')
|
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>' | 'partner:<id>')
|
||||||
const myAliases = event.my_aliases || [];
|
const myAliases = event.my_aliases || [];
|
||||||
const myPartner = event.my_partner || null;
|
const myPartner = event.my_partner || null;
|
||||||
const showResponderSelect = !!(event.has_players_group && (myAliases.length > 0 || myPartner)) || !!(myPartner && event.in_guardians_group);
|
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
|
// Response that should be highlighted for the currently selected responder
|
||||||
const activeResp = !showResponderSelect || responder === 'all'
|
const activeResp = !showResponderSelect || responder === 'all'
|
||||||
@@ -825,7 +826,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
? [
|
? [
|
||||||
...(event.in_guardians_group ? [{ type:'self' }] : []),
|
...(event.in_guardians_group ? [{ type:'self' }] : []),
|
||||||
...myAliases.map(a => ({ type:'alias', aliasId:a.id })),
|
...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'
|
: responder === 'self'
|
||||||
? [{ type:'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>
|
<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)}
|
<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}}>
|
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>}
|
{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>)}
|
{myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1029,44 +1030,60 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
|||||||
)}
|
)}
|
||||||
{(isToolManager||avail.length>0)&&(
|
{(isToolManager||avail.length>0)&&(
|
||||||
<>
|
<>
|
||||||
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div>
|
<div
|
||||||
<div style={{display:'flex',gap:20,marginBottom:10,fontSize:13}}>
|
onClick={()=>setResponsesExpanded(e=>!e)}
|
||||||
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
|
style={{display:'flex',alignItems:'center',justifyContent:'space-between',cursor:'pointer',userSelect:'none',marginBottom:responsesExpanded?8:0}}
|
||||||
{isToolManager&&<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>}
|
>
|
||||||
</div>
|
<span style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px'}}>Responses</span>
|
||||||
{avail.length>0&&(
|
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||||
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
|
<div style={{display:'flex',gap:12,fontSize:12}}>
|
||||||
{avail.map(r=>{
|
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
|
||||||
const rowKey = r.is_alias ? `alias:${r.alias_id}` : `user:${r.user_id}`;
|
{isToolManager&&<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>}
|
||||||
const displayName = r.is_alias
|
</div>
|
||||||
? `${r.first_name} ${r.last_name}`
|
<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>
|
||||||
: (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>
|
||||||
)}
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export const api = {
|
|||||||
},
|
},
|
||||||
// Spouse/Partner
|
// Spouse/Partner
|
||||||
getPartner: () => req('GET', '/users/me/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'),
|
removePartner: () => req('DELETE', '/users/me/partner'),
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
|
|||||||
Reference in New Issue
Block a user