243 lines
6.6 KiB
TypeScript
243 lines
6.6 KiB
TypeScript
import React, { useState } from 'react';
|
||
import {
|
||
Alert,
|
||
KeyboardAvoidingView,
|
||
Platform,
|
||
Pressable,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
View,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||
import type { AttendanceCheckInResult } from '../api/types';
|
||
import { CheckInResultModal } from '../components/CheckInResultModal';
|
||
import type { RootStackParamList } from '../navigation/types';
|
||
import { performAppAssignBlank, performAppUndoCheckIn } from '../offline/checkInService';
|
||
import { useOffline } from '../offline/OfflineContext';
|
||
import { colors, spacing, typography } from '../theme';
|
||
import { AppIcon, PrimaryButton, TextField } from '../components/ui';
|
||
|
||
type Props = NativeStackScreenProps<RootStackParamList, 'AssignBlank'>;
|
||
|
||
export function AssignBlankScreen({ navigation, route }: Props) {
|
||
const {
|
||
eventId,
|
||
eventName,
|
||
attendanceId,
|
||
attendanceName,
|
||
participantId,
|
||
firstName: initialFirst,
|
||
lastName: initialLast,
|
||
email: initialEmail,
|
||
phone: initialPhone,
|
||
} = route.params;
|
||
const insets = useSafeAreaInsets();
|
||
const { refreshStatuses } = useOffline();
|
||
|
||
const [firstName, setFirstName] = useState(initialFirst || '');
|
||
const [lastName, setLastName] = useState(initialLast || '');
|
||
const [email, setEmail] = useState(initialEmail || '');
|
||
const [phone, setPhone] = useState(initialPhone || '');
|
||
const [saving, setSaving] = useState(false);
|
||
const [result, setResult] = useState<AttendanceCheckInResult | null>(null);
|
||
const [resultError, setResultError] = useState<string | null>(null);
|
||
const [pendingSync, setPendingSync] = useState(false);
|
||
const [modalVisible, setModalVisible] = useState(false);
|
||
const [undoLoading, setUndoLoading] = useState(false);
|
||
|
||
const save = async () => {
|
||
if (!firstName.trim()) {
|
||
setResult(null);
|
||
setResultError('First name is required');
|
||
setPendingSync(false);
|
||
setModalVisible(true);
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
const outcome = await performAppAssignBlank({
|
||
eventId,
|
||
attendanceId,
|
||
attendanceName,
|
||
participantId,
|
||
profile: { firstName, lastName, email, phone },
|
||
});
|
||
setSaving(false);
|
||
if (!outcome.ok) {
|
||
setResult(null);
|
||
setResultError(outcome.error);
|
||
setPendingSync(false);
|
||
} else {
|
||
setResult(outcome.result);
|
||
setResultError(null);
|
||
setPendingSync(outcome.pendingSync);
|
||
refreshStatuses().catch(() => undefined);
|
||
}
|
||
setModalVisible(true);
|
||
};
|
||
|
||
const closeModal = () => {
|
||
if (undoLoading) return;
|
||
setModalVisible(false);
|
||
if (result && !resultError) {
|
||
navigation.goBack();
|
||
return;
|
||
}
|
||
setResult(null);
|
||
setResultError(null);
|
||
setPendingSync(false);
|
||
};
|
||
|
||
const handleUndo = async () => {
|
||
if (!result) return;
|
||
setUndoLoading(true);
|
||
const outcome = await performAppUndoCheckIn({
|
||
eventId,
|
||
attendanceId,
|
||
attendanceName,
|
||
participantId: result.participantId,
|
||
});
|
||
setUndoLoading(false);
|
||
if (!outcome.ok) {
|
||
Alert.alert('Undo failed', outcome.error);
|
||
return;
|
||
}
|
||
setModalVisible(false);
|
||
setResult(null);
|
||
setResultError(null);
|
||
setPendingSync(false);
|
||
refreshStatuses().catch(() => undefined);
|
||
navigation.goBack();
|
||
};
|
||
|
||
return (
|
||
<View style={styles.wrap}>
|
||
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||
<View style={styles.headerBg} />
|
||
<Pressable onPress={() => navigation.goBack()} hitSlop={10} style={styles.backBtn}>
|
||
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
|
||
<Text style={styles.backLabel} numberOfLines={1}>
|
||
{eventName}
|
||
</Text>
|
||
</Pressable>
|
||
<Text style={styles.headerTitle}>Assign a Blank</Text>
|
||
<Text style={styles.headerSubtitle}>{attendanceName}</Text>
|
||
</View>
|
||
|
||
<KeyboardAvoidingView
|
||
style={styles.flex}
|
||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||
>
|
||
<ScrollView
|
||
contentContainerStyle={[
|
||
styles.form,
|
||
{ paddingBottom: Math.max(insets.bottom, 16) + 24 },
|
||
]}
|
||
keyboardShouldPersistTaps="handled"
|
||
>
|
||
<Text style={styles.lead}>
|
||
Update this spare badge’s details, then save to check the person in.
|
||
</Text>
|
||
<TextField
|
||
label="First name"
|
||
value={firstName}
|
||
onChangeText={setFirstName}
|
||
autoCapitalize="words"
|
||
autoCorrect={false}
|
||
/>
|
||
<TextField
|
||
label="Last name"
|
||
value={lastName}
|
||
onChangeText={setLastName}
|
||
autoCapitalize="words"
|
||
autoCorrect={false}
|
||
/>
|
||
<TextField
|
||
label="Email"
|
||
value={email}
|
||
onChangeText={setEmail}
|
||
keyboardType="email-address"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
<TextField
|
||
label="Phone"
|
||
value={phone}
|
||
onChangeText={setPhone}
|
||
keyboardType="phone-pad"
|
||
autoCorrect={false}
|
||
/>
|
||
<PrimaryButton
|
||
title="Save and check in"
|
||
loading={saving}
|
||
onPress={() => {
|
||
void save();
|
||
}}
|
||
/>
|
||
</ScrollView>
|
||
</KeyboardAvoidingView>
|
||
|
||
<CheckInResultModal
|
||
visible={modalVisible}
|
||
result={result}
|
||
error={resultError}
|
||
pendingSync={pendingSync}
|
||
onClose={closeModal}
|
||
onUndo={result ? handleUndo : undefined}
|
||
undoLoading={undoLoading}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
wrap: { flex: 1, backgroundColor: colors.background },
|
||
flex: { flex: 1 },
|
||
header: {
|
||
paddingHorizontal: spacing.xl,
|
||
paddingBottom: spacing.lg,
|
||
overflow: 'hidden',
|
||
},
|
||
headerBg: {
|
||
...StyleSheet.absoluteFill,
|
||
backgroundColor: colors.primary,
|
||
zIndex: 0,
|
||
},
|
||
backBtn: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
marginBottom: spacing.md,
|
||
alignSelf: 'flex-start',
|
||
zIndex: 1,
|
||
},
|
||
backLabel: {
|
||
color: 'rgba(255,255,255,0.85)',
|
||
fontSize: 14,
|
||
fontWeight: '500',
|
||
},
|
||
headerTitle: {
|
||
color: '#fff',
|
||
fontSize: 20,
|
||
fontWeight: '700',
|
||
marginBottom: 4,
|
||
zIndex: 1,
|
||
},
|
||
headerSubtitle: {
|
||
color: 'rgba(255,255,255,0.90)',
|
||
fontSize: 14,
|
||
zIndex: 1,
|
||
},
|
||
form: {
|
||
paddingHorizontal: spacing.lg,
|
||
paddingTop: spacing.lg,
|
||
gap: 16,
|
||
},
|
||
lead: {
|
||
...typography.small,
|
||
color: colors.muted,
|
||
marginBottom: 4,
|
||
},
|
||
});
|