Add blank-badge assignment and improve offline check-in flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-09-30 09:39:46 +05:30
parent 2f6b838939
commit 51e679d6ec
21 changed files with 1656 additions and 232 deletions

Binary file not shown.

View File

@ -34,9 +34,15 @@ If Gradle reports `Could not find org.asyncstorage.shared_storage:storage-androi
1. While online, open **Events** and tap the cloud icon (or long-press an event) → **Enable Offline**. 1. While online, open **Events** and tap the cloud icon (or long-press an event) → **Enable Offline**.
2. The app downloads active attendance sessions + participant rosters into local SQLite. 2. The app downloads active attendance sessions + participant rosters into local SQLite.
3. Check in via search or QR without internet; items queue in an outbox. 3. While Offline is on, search and QR check-ins always save locally (even with internet). They queue in an outbox.
4. Tap **Sync** (or wait for reconnect) to flush via `POST .../check-ins/sync`. 4. Every minute while online (app in the foreground), pending check-ins are **uploaded**. Reconnect and returning to the app also upload.
5. **Refresh Offline Data** updates the snapshot without clearing pending outbox rows. 5. Header **Sync** is two-way at that page’s scope:
- **Events** — all Offline-enabled events (upload pending + refresh all sheets)
- **Attendance sessions** — this event (upload pending + refresh all sheets)
- **Participants** — this attendance sheet only (upload pending + refresh this roster)
6. **Refresh Offline Data** (long-press an event) is a recovery pull of the snapshot without clearing pending outbox rows.
7. **Assign a Blank** (when enabled on the attendance session in the dashboard) scans a spare badge QR, lets staff edit name/email/phone, then saves — updating that participant and checking them in. It uses the same offline outbox as check-ins.
8. **Undo** on the check-in success sheet reverses the last check-in. For a later mistake, swipe a checked-in row left to reveal **Uncheck-in**. Staff-only; last check-in only; same offline queue. Does not revert Assign a Blank profile.
Native rebuild is required after installing `@op-engineering/op-sqlite` / NetInfo (`npm run android` / `npm run ios`). Native rebuild is required after installing `@op-engineering/op-sqlite` / NetInfo (`npm run android` / `npm run ios`).

49
package-lock.json generated
View File

@ -17,6 +17,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-config": "^1.6.1", "react-native-config": "^1.6.1",
"react-native-gesture-handler": "^2.28.0",
"react-native-get-random-values": "^2.0.0", "react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "^5.5.2", "react-native-safe-area-context": "^5.5.2",
"react-native-screens": "^4.25.1", "react-native-screens": "^4.25.1",
@ -2026,6 +2027,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
"license": "MIT",
"dependencies": {
"@types/hammerjs": "^2.0.36"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -3621,6 +3634,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -7121,6 +7140,21 @@
"hermes-estree": "0.33.3" "hermes-estree": "0.33.3"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -10465,6 +10499,21 @@
} }
} }
}, },
"node_modules/react-native-gesture-handler": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
"license": "MIT",
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-get-random-values": { "node_modules/react-native-get-random-values": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz", "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",

View File

@ -33,6 +33,30 @@ export const attendancesApi = {
); );
}, },
undoCheckIn(eventId: string, attendanceId: string, participantId: string) {
return api.post<AttendanceCheckInResult>(
`/events/${eventId}/attendances/${attendanceId}/undo-check-in`,
{ participantId },
);
},
assignBlank(
eventId: string,
attendanceId: string,
body: {
participantId: string;
firstName: string;
lastName?: string;
email?: string;
phone?: string;
},
) {
return api.post<AttendanceCheckInResult>(
`/events/${eventId}/attendances/${attendanceId}/assign-blank`,
body,
);
},
syncCheckIns( syncCheckIns(
eventId: string, eventId: string,
attendanceId: string, attendanceId: string,

View File

@ -43,6 +43,7 @@ export interface AttendanceRecord {
status: 'active' | 'archived'; status: 'active' | 'archived';
settings?: { settings?: {
allowDuplicateCheckIn?: boolean; allowDuplicateCheckIn?: boolean;
allowAssignBlank?: boolean;
publicLinkEnabled?: boolean; publicLinkEnabled?: boolean;
}; };
uniqueParticipants?: number; uniqueParticipants?: number;
@ -74,10 +75,19 @@ export interface AttendanceCheckInResult {
alreadyCheckedIn: boolean; alreadyCheckedIn: boolean;
} }
export interface AssignBlankProfile {
firstName: string;
lastName?: string;
email?: string;
phone?: string;
}
export interface OfflineSyncOperation { export interface OfflineSyncOperation {
clientOpId: string; clientOpId: string;
participantId: string; participantId: string;
checkedInAt: string; checkedInAt: string;
profile?: AssignBlankProfile;
action?: 'check_in' | 'undo';
} }
export interface OfflineSyncOpResult { export interface OfflineSyncOpResult {

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; import { Alert, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { AttendanceCheckInResult } from '../api/types'; import type { AttendanceCheckInResult } from '../api/types';
import { colors, radii, spacing, typography } from '../theme'; import { colors, radii, spacing, typography } from '../theme';
@ -11,6 +11,8 @@ type Props = {
error: string | null; error: string | null;
pendingSync?: boolean; pendingSync?: boolean;
onClose: () => void; onClose: () => void;
onUndo?: () => void | Promise<void>;
undoLoading?: boolean;
}; };
const CHECKIN_GREEN = '#16A34A'; const CHECKIN_GREEN = '#16A34A';
@ -22,8 +24,25 @@ export function CheckInResultModal({
error, error,
pendingSync = false, pendingSync = false,
onClose, onClose,
onUndo,
undoLoading = false,
}: Props) { }: Props) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const showUndo = Boolean(result && onUndo && !error);
const confirmUndo = () => {
if (!result || !onUndo || undoLoading) return;
Alert.alert('Undo check-in', `Undo check-in for ${result.displayName}?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Undo',
style: 'destructive',
onPress: () => {
void onUndo();
},
},
]);
};
return ( return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
@ -85,7 +104,23 @@ export function CheckInResultModal({
</> </>
) : null} ) : null}
<PrimaryButton title="Done" onPress={onClose} style={styles.doneBtn} /> <View style={styles.actions}>
{showUndo ? (
<Pressable
style={styles.undoBtn}
onPress={confirmUndo}
disabled={undoLoading}
>
<Text style={styles.undoText}>{undoLoading ? 'Undoing…' : 'Undo'}</Text>
</Pressable>
) : null}
<PrimaryButton
title="Done"
onPress={onClose}
style={showUndo ? styles.doneBtnFlex : styles.doneBtn}
disabled={undoLoading}
/>
</View>
</Pressable> </Pressable>
</Pressable> </Pressable>
</Modal> </Modal>
@ -177,7 +212,30 @@ const styles = StyleSheet.create({
color: PENDING_AMBER, color: PENDING_AMBER,
fontWeight: '600', fontWeight: '600',
}, },
doneBtn: { actions: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: spacing.sm, marginTop: spacing.sm,
}, },
undoBtn: {
height: 52,
paddingHorizontal: 18,
borderRadius: radii.md,
borderWidth: 1.5,
borderColor: colors.error,
alignItems: 'center',
justifyContent: 'center',
},
undoText: {
color: colors.error,
fontSize: 16,
fontWeight: '700',
},
doneBtn: {
flex: 1,
},
doneBtnFlex: {
flex: 1,
},
}); });

159
src/components/SwipeRow.tsx Normal file
View File

@ -0,0 +1,159 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
PanResponder,
Pressable,
StyleSheet,
Text,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { colors, radii } from '../theme';
const ACTION_WIDTH = 108;
const OPEN_THRESHOLD = 40;
type Props = {
enabled: boolean;
open: boolean;
onOpen: () => void;
onClose: () => void;
actionLabel: string;
onActionPress: () => void;
actionBusy?: boolean;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
};
export function SwipeRow({
enabled,
open,
onOpen,
onClose,
actionLabel,
onActionPress,
actionBusy = false,
children,
style,
}: Props) {
const translateX = useRef(new Animated.Value(0)).current;
const startX = useRef(0);
const openRef = useRef(open);
openRef.current = open;
const [rowHeight, setRowHeight] = useState(0);
const snapTo = (toValue: number, after?: () => void) => {
Animated.spring(translateX, {
toValue,
useNativeDriver: true,
bounciness: 0,
speed: 20,
}).start(({ finished }) => {
if (finished) after?.();
});
};
useEffect(() => {
snapTo(open ? -ACTION_WIDTH : 0);
}, [open]);
const pan = useMemo(
() =>
PanResponder.create({
onMoveShouldSetPanResponder: (_e, g) => {
if (!enabled) return false;
return Math.abs(g.dx) > 8 && Math.abs(g.dx) > Math.abs(g.dy);
},
onMoveShouldSetPanResponderCapture: (_e, g) => {
if (!enabled) return false;
return Math.abs(g.dx) > 8 && Math.abs(g.dx) > Math.abs(g.dy);
},
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
translateX.stopAnimation((value) => {
startX.current = value;
});
},
onPanResponderMove: (_e, g) => {
const next = Math.min(0, Math.max(-ACTION_WIDTH, startX.current + g.dx));
translateX.setValue(next);
},
onPanResponderRelease: (_e, g) => {
const projected = startX.current + g.dx;
const closingSwipe = openRef.current && (g.dx > OPEN_THRESHOLD || g.vx > 0.35);
const shouldOpen =
!closingSwipe && (projected < -OPEN_THRESHOLD || g.vx < -0.4);
if (shouldOpen) {
snapTo(-ACTION_WIDTH, onOpen);
} else {
snapTo(0, onClose);
}
},
onPanResponderTerminate: () => {
snapTo(openRef.current ? -ACTION_WIDTH : 0);
},
}),
[enabled, onClose, onOpen, translateX],
);
const onForegroundLayout = (e: LayoutChangeEvent) => {
const next = Math.round(e.nativeEvent.layout.height);
if (next > 0 && next !== rowHeight) setRowHeight(next);
};
if (!enabled) return <View style={style}>{children}</View>;
return (
<View style={style}>
<View style={styles.clip} {...pan.panHandlers}>
{rowHeight > 0 ? (
<View
style={[styles.actionWrap, { height: rowHeight }]}
pointerEvents={open ? 'auto' : 'none'}
>
<Pressable style={styles.action} onPress={actionBusy ? undefined : onActionPress}>
<Text style={styles.actionText}>{actionBusy ? '…' : actionLabel}</Text>
</Pressable>
</View>
) : null}
<Animated.View
style={[styles.foreground, { transform: [{ translateX }] }]}
onLayout={onForegroundLayout}
>
{children}
</Animated.View>
</View>
</View>
);
}
const styles = StyleSheet.create({
clip: {
overflow: 'hidden',
borderRadius: radii.xl,
},
actionWrap: {
position: 'absolute',
right: 0,
top: 0,
width: ACTION_WIDTH,
},
action: {
flex: 1,
backgroundColor: colors.error,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 10,
},
actionText: {
color: '#fff',
fontSize: 12,
fontWeight: '700',
textAlign: 'center',
},
foreground: {
backgroundColor: colors.card,
},
});

