278 lines
16 KiB
JavaScript
278 lines
16 KiB
JavaScript
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 },
|
|
})
|