Update project files

This commit is contained in:
2026-07-17 09:48:49 +01:00
parent 0d71e6b7bc
commit cfe7fffce3
27 changed files with 5134 additions and 21 deletions

277
src/screens/AdminScreen.jsx Normal file
View File

@@ -0,0 +1,277 @@
import { useEffect, useState } from 'react'
import { View, Text, ScrollView, StyleSheet, Alert } from 'react-native'
import { useAuth } from '../context/AuthContext.jsx'
import { householdsApi, usersApi } from '../api/client.js'
import { formatDate } from '../utils/searchUtils.js'
import { StatusBanner, Panel, SectionHeading, InputField, Btn, BtnRow, EmptyState, EntityRow, EntityMeta, EntityActions, FormNote } from '../components/ui.jsx'
import { colors, spacing, fontSize, radius } from '../theme.js'
const EMPTY_HOUSEHOLD_FORM = { name: '', description: '' }
const EMPTY_USER_FORM = { firstName: '', lastName: '', email: '', password: '', confirmPassword: '' }
function formatPersonName(p) {
const full = [p.firstName, p.lastName].filter(Boolean).join(' ')
return full || p.email || 'Unnamed person'
}
export default function AdminScreen() {
const { isAuthenticated, isSiteAdmin, register } = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [status, setStatus] = useState('')
const [households, setHouseholds] = useState([])
const [selectedHouseholdId, setSelectedHouseholdId] = useState('')
const [editingHouseholdId, setEditingHouseholdId] = useState('')
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM)
const [inviteEmail, setInviteEmail] = useState('')
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
const [createdUser, setCreatedUser] = useState(null)
const [householdHistory, setHouseholdHistory] = useState([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState('')
async function loadHouseholds(preferredId = '') {
const r = await householdsApi.getHouseholds()
const next = Array.isArray(r) ? r : []
setHouseholds(next)
setSelectedHouseholdId(id => { const t = preferredId || id; return next.some(h => h.id === t) ? t : next[0]?.id ?? '' })
setEditingHouseholdId(id => next.some(h => h.id === id) ? id : '')
}
useEffect(() => {
let cancelled = false
async function init() {
if (!isAuthenticated) { setHouseholds([]); return }
setLoading(true); setError('')
try {
const r = await householdsApi.getHouseholds()
if (cancelled) return
const next = Array.isArray(r) ? r : []
setHouseholds(next); setSelectedHouseholdId(next[0]?.id ?? '')
} catch (e) { if (!cancelled) setError(e.message) }
finally { if (!cancelled) setLoading(false) }
}
init()
return () => { cancelled = true }
}, [isAuthenticated])
useEffect(() => {
let cancelled = false
async function loadHistory() {
if (!isAuthenticated || !selectedHouseholdId) { setHouseholdHistory([]); return }
setHistoryLoading(true); setHistoryError('')
try { const r = await householdsApi.getHouseholdHistory(selectedHouseholdId); if (!cancelled) setHouseholdHistory(Array.isArray(r) ? r : []) }
catch (e) { if (!cancelled) { setHouseholdHistory([]); setHistoryError(e.message) } }
finally { if (!cancelled) setHistoryLoading(false) }
}
loadHistory()
return () => { cancelled = true }
}, [isAuthenticated, selectedHouseholdId])
const selectedHousehold = households.find(h => h.id === selectedHouseholdId) ?? null
const editingHousehold = households.find(h => h.id === editingHouseholdId) ?? null
const canManageSelected = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin))
const canSubmitForm = editingHouseholdId ? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin)) : isSiteAdmin
async function submitHousehold() {
const name = householdForm.name.trim()
if (!name) { setError('Household name is required.'); return }
if (!canSubmitForm) { setError(editingHouseholdId ? 'You can only edit households you administer.' : 'Only site admins can create households.'); return }
setLoading(true); setError(''); setStatus('')
try {
const payload = { name, description: householdForm.description.trim() || null }
const result = editingHouseholdId ? await householdsApi.updateHousehold(editingHouseholdId, payload) : await householdsApi.createHousehold(payload)
await loadHouseholds(result?.id ?? editingHouseholdId)
setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
setStatus(editingHouseholdId ? 'Household updated.' : 'Household created.')
} catch (e) { setError(e.message) }
finally { setLoading(false) }
}
async function submitInvite() {
const email = inviteEmail.trim()
if (!selectedHouseholdId) { setError('Select a household first.'); return }
if (!email) { setError('Email is required.'); return }
if (!canManageSelected) { setError('You can only invite users to households you administer.'); return }
setLoading(true); setError(''); setStatus('')
try { await householdsApi.inviteHouseholdMember(selectedHouseholdId, { email }); await loadHouseholds(selectedHouseholdId); setInviteEmail(''); setStatus('Invitation sent.') }
catch (e) { setError(e.message) }
finally { setLoading(false) }
}
async function submitCreateUser() {
if (!isSiteAdmin) { setError('Only site admins can create users.'); return }
const email = userForm.email.trim()
if (!email) { setError('Email is required.'); return }
if (!userForm.password) { setError('Password is required.'); return }
if (userForm.password !== userForm.confirmPassword) { setError('Passwords do not match.'); return }
setLoading(true); setError(''); setStatus('')
try {
const result = await register({ email, password: userForm.password, confirmPassword: userForm.confirmPassword, firstName: userForm.firstName.trim() || null, lastName: userForm.lastName.trim() || null })
try {
const users = await usersApi.getUsers()
const match = Array.isArray(users) ? users.find(u => u.email?.toLowerCase() === email.toLowerCase()) : null
setCreatedUser(match ?? { email, firstName: userForm.firstName.trim(), lastName: userForm.lastName.trim(), roles: [] })
} catch { setCreatedUser({ email, firstName: userForm.firstName.trim(), lastName: userForm.lastName.trim(), roles: [] }) }
setUserForm(EMPTY_USER_FORM); setStatus(result?.message || 'User created.')
} catch (e) { setError(e.message) }
finally { setLoading(false) }
}
async function handleLeave(id) {
Alert.alert('Leave household?', undefined, [
{ text: 'Cancel' },
{ text: 'Leave', style: 'destructive', onPress: async () => {
setLoading(true); setError(''); setStatus('')
try { await householdsApi.leaveHousehold(id); await loadHouseholds(selectedHouseholdId === id ? '' : selectedHouseholdId); setStatus('Household left.') }
catch (e) { setError(e.message) }
finally { setLoading(false) }
}},
])
}
if (!isAuthenticated) return (
<View style={s.container}><Text style={s.authMsg}>Sign in to access admin features.</Text></View>
)
const totalMembers = households.reduce((n, h) => n + (h.members?.length ?? 0), 0)
const managedCount = households.filter(h => h.isCurrentUserHouseholdAdmin || isSiteAdmin).length
return (
<ScrollView style={s.container} contentContainerStyle={s.content}>
{error ? <StatusBanner type="error">{error}</StatusBanner> : null}
{status ? <StatusBanner type="success">{status}</StatusBanner> : null}
{loading ? <StatusBanner type="info">Processing...</StatusBanner> : null}
<Panel>
<SectionHeading title="Household Summary" />
<View style={s.statsGrid}>
<View style={s.statCard}><Text style={s.statLabel}>Households</Text><Text style={s.statValue}>{households.length}</Text></View>
<View style={s.statCard}><Text style={s.statLabel}>Managed</Text><Text style={s.statValue}>{managedCount}</Text></View>
<View style={s.statCard}><Text style={s.statLabel}>Members</Text><Text style={s.statValue}>{totalMembers}</Text></View>
</View>
</Panel>
<Panel>
<SectionHeading title={editingHouseholdId ? 'Edit Household' : 'Create Household'} right={editingHouseholdId ? <Btn title="New" variant="secondary" onPress={() => { setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM) }} /> : null} />
{!isSiteAdmin && !editingHouseholdId && <FormNote>Only site admins can create households.</FormNote>}
<InputField label="Name" value={householdForm.name} onChangeText={v => setHouseholdForm(f => ({ ...f, name: v }))} placeholder="Main Household" />
<InputField label="Description" value={householdForm.description} onChangeText={v => setHouseholdForm(f => ({ ...f, description: v }))} placeholder="Shared pantry access" multiline />
<BtnRow>
<Btn title={editingHouseholdId ? 'Update household' : 'Create household'} onPress={submitHousehold} disabled={!canSubmitForm || loading} />
<Btn title="Clear" variant="secondary" onPress={() => { setEditingHouseholdId(''); setHouseholdForm(EMPTY_HOUSEHOLD_FORM) }} />
</BtnRow>
</Panel>
<Panel>
<SectionHeading title="Invite Member" right={<Text style={s.subtleText}>{selectedHousehold?.name || 'None selected'}</Text>} />
{!selectedHousehold ? <EmptyState>Select a household to invite a member.</EmptyState> : (
<>
{!canManageSelected && <FormNote>You can only invite users to households you administer.</FormNote>}
<InputField label="Email" value={inviteEmail} onChangeText={setInviteEmail} keyboardType="email-address" autoCapitalize="none" placeholder="member@example.com" editable={canManageSelected} />
<BtnRow>
<Btn title="Send invite" onPress={submitInvite} disabled={!canManageSelected || loading} />
<Btn title="Clear" variant="secondary" onPress={() => setInviteEmail('')} />
</BtnRow>
</>
)}
</Panel>
<Panel>
<SectionHeading title="Create User" />
<FormNote>{isSiteAdmin ? 'Creates a new account via the register endpoint.' : 'Only site admins can create users.'}</FormNote>
<InputField label="First Name" value={userForm.firstName} onChangeText={v => setUserForm(f => ({ ...f, firstName: v }))} placeholder="Jordan" editable={isSiteAdmin} />
<InputField label="Last Name" value={userForm.lastName} onChangeText={v => setUserForm(f => ({ ...f, lastName: v }))} placeholder="Lee" editable={isSiteAdmin} />
<InputField label="Email" value={userForm.email} onChangeText={v => setUserForm(f => ({ ...f, email: v }))} keyboardType="email-address" autoCapitalize="none" placeholder="new@example.com" editable={isSiteAdmin} />
<InputField label="Password" value={userForm.password} onChangeText={v => setUserForm(f => ({ ...f, password: v }))} secureTextEntry placeholder="Password" editable={isSiteAdmin} />
<InputField label="Confirm Password" value={userForm.confirmPassword} onChangeText={v => setUserForm(f => ({ ...f, confirmPassword: v }))} secureTextEntry placeholder="Repeat password" editable={isSiteAdmin} />
<BtnRow>
<Btn title="Create user" onPress={submitCreateUser} disabled={!isSiteAdmin || loading} />
<Btn title="Clear" variant="secondary" onPress={() => setUserForm(EMPTY_USER_FORM)} disabled={!isSiteAdmin} />
</BtnRow>
{createdUser && (
<View style={s.createdUserCard}>
<Text style={s.strongText}>{formatPersonName(createdUser)}</Text>
<EntityMeta>{createdUser.email}</EntityMeta>
{createdUser.id && <EntityMeta>ID: {createdUser.id}</EntityMeta>}
<View style={s.roleList}>
{(Array.isArray(createdUser.roles) && createdUser.roles.length > 0
? createdUser.roles
: ['No roles assigned']
).map((r, i) => <View key={i} style={s.roleBadge}><Text style={s.roleBadgeText}>{r}</Text></View>)}
</View>
</View>
)}
</Panel>
<Panel>
<SectionHeading title="Household History" right={<Text style={s.subtleText}>{selectedHousehold?.name || 'None selected'}</Text>} />
{historyLoading ? <StatusBanner type="info">Loading history...</StatusBanner> : null}
{historyError ? <StatusBanner type="error">{historyError}</StatusBanner> : null}
{!selectedHousehold ? <EmptyState>Select a household to see its history.</EmptyState> :
householdHistory.length === 0 ? <EmptyState>No history returned.</EmptyState> :
householdHistory.map(entry => (
<EntityRow key={entry.id}>
<Text style={s.strongText}>{entry.action}</Text>
<EntityMeta>{formatDate(entry.changedAt) || 'Unknown'} by {entry.changedByEmail || 'Unknown'}</EntityMeta>
<EntityMeta>{entry.description || 'No description.'}</EntityMeta>
{entry.affectedUserEmail && <EntityMeta>Affected: {entry.affectedUserEmail}</EntityMeta>}
</EntityRow>
))
}
</Panel>
<Panel>
<SectionHeading title="Households" right={<Text style={s.subtleText}>{households.length} total</Text>} />
{households.length === 0 ? <EmptyState>No households yet.</EmptyState> : households.map(h => {
const canManage = isSiteAdmin || h.isCurrentUserHouseholdAdmin
return (
<EntityRow key={h.id} selected={selectedHouseholdId === h.id}>
<View style={s.householdHeader}>
<Text style={s.strongText}>{h.name}</Text>
<View style={s.badgeRow}>
{h.isCurrentUserHouseholdAdmin && <View style={s.adminBadge}><Text style={s.badgeText}>Household admin</Text></View>}
</View>
</View>
<EntityMeta>{h.description || 'No description.'}</EntityMeta>
<EntityMeta>Created: {formatDate(h.createdAt) || 'Not available'}</EntityMeta>
{(h.members ?? []).map(member => (
<View key={member.userId} style={s.memberRow}>
<Text style={s.memberName}>{formatPersonName(member)}</Text>
<EntityMeta>{member.email}</EntityMeta>
</View>
))}
<EntityActions>
<Btn title="Select" variant="secondary" onPress={() => setSelectedHouseholdId(h.id)} />
{canManage && <Btn title="Edit" variant="secondary" onPress={() => { setSelectedHouseholdId(h.id); setEditingHouseholdId(h.id); setHouseholdForm({ name: h.name ?? '', description: h.description ?? '' }) }} />}
<Btn title="Leave" variant="danger" onPress={() => handleLeave(h.id)} />
</EntityActions>
</EntityRow>
)
})}
</Panel>
</ScrollView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
content: { padding: spacing.md },
authMsg: { padding: spacing.lg, textAlign: 'center', color: colors.textMuted, fontSize: fontSize.md },
statsGrid: { flexDirection: 'row', gap: spacing.sm },
statCard: { flex: 1, backgroundColor: colors.surfaceMuted, borderRadius: radius.sm, padding: spacing.sm, alignItems: 'center' },
statLabel: { fontSize: fontSize.xs, color: colors.textMuted },
statValue: { fontSize: fontSize.xl, fontWeight: '700', color: colors.text },
strongText: { fontSize: fontSize.md, fontWeight: '600', color: colors.text },
subtleText: { fontSize: fontSize.sm, color: colors.textMuted },
householdHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
badgeRow: { flexDirection: 'row', gap: spacing.xs },
adminBadge: { backgroundColor: colors.accent + '22', borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 },
badgeText: { fontSize: fontSize.xs, color: colors.accent },
memberRow: { paddingVertical: spacing.xs, borderTopWidth: 1, borderColor: colors.border, marginTop: spacing.xs },
memberName: { fontSize: fontSize.sm, fontWeight: '600', color: colors.text },
createdUserCard: { marginTop: spacing.sm, padding: spacing.sm, borderWidth: 1, borderColor: colors.border, borderRadius: radius.sm },
roleList: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginTop: spacing.xs },
roleBadge: { backgroundColor: colors.chipNeutral, borderRadius: radius.sm, paddingHorizontal: spacing.xs, paddingVertical: 2 },
roleBadgeText: { fontSize: fontSize.xs, color: colors.textSoft },
})