View File

@ -23,6 +23,7 @@ export type AppIconName =
| 'log-out-outline' | 'log-out-outline'
| 'mail-outline' | 'mail-outline'
| 'people-outline' | 'people-outline'
| 'person-add-outline'
| 'person-outline' | 'person-outline'
| 'qr-code-outline' | 'qr-code-outline'
| 'refresh-outline' | 'refresh-outline'

View File

@ -9,6 +9,7 @@ import { EventsScreen } from '../screens/EventsScreen';
import { AttendanceSessionsScreen } from '../screens/AttendanceSessionsScreen'; import { AttendanceSessionsScreen } from '../screens/AttendanceSessionsScreen';
import { ParticipantsScreen } from '../screens/ParticipantsScreen'; import { ParticipantsScreen } from '../screens/ParticipantsScreen';
import { QrScannerScreen } from '../screens/QrScannerScreen'; import { QrScannerScreen } from '../screens/QrScannerScreen';
import { AssignBlankScreen } from '../screens/AssignBlankScreen';
import type { RootStackParamList } from './types'; import type { RootStackParamList } from './types';
import { colors } from '../theme'; import { colors } from '../theme';
import { getAccessToken } from '../api/client'; import { getAccessToken } from '../api/client';
@ -89,6 +90,7 @@ export function RootNavigator() {
<Stack.Screen name="Events" component={EventsScreen} /> <Stack.Screen name="Events" component={EventsScreen} />
<Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} /> <Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} />
<Stack.Screen name="Participants" component={ParticipantsScreen} /> <Stack.Screen name="Participants" component={ParticipantsScreen} />
<Stack.Screen name="AssignBlank" component={AssignBlankScreen} />
<Stack.Screen <Stack.Screen
name="QrScanner" name="QrScanner"
component={QrScannerScreen} component={QrScannerScreen}

View File

@ -9,11 +9,26 @@ export type RootStackParamList = {
attendanceId: string; attendanceId: string;
attendanceName: string; attendanceName: string;
allowDuplicate: boolean; allowDuplicate: boolean;
allowAssignBlank: boolean;
}; };
QrScanner: { QrScanner: {
eventId: string; eventId: string;
eventName: string;
attendanceId: string; attendanceId: string;
attendanceName: string; attendanceName: string;
allowDuplicate: boolean; allowDuplicate: boolean;
allowAssignBlank?: boolean;
mode?: 'checkIn' | 'assignBlank';
};
AssignBlank: {
eventId: string;
eventName: string;
attendanceId: string;
attendanceName: string;
participantId: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}; };
}; };

View File

@ -9,7 +9,7 @@ import React, {
} from 'react'; } from 'react';
import { AppState, type AppStateStatus } from 'react-native'; import { AppState, type AppStateStatus } from 'react-native';
import { getDb } from './db'; import { getDb } from './db';
import { runSync, subscribeSyncEngine } from './syncEngine'; import { runSync, subscribeSyncEngine, type SyncOpts, type SyncResult } from './syncEngine';
import type { EventOfflineStatus } from './types'; import type { EventOfflineStatus } from './types';
import { getEventOfflineStatus, listOfflineEventIds } from './repos'; import { getEventOfflineStatus, listOfflineEventIds } from './repos';
@ -20,11 +20,13 @@ type OfflineContextValue = {
lastSyncError: string | null; lastSyncError: string | null;
statusByEvent: Record<string, EventOfflineStatus>; statusByEvent: Record<string, EventOfflineStatus>;
refreshStatuses: () => Promise<void>; refreshStatuses: () => Promise<void>;
syncNow: (eventId?: string) => Promise<{ ok: boolean; flushed: number; error?: string }>; syncNow: (opts?: SyncOpts) => Promise<SyncResult>;
}; };
const OfflineContext = createContext<OfflineContextValue | null>(null); const OfflineContext = createContext<OfflineContextValue | null>(null);
const AUTO_SYNC_MS = 60_000;
export function OfflineProvider({ children }: { children: React.ReactNode }) { export function OfflineProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [isOnline, setIsOnline] = useState(true); const [isOnline, setIsOnline] = useState(true);
@ -44,8 +46,8 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
const syncNow = useCallback( const syncNow = useCallback(
async (eventId?: string) => { async (opts?: SyncOpts) => {
const result = await runSync(eventId); const result = await runSync(opts);
await refreshStatuses(); await refreshStatuses();
return result; return result;
}, },
@ -81,7 +83,7 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
const online = Boolean(state.isConnected && state.isInternetReachable !== false); const online = Boolean(state.isConnected && state.isInternetReachable !== false);
setIsOnline(online); setIsOnline(online);
if (online) { if (online) {
syncNow().catch(() => undefined); syncNow({ pull: false }).catch(() => undefined);
} }
}); });
return () => unsub(); return () => unsub();
@ -90,13 +92,23 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
const onAppState = (next: AppStateStatus) => { const onAppState = (next: AppStateStatus) => {
if (next === 'active') { if (next === 'active') {
syncNow().catch(() => undefined); syncNow({ pull: false }).catch(() => undefined);
} }
}; };
const sub = AppState.addEventListener('change', onAppState); const sub = AppState.addEventListener('change', onAppState);
return () => sub.remove(); return () => sub.remove();
}, [syncNow]); }, [syncNow]);
const hasPacks = Object.values(statusByEvent).some((s) => s.enabled);
useEffect(() => {
if (!ready || !isOnline || !hasPacks) return;
const id = setInterval(() => {
syncNow({ pull: false }).catch(() => undefined);
}, AUTO_SYNC_MS);
return () => clearInterval(id);
}, [ready, isOnline, hasPacks, syncNow]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
ready, ready,

View File

@ -1,16 +1,18 @@
import NetInfo from '@react-native-community/netinfo'; import NetInfo from '@react-native-community/netinfo';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { attendancesApi } from '../api/attendances'; import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult } from '../api/types'; import type { AssignBlankProfile, AttendanceCheckInResult } from '../api/types';
import { getDeviceId } from './deviceId'; import { getDeviceId } from './deviceId';
import { import {
applyLocalCheckIn, applyLocalCheckIn,
countPendingForSession, applyLocalProfile,
applyLocalUndo,
deleteOutbox,
findLatestPendingOutboxForParticipant,
getOfflineParticipant, getOfflineParticipant,
getOfflineSession, getOfflineSession,
insertOutbox, insertOutbox,
isOfflinePackActive, isOfflinePackActive,
updateParticipantFromSync,
} from './repos'; } from './repos';
export type CheckInServiceResult = export type CheckInServiceResult =
@ -22,17 +24,27 @@ async function isOnline(): Promise<boolean> {
return Boolean(state.isConnected && state.isInternetReachable !== false); return Boolean(state.isConnected && state.isInternetReachable !== false);
} }
function displayNameFromProfile(profile: AssignBlankProfile, fallback?: string | null) {
const fromNames = [profile.firstName, profile.lastName].filter(Boolean).join(' ').trim();
return fromNames || profile.email || fallback || 'Participant';
}
async function queueLocalCheckIn(opts: { async function queueLocalCheckIn(opts: {
eventId: string; eventId: string;
attendanceId: string; attendanceId: string;
attendanceName: string; attendanceName: string;
participantId: string; participantId: string;
profile?: AssignBlankProfile;
}): Promise<CheckInServiceResult> { }): Promise<CheckInServiceResult> {
const session = await getOfflineSession(opts.attendanceId); const session = await getOfflineSession(opts.attendanceId);
if (!session) { if (!session) {
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' }; return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
} }
if (opts.profile && session.allowAssignBlank !== 1) {
return { ok: false, error: 'Assign a Blank is not enabled for this attendance session.' };
}
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId); const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
if (!participant) { if (!participant) {
return { return {
@ -57,10 +69,16 @@ async function queueLocalCheckIn(opts: {
participantId: opts.participantId, participantId: opts.participantId,
checkedInAt, checkedInAt,
deviceId, deviceId,
profile: opts.profile ?? null,
}); });
if (opts.profile) {
await applyLocalProfile(opts.attendanceId, opts.participantId, opts.profile);
}
await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt); await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt);
const nextCount = participant.checkInCount + 1; const nextCount = participant.checkInCount + 1;
const email = opts.profile?.email ?? participant.email ?? '';
const phone = opts.profile?.phone ?? participant.phone ?? '';
return { return {
ok: true, ok: true,
pendingSync: true, pendingSync: true,
@ -68,9 +86,11 @@ async function queueLocalCheckIn(opts: {
attendanceId: opts.attendanceId, attendanceId: opts.attendanceId,
attendanceName: opts.attendanceName || session.name, attendanceName: opts.attendanceName || session.name,
participantId: opts.participantId, participantId: opts.participantId,
displayName: participant.displayName, displayName: opts.profile
email: participant.email || '', ? displayNameFromProfile(opts.profile, participant.displayName)
phone: participant.phone || '', : participant.displayName,
email,
phone,
checkInCount: nextCount, checkInCount: nextCount,
lastCheckedInAt: checkedInAt, lastCheckedInAt: checkedInAt,
allowDuplicateCheckIn: allowDuplicate, allowDuplicateCheckIn: allowDuplicate,
@ -80,7 +100,7 @@ async function queueLocalCheckIn(opts: {
} }
/** /**
* Check-in: live API when online with empty outbox; otherwise local outbox. * Check-in: always local outbox when Offline is enabled; live API otherwise.
*/ */
export async function performAppCheckIn(opts: { export async function performAppCheckIn(opts: {
eventId: string; eventId: string;
@ -89,15 +109,12 @@ export async function performAppCheckIn(opts: {
participantId: string; participantId: string;
}): Promise<CheckInServiceResult> { }): Promise<CheckInServiceResult> {
const packActive = await isOfflinePackActive(opts.eventId); const packActive = await isOfflinePackActive(opts.eventId);
const pending = await countPendingForSession(opts.attendanceId); if (packActive) {
const online = await isOnline();
if (packActive && (!online || pending > 0)) {
return queueLocalCheckIn(opts); return queueLocalCheckIn(opts);
} }
const online = await isOnline();
if (!online) { if (!online) {
if (packActive) return queueLocalCheckIn(opts);
return { return {
ok: false, ok: false,
error: 'You are offline. Enable Offline for this event first to check in without internet.', error: 'You are offline. Enable Offline for this event first to check in without internet.',
@ -106,21 +123,136 @@ export async function performAppCheckIn(opts: {
const res = await attendancesApi.checkIn(opts.eventId, opts.attendanceId, opts.participantId); const res = await attendancesApi.checkIn(opts.eventId, opts.attendanceId, opts.participantId);
if (res.error || !res.data) { if (res.error || !res.data) {
// Network flake after we thought we were online — fall back to outbox if pack exists
if (res.status === 0 && packActive) {
return queueLocalCheckIn(opts);
}
return { ok: false, error: res.error || 'Check-in failed' }; return { ok: false, error: res.error || 'Check-in failed' };
} }
if (packActive) { return { ok: true, result: res.data, pendingSync: false };
await updateParticipantFromSync( }
opts.attendanceId,
opts.participantId, export async function performAppAssignBlank(opts: {
res.data.checkInCount, eventId: string;
res.data.lastCheckedInAt, attendanceId: string;
); attendanceName: string;
} participantId: string;
profile: AssignBlankProfile;
}): Promise<CheckInServiceResult> {
const firstName = opts.profile.firstName.trim();
if (!firstName) {
return { ok: false, error: 'First name is required' };
}
const profile: AssignBlankProfile = {
firstName,
lastName: (opts.profile.lastName || '').trim(),
email: (opts.profile.email || '').trim().toLowerCase(),
phone: (opts.profile.phone || '').trim(),
};
const packActive = await isOfflinePackActive(opts.eventId);
if (packActive) {
return queueLocalCheckIn({ ...opts, profile });
}
const online = await isOnline();
if (!online) {
return {
ok: false,
error: 'You are offline. Enable Offline for this event first to assign a blank badge.',
};
}
const res = await attendancesApi.assignBlank(opts.eventId, opts.attendanceId, {
participantId: opts.participantId,
...profile,
});
if (res.error || !res.data) {
return { ok: false, error: res.error || 'Assign blank failed' };
}
return { ok: true, result: res.data, pendingSync: false };
}
export async function performAppUndoCheckIn(opts: {
eventId: string;
attendanceId: string;
attendanceName: string;
participantId: string;
}): Promise<CheckInServiceResult> {
const packActive = await isOfflinePackActive(opts.eventId);
if (packActive) {
const session = await getOfflineSession(opts.attendanceId);
if (!session) {
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
}
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
if (!participant) {
return {
ok: false,
error: 'Participant not in offline pack. Refresh offline data while online.',
};
}
if (participant.alreadyCheckedIn !== 1 || participant.checkInCount < 1) {
return { ok: false, error: 'Participant is not checked in' };
}
const latest = await findLatestPendingOutboxForParticipant(
opts.attendanceId,
opts.participantId,
);
await applyLocalUndo(opts.attendanceId, opts.participantId);
let pendingSync = false;
if (latest && latest.action !== 'undo' && !latest.profile) {
await deleteOutbox(latest.clientOpId);
} else {
const clientOpId = uuidv4();
const deviceId = await getDeviceId();
await insertOutbox({
clientOpId,
eventId: opts.eventId,
attendanceId: opts.attendanceId,
participantId: opts.participantId,
checkedInAt: new Date().toISOString(),
deviceId,
action: 'undo',
});
pendingSync = true;
}
const nextCount = Math.max(0, participant.checkInCount - 1);
return {
ok: true,
pendingSync,
result: {
attendanceId: opts.attendanceId,
attendanceName: opts.attendanceName || session.name,
participantId: opts.participantId,
displayName: participant.displayName,
email: participant.email ?? '',
phone: participant.phone ?? '',
checkInCount: nextCount,
lastCheckedInAt: nextCount > 0 ? participant.lastCheckedInAt : null,
allowDuplicateCheckIn: session.allowDuplicateCheckIn === 1,
alreadyCheckedIn: nextCount > 0,
},
};
}
const online = await isOnline();
if (!online) {
return {
ok: false,
error: 'You are offline. Enable Offline for this event first to undo a check-in.',
};
}
const res = await attendancesApi.undoCheckIn(opts.eventId, opts.attendanceId, opts.participantId);
if (res.error || !res.data) {
const err =
res.status === 404
? 'Undo API not found on this server. Point the app at a backend that includes POST .../undo-check-in (restart local backend, or deploy it).'
: res.error || 'Undo check-in failed';
return { ok: false, error: err };
}
return { ok: true, result: res.data, pendingSync: false }; return { ok: true, result: res.data, pendingSync: false };
} }

View File

@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS offline_sessions (
name TEXT NOT NULL, name TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
allow_duplicate_check_in INTEGER NOT NULL DEFAULT 0, allow_duplicate_check_in INTEGER NOT NULL DEFAULT 0,
allow_assign_blank INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (event_id) REFERENCES offline_events(id) ON DELETE CASCADE FOREIGN KEY (event_id) REFERENCES offline_events(id) ON DELETE CASCADE
); );
@ -59,6 +60,8 @@ CREATE TABLE IF NOT EXISTS outbox (
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
last_error TEXT, last_error TEXT,
profile_json TEXT,
action TEXT NOT NULL DEFAULT 'check_in',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@ -68,6 +71,14 @@ CREATE INDEX IF NOT EXISTS idx_outbox_event ON outbox(event_id, status);
CREATE INDEX IF NOT EXISTS idx_outbox_session ON outbox(attendance_id, status); CREATE INDEX IF NOT EXISTS idx_outbox_session ON outbox(attendance_id, status);
`; `;
async function ensureColumn(instance: DB, table: string, column: string, ddl: string): Promise<void> {
const res = await instance.execute(`PRAGMA table_info(${table})`);
const has = res.rows.some((r) => String(r.name) === column);
if (!has) {
await instance.execute(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
}
export async function getDb(): Promise<DB> { export async function getDb(): Promise<DB> {
if (db) return db; if (db) return db;
if (!readyPromise) { if (!readyPromise) {
@ -80,6 +91,19 @@ export async function getDb(): Promise<DB> {
.filter(Boolean) .filter(Boolean)
.map((s) => [`${s};`] as [string]), .map((s) => [`${s};`] as [string]),
); );
await ensureColumn(
instance,
'offline_sessions',
'allow_assign_blank',
'allow_assign_blank INTEGER NOT NULL DEFAULT 0',
);
await ensureColumn(instance, 'outbox', 'profile_json', 'profile_json TEXT');
await ensureColumn(
instance,
'outbox',
'action',
"action TEXT NOT NULL DEFAULT 'check_in'",
);
db = instance; db = instance;
return instance; return instance;
})(); })();

View File

@ -5,8 +5,10 @@ import type {
OfflineEventRow, OfflineEventRow,
OfflineParticipantRow, OfflineParticipantRow,
OfflineSessionRow, OfflineSessionRow,
OutboxAction,
OutboxRow, OutboxRow,
OutboxStatus, OutboxStatus,
AssignBlankProfile,
} from './types'; } from './types';
function nowIso() { function nowIso() {
@ -87,14 +89,15 @@ export async function replaceSessionsForEvent(
await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]); await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]);
for (const s of sessions) { for (const s of sessions) {
await db.execute( await db.execute(
`INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in) `INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in, allow_assign_blank)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
[ [
s._id, s._id,
eventId, eventId,
s.name, s.name,
s.status, s.status,
s.settings?.allowDuplicateCheckIn ? 1 : 0, s.settings?.allowDuplicateCheckIn ? 1 : 0,
s.settings?.allowAssignBlank ? 1 : 0,
], ],
); );
} }
@ -112,6 +115,7 @@ export async function listOfflineSessions(eventId: string): Promise<OfflineSessi
name: String(row.name), name: String(row.name),
status: String(row.status), status: String(row.status),
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0, allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
allowAssignBlank: Number(row.allow_assign_blank) || 0,
})); }));
} }
@ -126,6 +130,7 @@ export async function getOfflineSession(attendanceId: string): Promise<OfflineSe
name: String(row.name), name: String(row.name),
status: String(row.status), status: String(row.status),
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0, allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
allowAssignBlank: Number(row.allow_assign_blank) || 0,
}; };
} }
@ -220,6 +225,36 @@ export async function getOfflineParticipant(
return mapParticipant(row as Record<string, unknown>); return mapParticipant(row as Record<string, unknown>);
} }
export async function applyLocalProfile(
attendanceId: string,
participantId: string,
profile: AssignBlankProfile,
): Promise<void> {
const db = await getDb();
const displayName =
[profile.firstName, profile.lastName].filter(Boolean).join(' ').trim() ||
profile.email ||
'Participant';
await db.execute(
`UPDATE offline_participants
SET first_name = ?,
last_name = ?,
email = ?,
phone = ?,
display_name = ?
WHERE attendance_id = ? AND participant_id = ?`,
[
profile.firstName,
profile.lastName ?? '',
profile.email ?? '',
profile.phone ?? '',
displayName,
attendanceId,
participantId,
],
);
}
export async function applyLocalCheckIn( export async function applyLocalCheckIn(
attendanceId: string, attendanceId: string,
participantId: string, participantId: string,
@ -236,6 +271,18 @@ export async function applyLocalCheckIn(
); );
} }
export async function applyLocalUndo(attendanceId: string, participantId: string): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE offline_participants
SET already_checked_in = CASE WHEN check_in_count <= 1 THEN 0 ELSE 1 END,
last_checked_in_at = CASE WHEN check_in_count <= 1 THEN NULL ELSE last_checked_in_at END,
check_in_count = MAX(0, check_in_count - 1)
WHERE attendance_id = ? AND participant_id = ?`,
[attendanceId, participantId],
);
}
export async function updateParticipantFromSync( export async function updateParticipantFromSync(
attendanceId: string, attendanceId: string,
participantId: string, participantId: string,
@ -260,14 +307,16 @@ export async function insertOutbox(row: {
participantId: string; participantId: string;
checkedInAt: string; checkedInAt: string;
deviceId: string; deviceId: string;
profile?: AssignBlankProfile | null;
action?: OutboxAction;
}): Promise<void> { }): Promise<void> {
const db = await getDb(); const db = await getDb();
const ts = nowIso(); const ts = nowIso();
await db.execute( await db.execute(
`INSERT INTO outbox ( `INSERT INTO outbox (
client_op_id, event_id, attendance_id, participant_id, checked_in_at, client_op_id, event_id, attendance_id, participant_id, checked_in_at,
device_id, status, last_error, created_at, updated_at device_id, status, last_error, profile_json, action, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?, ?, ?)`,
[ [
row.clientOpId, row.clientOpId,
row.eventId, row.eventId,
@ -275,12 +324,29 @@ export async function insertOutbox(row: {
row.participantId, row.participantId,
row.checkedInAt, row.checkedInAt,
row.deviceId, row.deviceId,
row.profile ? JSON.stringify(row.profile) : null,
row.action || 'check_in',
ts, ts,
ts, ts,
], ],
); );
} }
function parseOutboxAction(raw: unknown): OutboxAction {
return String(raw) === 'undo' ? 'undo' : 'check_in';
}
function parseProfileJson(raw: unknown): AssignBlankProfile | null {
if (raw == null || raw === '') return null;
try {
const parsed = JSON.parse(String(raw)) as AssignBlankProfile;
if (!parsed || typeof parsed.firstName !== 'string') return null;
return parsed;
} catch {
return null;
}
}
function mapOutbox(row: Record<string, unknown>): OutboxRow { function mapOutbox(row: Record<string, unknown>): OutboxRow {
return { return {
clientOpId: String(row.client_op_id), clientOpId: String(row.client_op_id),
@ -293,22 +359,50 @@ function mapOutbox(row: Record<string, unknown>): OutboxRow {
lastError: row.last_error != null ? String(row.last_error) : null, lastError: row.last_error != null ? String(row.last_error) : null,
createdAt: String(row.created_at), createdAt: String(row.created_at),
updatedAt: String(row.updated_at), updatedAt: String(row.updated_at),
profile: parseProfileJson(row.profile_json),
action: parseOutboxAction(row.action),
}; };
} }
export async function listPendingOutbox(eventId?: string): Promise<OutboxRow[]> { export async function listPendingOutbox(scope?: {
eventId?: string;
attendanceId?: string;
}): Promise<OutboxRow[]> {
const db = await getDb(); const db = await getDb();
const res = eventId const res = scope?.attendanceId
? await db.execute( ? await db.execute(
`SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`, `SELECT * FROM outbox WHERE attendance_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
[eventId], [scope.attendanceId],
) )
: await db.execute( : scope?.eventId
`SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`, ? await db.execute(
); `SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
[scope.eventId],
)
: await db.execute(
`SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`,
);
return res.rows.map((r) => mapOutbox(r as Record<string, unknown>)); return res.rows.map((r) => mapOutbox(r as Record<string, unknown>));
} }
/** Overlay leftover outbox rows onto the local roster after a server pull. */
export async function reapplyPendingOutboxToRoster(scope?: {
eventId?: string;
attendanceId?: string;
}): Promise<void> {
const pending = await listPendingOutbox(scope);
for (const row of pending) {
if (row.action === 'undo') {
await applyLocalUndo(row.attendanceId, row.participantId);
continue;
}
if (row.profile) {
await applyLocalProfile(row.attendanceId, row.participantId, row.profile);
}
await applyLocalCheckIn(row.attendanceId, row.participantId, row.checkedInAt);
}
}
export async function listConflictOutbox(eventId: string): Promise<OutboxRow[]> { export async function listConflictOutbox(eventId: string): Promise<OutboxRow[]> {
const db = await getDb(); const db = await getDb();
const res = await db.execute( const res = await db.execute(
@ -340,6 +434,27 @@ export async function countPendingForSession(attendanceId: string): Promise<numb
return Number(res.rows[0]?.c) || 0; return Number(res.rows[0]?.c) || 0;
} }
export async function findLatestPendingOutboxForParticipant(
attendanceId: string,
participantId: string,
): Promise<OutboxRow | null> {
const db = await getDb();
const res = await db.execute(
`SELECT * FROM outbox
WHERE attendance_id = ? AND participant_id = ? AND status IN ('pending', 'syncing')
ORDER BY created_at DESC LIMIT 1`,
[attendanceId, participantId],
);
const row = res.rows[0];
if (!row) return null;
return mapOutbox(row as Record<string, unknown>);
}
export async function deleteOutbox(clientOpId: string): Promise<void> {
const db = await getDb();
await db.execute(`DELETE FROM outbox WHERE client_op_id = ?`, [clientOpId]);
}
export async function markOutboxStatus( export async function markOutboxStatus(
clientOpId: string, clientOpId: string,
status: OutboxStatus, status: OutboxStatus,

View File

@ -1,16 +1,33 @@
import NetInfo from '@react-native-community/netinfo'; import NetInfo from '@react-native-community/netinfo';
import { attendancesApi } from '../api/attendances'; import { attendancesApi } from '../api/attendances';
import { getDeviceId } from './deviceId'; import { getDeviceId } from './deviceId';
import { refreshSessionRoster } from './download'; import { refreshOfflinePack, refreshSessionRoster } from './download';
import { import {
getOfflineEvent,
getOfflineSession,
listOfflineEventIds,
listPendingOutbox, listPendingOutbox,
markOutboxStatus, markOutboxStatus,
markOutboxSyncing, markOutboxSyncing,
reapplyPendingOutboxToRoster,
setEventLastSyncedAt, setEventLastSyncedAt,
updateParticipantFromSync, updateParticipantFromSync,
} from './repos'; } from './repos';
import type { SyncOpResult } from './types'; import type { SyncOpResult } from './types';
export type SyncOpts = {
eventId?: string;
attendanceId?: string;
pull?: boolean;
};
export type SyncResult = {
ok: boolean;
flushed: number;
pulled: boolean;
error?: string;
};
type SyncListener = (state: { syncing: boolean; lastError: string | null }) => void; type SyncListener = (state: { syncing: boolean; lastError: string | null }) => void;
let syncing = false; let syncing = false;
@ -31,16 +48,75 @@ export function isSyncEngineRunning(): boolean {
return syncing; return syncing;
} }
export function formatSyncAlert(result: SyncResult): string {
if (!result.ok && result.error && result.flushed === 0 && !result.pulled) {
return result.error;
}
const bits: string[] = [];
if (result.flushed > 0) {
bits.push(`Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}`);
}
if (result.pulled) {
bits.push(result.flushed > 0 ? 'roster refreshed' : 'Roster updated');
}
if (!bits.length) {
return result.error || 'Already up to date.';
}
const msg = bits.join(' · ');
return result.error ? `${msg}. ${result.error}` : msg;
}
function mergeSyncOpts(a: SyncOpts, b: SyncOpts): SyncOpts {
const pull = Boolean(a.pull || b.pull);
if (!a.eventId || !b.eventId || a.eventId !== b.eventId) {
return { pull };
}
if (!a.attendanceId || !b.attendanceId || a.attendanceId !== b.attendanceId) {
return { eventId: a.eventId, pull };
}
return { eventId: a.eventId, attendanceId: a.attendanceId, pull };
}
type Deferred = {
promise: Promise<SyncResult>;
resolve: (r: SyncResult) => void;
};
function createDeferred(): Deferred {
let resolve: (r: SyncResult) => void = () => undefined;
const promise = new Promise<SyncResult>((res) => {
resolve = res;
});
return { promise, resolve };
}
let running = false;
let queuedOpts: SyncOpts | null = null;
let queuedDeferred: Deferred | null = null;
async function flushGroup( async function flushGroup(
eventId: string, eventId: string,
attendanceId: string, attendanceId: string,
ops: { clientOpId: string; participantId: string; checkedInAt: string }[], ops: {
clientOpId: string;
participantId: string;
checkedInAt: string;
profile?: { firstName: string; lastName?: string; email?: string; phone?: string } | null;
action?: 'check_in' | 'undo';
}[],
deviceId: string, deviceId: string,
refreshRoster: boolean,
): Promise<string | null> { ): Promise<string | null> {
await markOutboxSyncing(ops.map((o) => o.clientOpId)); await markOutboxSyncing(ops.map((o) => o.clientOpId));
const res = await attendancesApi.syncCheckIns(eventId, attendanceId, { const res = await attendancesApi.syncCheckIns(eventId, attendanceId, {
deviceId, deviceId,
operations: ops, operations: ops.map((o) => ({
clientOpId: o.clientOpId,
participantId: o.participantId,
checkedInAt: o.checkedInAt,
...(o.profile ? { profile: o.profile } : {}),
...(o.action === 'undo' ? { action: 'undo' as const } : {}),
})),
}); });
if (res.error || !res.data) { if (res.error || !res.data) {
@ -88,38 +164,70 @@ async function flushGroup(
} }
} }
// Light roster refresh to pick up other devices if (refreshRoster) {
await refreshSessionRoster(eventId, attendanceId); const rr = await refreshSessionRoster(eventId, attendanceId);
if (rr.ok) {
await reapplyPendingOutboxToRoster({ attendanceId });
}
}
return null; return null;
} }
/** async function pullSheets(opts: SyncOpts): Promise<{ eventIds: string[]; error?: string }> {
* Flush pending outbox. Optionally scope to one event. if (opts.attendanceId) {
*/ let eventId = opts.eventId;
export async function runSync(eventId?: string): Promise<{ if (!eventId) {
ok: boolean; const session = await getOfflineSession(opts.attendanceId);
flushed: number; eventId = session?.eventId;
error?: string; }
}> { if (!eventId) {
if (syncing) { return { eventIds: [], error: 'Offline pack missing this attendance session.' };
return { ok: false, flushed: 0, error: 'Sync already in progress' }; }
const res = await refreshSessionRoster(eventId, opts.attendanceId);
if (!res.ok) {
return { eventIds: [], error: res.error };
}
await reapplyPendingOutboxToRoster({ attendanceId: opts.attendanceId });
return { eventIds: [eventId] };
} }
const ids = opts.eventId ? [opts.eventId] : await listOfflineEventIds();
let lastError: string | undefined;
const eventIds: string[] = [];
for (const id of ids) {
const event = await getOfflineEvent(id);
if (!event) continue;
const res = await refreshOfflinePack({
_id: event.id,
name: event.name,
startDate: event.startDate ?? undefined,
endDate: event.endDate ?? undefined,
location: event.location ?? undefined,
});
if (!res.ok) {
lastError = res.error;
continue;
}
eventIds.push(id);
await reapplyPendingOutboxToRoster({ eventId: id });
}
return { eventIds, error: lastError };
}
async function executeSync(opts: SyncOpts): Promise<SyncResult> {
const net = await NetInfo.fetch(); const net = await NetInfo.fetch();
if (!net.isConnected || net.isInternetReachable === false) { if (!net.isConnected || net.isInternetReachable === false) {
return { ok: false, flushed: 0, error: 'No internet connection' }; return { ok: false, flushed: 0, pulled: false, error: 'No internet connection' };
} }
syncing = true; const scope = { eventId: opts.eventId, attendanceId: opts.attendanceId };
emit(null); const pending = await listPendingOutbox(scope);
const pull = Boolean(opts.pull);
try { let flushed = 0;
const pending = await listPendingOutbox(eventId); let lastError: string | null = null;
if (!pending.length) { const touchedEvents = new Set<string>();
if (eventId) await setEventLastSyncedAt(eventId, new Date().toISOString());
return { ok: true, flushed: 0 };
}
if (pending.length) {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
const groups = new Map<string, typeof pending>(); const groups = new Map<string, typeof pending>();
for (const row of pending) { for (const row of pending) {
@ -129,14 +237,9 @@ export async function runSync(eventId?: string): Promise<{
groups.set(key, list); groups.set(key, list);
} }
let flushed = 0;
let lastError: string | null = null;
const touchedEvents = new Set<string>();
for (const [key, rows] of groups) { for (const [key, rows] of groups) {
const [eid, aid] = key.split('::'); const [eid, aid] = key.split('::');
touchedEvents.add(eid); touchedEvents.add(eid);
// Chunk 200
for (let i = 0; i < rows.length; i += 200) { for (let i = 0; i < rows.length; i += 200) {
const chunk = rows.slice(i, i + 200); const chunk = rows.slice(i, i + 200);
const err = await flushGroup( const err = await flushGroup(
@ -146,29 +249,80 @@ export async function runSync(eventId?: string): Promise<{
clientOpId: r.clientOpId, clientOpId: r.clientOpId,
participantId: r.participantId, participantId: r.participantId,
checkedInAt: r.checkedInAt, checkedInAt: r.checkedInAt,
profile: r.profile,
action: r.action,
})), })),
deviceId, deviceId,
!pull,
); );
if (err) lastError = err; if (err) lastError = err;
else flushed += chunk.length; else flushed += chunk.length;
} }
} }
const ts = new Date().toISOString();
for (const eid of touchedEvents) {
await setEventLastSyncedAt(eid, ts);
}
emit(lastError);
return lastError
? { ok: false, flushed, error: lastError }
: { ok: true, flushed };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Sync failed';
emit(msg);
return { ok: false, flushed: 0, error: msg };
} finally {
syncing = false;
emit(null);
} }
let pulled = false;
if (pull) {
const pullResult = await pullSheets(opts);
for (const id of pullResult.eventIds) touchedEvents.add(id);
if (pullResult.error) {
lastError = lastError || pullResult.error;
}
if (pullResult.eventIds.length) {
pulled = true;
}
}
const ts = new Date().toISOString();
for (const eid of touchedEvents) {
await setEventLastSyncedAt(eid, ts);
}
emit(lastError);
return lastError
? { ok: false, flushed, pulled, error: lastError }
: { ok: true, flushed, pulled };
}
/**
* Flush pending outbox, optionally pull latest sheets.
* Overlapping calls are queued and merged (widest scope + pull wins).
*/
export function runSync(opts: SyncOpts = {}): Promise<SyncResult> {
if (running) {
queuedOpts = queuedOpts ? mergeSyncOpts(queuedOpts, opts) : { ...opts };
if (!queuedDeferred) queuedDeferred = createDeferred();
return queuedDeferred.promise;
}
running = true;
syncing = true;
emit(null);
return (async () => {
try {
const first = await executeSync(opts);
while (queuedOpts) {
const next = queuedOpts;
const deferred = queuedDeferred;
queuedOpts = null;
queuedDeferred = null;
const queuedResult = await executeSync(next);
deferred?.resolve(queuedResult);
}
return first;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Sync failed';
const fail: SyncResult = { ok: false, flushed: 0, pulled: false, error: msg };
queuedDeferred?.resolve(fail);
queuedOpts = null;
queuedDeferred = null;
emit(msg);
return fail;
} finally {
running = false;
syncing = false;
emit(null);
}
})();
} }

View File

@ -17,6 +17,7 @@ export type OfflineSessionRow = {
name: string; name: string;
status: string; status: string;
allowDuplicateCheckIn: number; allowDuplicateCheckIn: number;
allowAssignBlank: number;
}; };
export type OfflineParticipantRow = { export type OfflineParticipantRow = {
@ -33,6 +34,15 @@ export type OfflineParticipantRow = {
lastCheckedInAt: string | null; lastCheckedInAt: string | null;
}; };
export type AssignBlankProfile = {
firstName: string;
lastName?: string;
email?: string;
phone?: string;
};
export type OutboxAction = 'check_in' | 'undo';
export type OutboxRow = { export type OutboxRow = {
clientOpId: string; clientOpId: string;
eventId: string; eventId: string;
@ -44,6 +54,8 @@ export type OutboxRow = {
lastError: string | null; lastError: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
profile: AssignBlankProfile | null;
action: OutboxAction;
}; };
export type SyncOpResultStatus = 'applied' | 'duplicate' | 'conflict' | 'rejected'; export type SyncOpResultStatus = 'applied' | 'duplicate' | 'conflict' | 'rejected';

View File

@ -0,0 +1,242 @@
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,
},
});

View File

@ -15,6 +15,7 @@ import { attendancesApi } from '../api/attendances';
import type { AttendanceRecord } from '../api/types'; import type { AttendanceRecord } from '../api/types';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { useOffline } from '../offline/OfflineContext'; import { useOffline } from '../offline/OfflineContext';
import { formatSyncAlert } from '../offline/syncEngine';
import { import {
dismissConflict, dismissConflict,
getOfflineEvent, getOfflineEvent,
@ -31,6 +32,13 @@ const SESSION_ICONS: AppIconName[] = ['business-outline', 'people-outline'];
const CHECKIN_GREEN = '#22C55E'; const CHECKIN_GREEN = '#22C55E';
const PEOPLE_BLUE = '#60A5FA'; const PEOPLE_BLUE = '#60A5FA';
function formatLastSynced(iso?: string | null) {
if (!iso) return 'not yet';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'not yet';
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function SessionsHeader({ function SessionsHeader({
eventName, eventName,
onBack, onBack,
@ -39,6 +47,7 @@ function SessionsHeader({
pendingCount, pendingCount,
isOnline, isOnline,
offlineReady, offlineReady,
lastSyncedAt,
}: { }: {
eventName: string; eventName: string;
onBack: () => void; onBack: () => void;
@ -47,6 +56,7 @@ function SessionsHeader({
pendingCount: number; pendingCount: number;
isOnline: boolean; isOnline: boolean;
offlineReady: boolean; offlineReady: boolean;
lastSyncedAt: string | null;
}) { }) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@ -85,7 +95,7 @@ function SessionsHeader({
<Text style={styles.headerSubtitle}> <Text style={styles.headerSubtitle}>
{offlineReady {offlineReady
? isOnline ? isOnline
? 'Offline pack ready · Select a session' ? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
: 'Working offline · Select a session' : 'Working offline · Select a session'
: 'Select an attendance session'} : 'Select an attendance session'}
</Text> </Text>
@ -115,6 +125,41 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null); setError(null);
if (await getOfflineEvent(eventId)) {
const local = await listOfflineSessions(eventId);
if (!local.length) {
setError(
isOnline
? 'No active attendance sessions.'
: 'No offline data for this event. Go online and Enable Offline first.',
);
setSessions([]);
setFromCache(true);
return;
}
const mapped: AttendanceRecord[] = [];
for (const s of local) {
const participants = await listOfflineParticipants(s.id);
const unique = participants.filter((p) => p.alreadyCheckedIn).length;
const total = participants.reduce((n, p) => n + (p.checkInCount || 0), 0);
mapped.push({
_id: s.id,
name: s.name,
status: s.status as 'active' | 'archived',
settings: {
allowDuplicateCheckIn: s.allowDuplicateCheckIn === 1,
allowAssignBlank: s.allowAssignBlank === 1,
},
uniqueParticipants: unique,
totalCheckedIn: total,
});
}
setSessions(mapped);
setFromCache(true);
return;
}
if (isOnline) { if (isOnline) {
const res = await attendancesApi.list(eventId); const res = await attendancesApi.list(eventId);
if (!res.error && res.data) { if (!res.error && res.data) {
@ -123,64 +168,35 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
setFromCache(false); setFromCache(false);
return; return;
} }
// fall through to cache on failure setError(res.error || 'Failed to load sessions');
if (!(await getOfflineEvent(eventId))) {
setError(res.error || 'Failed to load sessions');
setSessions([]);
setFromCache(false);
return;
}
}
const local = await listOfflineSessions(eventId);
if (!local.length) {
setError(
isOnline
? 'No active attendance sessions.'
: 'No offline data for this event. Go online and Enable Offline first.',
);
setSessions([]); setSessions([]);
setFromCache(false); setFromCache(false);
return; return;
} }
const mapped: AttendanceRecord[] = []; setError('No offline data for this event. Go online and Enable Offline first.');
for (const s of local) { setSessions([]);
const participants = await listOfflineParticipants(s.id); setFromCache(false);
const unique = participants.filter((p) => p.alreadyCheckedIn).length;
const total = participants.reduce((n, p) => n + (p.checkInCount || 0), 0);
mapped.push({
_id: s.id,
name: s.name,
status: s.status as 'active' | 'archived',
settings: { allowDuplicateCheckIn: s.allowDuplicateCheckIn === 1 },
uniqueParticipants: unique,
totalCheckedIn: total,
});
}
setSessions(mapped);
setFromCache(true);
}, [eventId, isOnline]); }, [eventId, isOnline]);
useEffect(() => { useEffect(() => {
const unsub = navigation.addListener('focus', () => {
load();
refreshStatuses();
});
(async () => { (async () => {
setLoading(true); setLoading(true);
await load(); await load();
await refreshStatuses(); await refreshStatuses();
setLoading(false); setLoading(false);
})(); })();
}, [load, refreshStatuses]); return unsub;
}, [navigation, load, refreshStatuses]);
const handleSync = async () => { const handleSync = async () => {
const result = await syncNow(eventId); const result = await syncNow({ eventId, pull: true });
await load(); await load();
if (!result.ok && result.error) { Alert.alert('Sync', formatSyncAlert(result));
Alert.alert('Sync', result.error);
} else if (result.flushed > 0) {
Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
} else {
Alert.alert('Sync', 'Nothing pending to sync.');
}
const conflicts = await listConflictOutbox(eventId); const conflicts = await listConflictOutbox(eventId);
if (conflicts.length) { if (conflicts.length) {
@ -218,6 +234,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
attendanceId: item._id, attendanceId: item._id,
attendanceName: item.name, attendanceName: item.name,
allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn), allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
allowAssignBlank: Boolean(item.settings?.allowAssignBlank),
}) })
} }
> >
@ -265,6 +282,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
pendingCount={status?.pendingCount ?? 0} pendingCount={status?.pendingCount ?? 0}
isOnline={isOnline} isOnline={isOnline}
offlineReady={offlineReady} offlineReady={offlineReady}
lastSyncedAt={status?.lastSyncedAt ?? null}
/> />
{fromCache ? ( {fromCache ? (

View File

@ -18,6 +18,7 @@ import type { CheckinsEvent } from '../api/types';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { downloadOfflinePack, refreshOfflinePack } from '../offline/download'; import { downloadOfflinePack, refreshOfflinePack } from '../offline/download';
import { useOffline } from '../offline/OfflineContext'; import { useOffline } from '../offline/OfflineContext';
import { formatSyncAlert } from '../offline/syncEngine';
import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos'; import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { colors, radii, shadows, spacing, typography } from '../theme'; import { colors, radii, shadows, spacing, typography } from '../theme';
@ -47,6 +48,7 @@ function EventsHeader({
syncing, syncing,
pendingTotal, pendingTotal,
isOnline, isOnline,
hasOfflinePacks,
}: { }: {
userEmail?: string | null; userEmail?: string | null;
onLogout: () => void; onLogout: () => void;
@ -54,6 +56,7 @@ function EventsHeader({
syncing: boolean; syncing: boolean;
pendingTotal: number; pendingTotal: number;
isOnline: boolean; isOnline: boolean;
hasOfflinePacks: boolean;
}) { }) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@ -74,7 +77,7 @@ function EventsHeader({
onPress={onSync} onPress={onSync}
hitSlop={10} hitSlop={10}
style={styles.syncBtn} style={styles.syncBtn}
disabled={syncing || !isOnline} disabled={syncing || !isOnline || !hasOfflinePacks}
> >
{syncing ? ( {syncing ? (
<ActivityIndicator color="#fff" size="small" /> <ActivityIndicator color="#fff" size="small" />
@ -191,14 +194,9 @@ export function EventsScreen({ navigation }: Props) {
}; };
const handleSync = async () => { const handleSync = async () => {
const result = await syncNow(); const result = await syncNow({ pull: true });
if (!result.ok && result.error) { await load();
Alert.alert('Sync', result.error); Alert.alert('Sync', formatSyncAlert(result));
} else if (result.flushed > 0) {
Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
} else {
Alert.alert('Sync', 'Nothing pending to sync.');
}
}; };
const enableOffline = async (item: CheckinsEvent) => { const enableOffline = async (item: CheckinsEvent) => {
@ -285,6 +283,7 @@ export function EventsScreen({ navigation }: Props) {
}; };
const pendingTotal = Object.values(statusByEvent).reduce((n, s) => n + s.pendingCount, 0); const pendingTotal = Object.values(statusByEvent).reduce((n, s) => n + s.pendingCount, 0);
const hasOfflinePacks = Object.values(statusByEvent).some((s) => s.enabled);
const renderItem = ({ item }: { item: CheckinsEvent }) => { const renderItem = ({ item }: { item: CheckinsEvent }) => {
const sessionCount = item.attendanceSessionCount; const sessionCount = item.attendanceSessionCount;
@ -390,6 +389,7 @@ export function EventsScreen({ navigation }: Props) {
syncing={syncing} syncing={syncing}
pendingTotal={pendingTotal} pendingTotal={pendingTotal}
isOnline={isOnline} isOnline={isOnline}
hasOfflinePacks={hasOfflinePacks}
/> />
{loading ? ( {loading ? (

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Pressable, Pressable,
StyleSheet, StyleSheet,
@ -13,9 +14,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { attendancesApi } from '../api/attendances'; import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types'; import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal'; import { CheckInResultModal } from '../components/CheckInResultModal';
import { SwipeRow } from '../components/SwipeRow';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { performAppCheckIn } from '../offline/checkInService'; import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService';
import { useOffline } from '../offline/OfflineContext'; import { useOffline } from '../offline/OfflineContext';
import { formatSyncAlert } from '../offline/syncEngine';
import { import {
countPendingForSession, countPendingForSession,
isOfflinePackActive, isOfflinePackActive,
@ -51,18 +54,41 @@ function formatCheckInTime(iso?: string | null) {
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true }); return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
} }
function formatLastSynced(iso?: string | null) {
if (!iso) return 'not yet';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'not yet';
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function ParticipantsHeader({ function ParticipantsHeader({
eventName, eventName,
sessionName, sessionName,
checkedInCount, checkedInCount,
totalCount, totalCount,
onlyCheckedIn,
onToggleCheckedIn,
onBack, onBack,
onSync,
syncing,
pendingCount,
isOnline,
offlineReady,
lastSyncedAt,
}: { }: {
eventName: string; eventName: string;
sessionName: string; sessionName: string;
checkedInCount: number; checkedInCount: number;
totalCount: number; totalCount: number;
onlyCheckedIn: boolean;
onToggleCheckedIn: () => void;
onBack: () => void; onBack: () => void;
onSync: () => void;
syncing: boolean;
pendingCount: number;
isOnline: boolean;
offlineReady: boolean;
lastSyncedAt: string | null;
}) { }) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@ -71,12 +97,31 @@ function ParticipantsHeader({
<View style={styles.headerBg} /> <View style={styles.headerBg} />
<View style={styles.headerCircle} /> <View style={styles.headerCircle} />
<Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}> <View style={styles.headerTopRow}>
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" /> <Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}>
<Text style={styles.backLabel} numberOfLines={1}> <AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
{eventName} <Text style={styles.backLabel} numberOfLines={1}>
</Text> {eventName}
</Pressable> </Text>
</Pressable>
{offlineReady ? (
<Pressable
onPress={onSync}
hitSlop={10}
style={styles.syncBtn}
disabled={syncing || !isOnline}
>
{syncing ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<AppIcon name="cloud-upload-outline" size={16} color="#fff" />
)}
<Text style={styles.syncText}>
Sync{pendingCount > 0 ? ` (${pendingCount})` : ''}
</Text>
</Pressable>
) : null}
</View>
<View style={styles.headerMain}> <View style={styles.headerMain}>
<View style={styles.headerTextCol}> <View style={styles.headerTextCol}>
@ -84,13 +129,29 @@ function ParticipantsHeader({
{sessionName} {sessionName}
</Text> </Text>
<Text style={styles.headerSubtitle}> <Text style={styles.headerSubtitle}>
Attendance · {checkedInCount}/{totalCount} checked in {offlineReady
? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
: `Attendance · ${checkedInCount}/${totalCount} checked in`}
</Text> </Text>
</View> </View>
<View style={styles.checkedBadge}> <Pressable
<Text style={styles.checkedBadgeCount}>{checkedInCount}</Text> onPress={onToggleCheckedIn}
<Text style={styles.checkedBadgeLabel}>checked in</Text> accessibilityRole="button"
</View> accessibilityState={{ selected: onlyCheckedIn }}
accessibilityLabel="Show only checked-in participants"
style={[styles.checkedBadge, onlyCheckedIn ? styles.checkedBadgeActive : null]}
>
<Text
style={[styles.checkedBadgeCount, onlyCheckedIn ? styles.checkedBadgeCountActive : null]}
>
{checkedInCount}
</Text>
<Text
style={[styles.checkedBadgeLabel, onlyCheckedIn ? styles.checkedBadgeLabelActive : null]}
>
checked in
</Text>
</Pressable>
</View> </View>
</View> </View>
); );
@ -127,9 +188,10 @@ function SearchBar({
} }
export function ParticipantsScreen({ navigation, route }: Props) { export function ParticipantsScreen({ navigation, route }: Props) {
const { eventId, eventName, attendanceId, attendanceName, allowDuplicate } = route.params; const { eventId, eventName, attendanceId, attendanceName, allowDuplicate, allowAssignBlank } =
route.params;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isOnline, refreshStatuses } = useOffline(); const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
const [all, setAll] = useState<AttendanceSearchParticipant[]>([]); const [all, setAll] = useState<AttendanceSearchParticipant[]>([]);
const [searchQ, setSearchQ] = useState(''); const [searchQ, setSearchQ] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -139,15 +201,26 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const [resultError, setResultError] = useState<string | null>(null); const [resultError, setResultError] = useState<string | null>(null);
const [pendingSync, setPendingSync] = useState(false); const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [undoLoading, setUndoLoading] = useState(false);
const [openSwipeId, setOpenSwipeId] = useState<string | null>(null);
const [fromCache, setFromCache] = useState(false); const [fromCache, setFromCache] = useState(false);
const [sessionPending, setSessionPending] = useState(0);
const [onlyCheckedIn, setOnlyCheckedIn] = useState(false);
const status = statusByEvent[eventId];
const offlineReady = Boolean(status?.enabled);
useLayoutEffect(() => {
navigation.setOptions({ gestureEnabled: openSwipeId == null });
}, [navigation, openSwipeId]);
const load = useCallback(async () => { const load = useCallback(async () => {
setError(null); setError(null);
const packActive = await isOfflinePackActive(eventId); const packActive = await isOfflinePackActive(eventId);
const pending = packActive ? await countPendingForSession(attendanceId) : 0; const pending = packActive ? await countPendingForSession(attendanceId) : 0;
setSessionPending(pending);
// Prefer local roster when offline, or when unsynced outbox would diverge from server. if (packActive) {
if (packActive && (!isOnline || pending > 0)) {
const local = await listOfflineParticipants(attendanceId); const local = await listOfflineParticipants(attendanceId);
setAll(local); setAll(local);
setFromCache(true); setFromCache(true);
@ -161,12 +234,6 @@ export function ParticipantsScreen({ navigation, route }: Props) {
setFromCache(false); setFromCache(false);
return; return;
} }
if (packActive) {
const local = await listOfflineParticipants(attendanceId);
setAll(local);
setFromCache(true);
return;
}
setError(res.error || 'Failed to load participants'); setError(res.error || 'Failed to load participants');
setAll([]); setAll([]);
setFromCache(false); setFromCache(false);
@ -191,8 +258,11 @@ export function ParticipantsScreen({ navigation, route }: Props) {
}, [navigation, load]); }, [navigation, load]);
const displayed = useMemo( const displayed = useMemo(
() => all.filter((p) => matchesParticipant(p, searchQ)), () =>
[all, searchQ], all.filter(
(p) => (!onlyCheckedIn || p.alreadyCheckedIn) && matchesParticipant(p, searchQ),
),
[all, searchQ, onlyCheckedIn],
); );
const checkedInCount = useMemo( const checkedInCount = useMemo(
@ -225,13 +295,73 @@ export function ParticipantsScreen({ navigation, route }: Props) {
await refreshStatuses(); await refreshStatuses();
}; };
const handleSync = async () => {
const outcome = await syncNow({ eventId, attendanceId, pull: true });
await load();
Alert.alert('Sync', formatSyncAlert(outcome));
};
const closeModal = () => { const closeModal = () => {
if (undoLoading) return;
setModalVisible(false); setModalVisible(false);
setResult(null); setResult(null);
setResultError(null); setResultError(null);
setPendingSync(false); 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);
await load();
await refreshStatuses();
};
const undoFromList = async (item: AttendanceSearchParticipant) => {
setOpenSwipeId(null);
setBusyId(item._id);
const outcome = await performAppUndoCheckIn({
eventId,
attendanceId,
attendanceName,
participantId: item._id,
});
setBusyId(null);
if (!outcome.ok) {
Alert.alert('Undo failed', outcome.error);
return;
}
await load();
await refreshStatuses();
};
const confirmUncheck = (item: AttendanceSearchParticipant) => {
Alert.alert('Uncheck-in', `Remove last check-in for ${item.displayName}?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Uncheck-in',
style: 'destructive',
onPress: () => {
void undoFromList(item);
},
},
]);
};
const listBottomPad = Math.max(insets.bottom, 16) + 88; const listBottomPad = Math.max(insets.bottom, 16) + 88;
const renderItem = ({ item }: { item: AttendanceSearchParticipant }) => { const renderItem = ({ item }: { item: AttendanceSearchParticipant }) => {
@ -241,65 +371,88 @@ export function ParticipantsScreen({ navigation, route }: Props) {
const meta = [item.email, item.phone].filter(Boolean).join(' · ') || '—'; const meta = [item.email, item.phone].filter(Boolean).join(' · ') || '—';
const checkInTime = formatCheckInTime(item.lastCheckedInAt); const checkInTime = formatCheckInTime(item.lastCheckedInAt);
return ( const rowInner = (
<Card style={styles.rowCard} padded> <View style={styles.row}>
<View style={styles.row}> <View
<View style={[
styles.avatar,
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
]}
>
<Text
style={[ style={[
styles.avatar, styles.avatarText,
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending, item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
]} ]}
> >
<Text {getInitial(item.displayName)}
style={[ </Text>
styles.avatarText, </View>
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
]}
>
{getInitial(item.displayName)}
</Text>
</View>
<View style={styles.rowText}> <View style={styles.rowText}>
<Text style={styles.name} numberOfLines={1}> <Text style={styles.name} numberOfLines={1}>
{item.displayName} {item.displayName}
</Text> </Text>
<Text style={styles.meta} numberOfLines={1}> <Text style={styles.meta} numberOfLines={1}>
{meta} {meta}
</Text> </Text>
{item.alreadyCheckedIn ? ( {item.alreadyCheckedIn ? (
<View style={styles.checkedRow}> <View style={styles.checkedRow}>
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} /> <AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
<Text style={styles.checked}> <Text style={styles.checked}>
Checked in{checkInTime ? ` · ${checkInTime}` : ''} Checked in{checkInTime ? ` · ${checkInTime}` : ''}
</Text> </Text>
</View> </View>
) : null}
</View>
{showAction ? (
<Pressable
style={[
styles.checkBtn,
isAgain ? styles.checkBtnAgain : styles.checkBtnPrimary,
]}
disabled={busyId === item._id}
onPress={() => checkIn(item._id)}
>
{busyId === item._id ? (
<ActivityIndicator color="#fff" size="small" />
) : isAgain ? (
<View style={styles.checkBtnInner}>
<AppIcon name="refresh-outline" size={11} color="#fff" />
<Text style={styles.checkBtnText}>Again</Text>
</View>
) : (
<Text style={styles.checkBtnText}>Check In</Text>
)}
</Pressable>
) : null} ) : null}
</View> </View>
</Card>
{showAction ? (
<Pressable
style={[
styles.checkBtn,
isAgain ? styles.checkBtnAgain : styles.checkBtnPrimary,
]}
disabled={busyId === item._id}
onPress={() => checkIn(item._id)}
>
{busyId === item._id ? (
<ActivityIndicator color="#fff" size="small" />
) : isAgain ? (
<View style={styles.checkBtnInner}>
<AppIcon name="refresh-outline" size={11} color="#fff" />
<Text style={styles.checkBtnText}>Again</Text>
</View>
) : (
<Text style={styles.checkBtnText}>Check In</Text>
)}
</Pressable>
) : null}
</View>
);
if (!item.alreadyCheckedIn) {
return (
<Card style={styles.rowCard} padded>
{rowInner}
</Card>
);
}
return (
<SwipeRow
enabled
open={openSwipeId === item._id}
onOpen={() => setOpenSwipeId(item._id)}
onClose={() => setOpenSwipeId((id) => (id === item._id ? null : id))}
actionLabel="Uncheck-in"
actionBusy={busyId === item._id}
onActionPress={() => confirmUncheck(item)}
style={styles.swipeRow}
>
<Card style={styles.swipeCard} padded>
{rowInner}
</Card>
</SwipeRow>
); );
}; };
@ -310,7 +463,17 @@ export function ParticipantsScreen({ navigation, route }: Props) {
sessionName={attendanceName} sessionName={attendanceName}
checkedInCount={checkedInCount} checkedInCount={checkedInCount}
totalCount={all.length} totalCount={all.length}
onlyCheckedIn={onlyCheckedIn}
onToggleCheckedIn={() => setOnlyCheckedIn((v) => !v)}
onBack={() => navigation.goBack()} onBack={() => navigation.goBack()}
onSync={() => {
handleSync();
}}
syncing={syncing}
pendingCount={sessionPending}
isOnline={isOnline}
offlineReady={offlineReady}
lastSyncedAt={status?.lastSyncedAt ?? null}
/> />
<SearchBar value={searchQ} onChangeText={setSearchQ} /> <SearchBar value={searchQ} onChangeText={setSearchQ} />
@ -326,6 +489,25 @@ export function ParticipantsScreen({ navigation, route }: Props) {
<Text style={styles.countText}> <Text style={styles.countText}>
{displayed.length} participant{displayed.length === 1 ? '' : 's'} {displayed.length} participant{displayed.length === 1 ? '' : 's'}
</Text> </Text>
{allowAssignBlank ? (
<Pressable
style={styles.assignBtn}
onPress={() =>
navigation.navigate('QrScanner', {
eventId,
eventName,
attendanceId,
attendanceName,
allowDuplicate,
allowAssignBlank: true,
mode: 'assignBlank',
})
}
>
<AppIcon name="person-add-outline" size={14} color="#fff" />
<Text style={styles.assignBtnText}>Assign a Blank</Text>
</Pressable>
) : null}
</View> </View>
{loading ? ( {loading ? (
@ -355,9 +537,12 @@ export function ParticipantsScreen({ navigation, route }: Props) {
onPress={() => onPress={() =>
navigation.navigate('QrScanner', { navigation.navigate('QrScanner', {
eventId, eventId,
eventName,
attendanceId, attendanceId,
attendanceName, attendanceName,
allowDuplicate, allowDuplicate,
allowAssignBlank: Boolean(allowAssignBlank),
mode: 'checkIn',
}) })
} }
> >
@ -371,6 +556,8 @@ export function ParticipantsScreen({ navigation, route }: Props) {
error={resultError} error={resultError}
pendingSync={pendingSync} pendingSync={pendingSync}
onClose={closeModal} onClose={closeModal}
onUndo={result ? handleUndo : undefined}
undoLoading={undoLoading}
/> />
</View> </View>
); );
@ -398,14 +585,35 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.10)', backgroundColor: 'rgba(255,255,255,0.10)',
zIndex: 0, zIndex: 0,
}, },
headerTopRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
zIndex: 1,
},
backBtn: { backBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
marginBottom: spacing.md,
alignSelf: 'flex-start', alignSelf: 'flex-start',
maxWidth: '85%', flex: 1,
zIndex: 1, minWidth: 0,
marginRight: 12,
},
syncBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(255,255,255,0.18)',
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: radii.pill,
},
syncText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
}, },
backLabel: { backLabel: {
color: 'rgba(255,255,255,0.85)', color: 'rgba(255,255,255,0.85)',
@ -438,6 +646,17 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
alignItems: 'center', alignItems: 'center',
minWidth: 64, minWidth: 64,
borderWidth: 1.5,
borderColor: 'transparent',
},
checkedBadgeActive: {
backgroundColor: '#fff',
borderColor: '#fff',
shadowColor: '#fff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 14,
elevation: 14,
}, },
checkedBadgeCount: { checkedBadgeCount: {
color: '#fff', color: '#fff',
@ -445,10 +664,16 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
lineHeight: 24, lineHeight: 24,
}, },
checkedBadgeCountActive: {
color: colors.primaryDark,
},
checkedBadgeLabel: { checkedBadgeLabel: {
color: 'rgba(255,255,255,0.95)', color: 'rgba(255,255,255,0.95)',
fontSize: 11, fontSize: 11,
}, },
checkedBadgeLabelActive: {
color: colors.primaryDark,
},
cacheBanner: { cacheBanner: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -487,16 +712,35 @@ const styles = StyleSheet.create({
paddingVertical: 0, paddingVertical: 0,
}, },
countStrip: { countStrip: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
backgroundColor: '#fff', backgroundColor: '#fff',
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: 10, paddingVertical: 8,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#F3F4F6', borderBottomColor: '#F3F4F6',
}, },
countText: { countText: {
flex: 1,
fontSize: 12, fontSize: 12,
color: colors.muted, color: colors.muted,
}, },
assignBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.primaryDark,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 7,
},
assignBtnText: {
color: '#fff',
fontSize: 12,
fontWeight: '700',
},
list: { list: {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingTop: spacing.md, paddingTop: spacing.md,
@ -513,6 +757,14 @@ const styles = StyleSheet.create({
borderRadius: radii.xl, borderRadius: radii.xl,
borderWidth: 0, borderWidth: 0,
}, },
swipeRow: {
marginBottom: 10,
},
swipeCard: {
marginBottom: 0,
borderRadius: radii.xl,
borderWidth: 0,
},
row: { row: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@ -1,6 +1,7 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
@ -15,11 +16,13 @@ import {
useCameraPermission, useCameraPermission,
useCodeScanner, useCodeScanner,
} from 'react-native-vision-camera'; } from 'react-native-vision-camera';
import { attendancesApi } from '../api/attendances';
import type { AttendanceCheckInResult } from '../api/types'; import type { AttendanceCheckInResult } from '../api/types';
import { CheckInResultModal } from '../components/CheckInResultModal'; import { CheckInResultModal } from '../components/CheckInResultModal';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
import { performAppCheckIn } from '../offline/checkInService'; import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService';
import { useOffline } from '../offline/OfflineContext'; import { useOffline } from '../offline/OfflineContext';
import { getOfflineParticipant, isOfflinePackActive } from '../offline/repos';
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr'; import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
import { colors, radii, spacing, typography } from '../theme'; import { colors, radii, spacing, typography } from '../theme';
import { PrimaryButton } from '../components/ui'; import { PrimaryButton } from '../components/ui';
@ -112,7 +115,8 @@ export function QrScannerScreen({ navigation, route }: Props) {
const headerTop = insets.top + spacing.md; const headerTop = insets.top + spacing.md;
const [headerBottom, setHeaderBottom] = useState(headerTop + 96); const [headerBottom, setHeaderBottom] = useState(headerTop + 96);
const scanTop = headerBottom + SCAN_GAP_BELOW_HEADER; const scanTop = headerBottom + SCAN_GAP_BELOW_HEADER;
const { eventId, attendanceId, attendanceName } = route.params; const { eventId, eventName, attendanceId, attendanceName, mode = 'checkIn' } = route.params;
const assignBlank = mode === 'assignBlank';
const { refreshStatuses } = useOffline(); const { refreshStatuses } = useOffline();
const { hasPermission, requestPermission } = useCameraPermission(); const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back'); const device = useCameraDevice('back');
@ -122,6 +126,30 @@ export function QrScannerScreen({ navigation, route }: Props) {
const [resultError, setResultError] = useState<string | null>(null); const [resultError, setResultError] = useState<string | null>(null);
const [pendingSync, setPendingSync] = useState(false); const [pendingSync, setPendingSync] = useState(false);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [undoLoading, setUndoLoading] = useState(false);
const goToAssignForm = useCallback(
(draft: {
participantId: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}) => {
navigation.replace('AssignBlank', {
eventId,
eventName,
attendanceId,
attendanceName,
participantId: draft.participantId,
firstName: draft.firstName,
lastName: draft.lastName,
email: draft.email,
phone: draft.phone,
});
},
[navigation, eventId, eventName, attendanceId, attendanceName],
);
const handleScan = useCallback( const handleScan = useCallback(
async (raw: string) => { async (raw: string) => {
@ -141,6 +169,82 @@ export function QrScannerScreen({ navigation, route }: Props) {
setResultError(null); setResultError(null);
setPendingSync(false); setPendingSync(false);
if (assignBlank) {
const packActive = await isOfflinePackActive(eventId);
let firstName = '';
let lastName = '';
let email = '';
let phone = '';
let found = false;
if (packActive) {
const local = await getOfflineParticipant(attendanceId, parsed.participantId);
if (local) {
found = true;
firstName = local.firstName || '';
lastName = local.lastName || '';
email = local.email || '';
phone = local.phone || '';
}
} else {
const preview = await attendancesApi.getParticipantPreview(
eventId,
attendanceId,
parsed.participantId,
);
if (!preview.error && preview.data) {
found = true;
firstName = preview.data.firstName || '';
lastName = preview.data.lastName || '';
email = preview.data.email || '';
phone = preview.data.phone || '';
}
}
setProcessing(false);
if (!found) {
setResult(null);
setResultError(
packActive
? 'Participant not in offline pack. Refresh offline data while online.'
: 'Participant not in this attendance list.',
);
setModalVisible(true);
return;
}
const openForm = () =>
goToAssignForm({
participantId: parsed.participantId,
firstName,
lastName,
email,
phone,
});
if (firstName.trim() || email.trim()) {
const label = [firstName, lastName].filter(Boolean).join(' ').trim() || email;
Alert.alert(
'This badge already has details',
`${label} is already named. Assign this blank anyway?`,
[
{
text: 'Cancel',
style: 'cancel',
onPress: () => {
scannedRef.current = false;
},
},
{ text: 'Continue', onPress: openForm },
],
);
return;
}
openForm();
return;
}
const outcome = await performAppCheckIn({ const outcome = await performAppCheckIn({
eventId, eventId,
attendanceId, attendanceId,
@ -161,7 +265,15 @@ export function QrScannerScreen({ navigation, route }: Props) {
} }
setModalVisible(true); setModalVisible(true);
}, },
[eventId, attendanceId, attendanceName, processing, refreshStatuses], [
assignBlank,
eventId,
attendanceId,
attendanceName,
processing,
refreshStatuses,
goToAssignForm,
],
); );
const codeScanner = useCodeScanner({ const codeScanner = useCodeScanner({
@ -173,6 +285,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
}); });
const closeModal = () => { const closeModal = () => {
if (undoLoading) return;
setModalVisible(false); setModalVisible(false);
setResult(null); setResult(null);
setResultError(null); setResultError(null);
@ -183,6 +296,28 @@ export function QrScannerScreen({ navigation, route }: Props) {
} }
}; };
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);
scannedRef.current = false;
refreshStatuses().catch(() => undefined);
};
if (!hasPermission) { if (!hasPermission) {
return ( return (
<View style={styles.centered}> <View style={styles.centered}>
@ -234,7 +369,9 @@ export function QrScannerScreen({ navigation, route }: Props) {
}} }}
> >
<Text style={styles.title}>{attendanceName}</Text> <Text style={styles.title}>{attendanceName}</Text>
<Text style={styles.hint}>Point at participant badge QR</Text> <Text style={styles.hint}>
{assignBlank ? 'Scan blank badge QR' : 'Point at participant badge QR'}
</Text>
{processing ? ( {processing ? (
<ActivityIndicator color="#fff" size="large" style={styles.spinner} /> <ActivityIndicator color="#fff" size="large" style={styles.spinner} />
) : null} ) : null}
@ -252,6 +389,8 @@ export function QrScannerScreen({ navigation, route }: Props) {
error={resultError} error={resultError}
pendingSync={pendingSync} pendingSync={pendingSync}
onClose={closeModal} onClose={closeModal}
onUndo={result && !assignBlank ? handleUndo : undefined}
undoLoading={undoLoading}
/> />
</View> </View>
); );