diff --git a/Archive.zip b/Archive.zip deleted file mode 100644 index e760bfd..0000000 Binary files a/Archive.zip and /dev/null differ diff --git a/README.md b/README.md index 96d0197..715bc3f 100644 --- a/README.md +++ b/README.md @@ -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**. 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. -4. Tap **Sync** (or wait for reconnect) to flush via `POST .../check-ins/sync`. -5. **Refresh Offline Data** updates the snapshot without clearing pending outbox rows. +3. While Offline is on, search and QR check-ins always save locally (even with internet). They queue in an outbox. +4. Every minute while online (app in the foreground), pending check-ins are **uploaded**. Reconnect and returning to the app also upload. +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`). diff --git a/package-lock.json b/package-lock.json index 9e9ebc6..ba58e15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "react": "19.2.3", "react-native": "0.85.3", "react-native-config": "^1.6.1", + "react-native-gesture-handler": "^2.28.0", "react-native-get-random-values": "^2.0.0", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.25.1", @@ -2026,6 +2027,18 @@ "dev": true, "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": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3621,6 +3634,12 @@ "@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": { "version": "2.0.6", "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" } }, + "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": { "version": "2.0.2", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz", diff --git a/src/api/attendances.ts b/src/api/attendances.ts index 5099185..eb2330a 100644 --- a/src/api/attendances.ts +++ b/src/api/attendances.ts @@ -33,6 +33,30 @@ export const attendancesApi = { ); }, + undoCheckIn(eventId: string, attendanceId: string, participantId: string) { + return api.post( + `/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( + `/events/${eventId}/attendances/${attendanceId}/assign-blank`, + body, + ); + }, + syncCheckIns( eventId: string, attendanceId: string, diff --git a/src/api/types.ts b/src/api/types.ts index 4b408d6..e7aaba5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -43,6 +43,7 @@ export interface AttendanceRecord { status: 'active' | 'archived'; settings?: { allowDuplicateCheckIn?: boolean; + allowAssignBlank?: boolean; publicLinkEnabled?: boolean; }; uniqueParticipants?: number; @@ -74,10 +75,19 @@ export interface AttendanceCheckInResult { alreadyCheckedIn: boolean; } +export interface AssignBlankProfile { + firstName: string; + lastName?: string; + email?: string; + phone?: string; +} + export interface OfflineSyncOperation { clientOpId: string; participantId: string; checkedInAt: string; + profile?: AssignBlankProfile; + action?: 'check_in' | 'undo'; } export interface OfflineSyncOpResult { diff --git a/src/components/CheckInResultModal.tsx b/src/components/CheckInResultModal.tsx index cc7a2b9..bf28e82 100644 --- a/src/components/CheckInResultModal.tsx +++ b/src/components/CheckInResultModal.tsx @@ -1,5 +1,5 @@ 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 type { AttendanceCheckInResult } from '../api/types'; import { colors, radii, spacing, typography } from '../theme'; @@ -11,6 +11,8 @@ type Props = { error: string | null; pendingSync?: boolean; onClose: () => void; + onUndo?: () => void | Promise; + undoLoading?: boolean; }; const CHECKIN_GREEN = '#16A34A'; @@ -22,8 +24,25 @@ export function CheckInResultModal({ error, pendingSync = false, onClose, + onUndo, + undoLoading = false, }: Props) { 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 ( @@ -85,7 +104,23 @@ export function CheckInResultModal({ ) : null} - + + {showUndo ? ( + + {undoLoading ? 'Undoing…' : 'Undo'} + + ) : null} + + @@ -177,7 +212,30 @@ const styles = StyleSheet.create({ color: PENDING_AMBER, fontWeight: '600', }, - doneBtn: { + actions: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, 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, + }, }); diff --git a/src/components/SwipeRow.tsx b/src/components/SwipeRow.tsx new file mode 100644 index 0000000..93f739d --- /dev/null +++ b/src/components/SwipeRow.tsx @@ -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; +}; + +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 {children}; + + return ( + + + {rowHeight > 0 ? ( + + + {actionBusy ? '…' : actionLabel} + + + ) : null} + + {children} + + + + ); +} + +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, + }, +}); diff --git a/src/components/ui/AppIcon.tsx b/src/components/ui/AppIcon.tsx index b5076e5..992b168 100644 --- a/src/components/ui/AppIcon.tsx +++ b/src/components/ui/AppIcon.tsx @@ -23,6 +23,7 @@ export type AppIconName = | 'log-out-outline' | 'mail-outline' | 'people-outline' + | 'person-add-outline' | 'person-outline' | 'qr-code-outline' | 'refresh-outline' diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index c36bed4..23405e2 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -9,6 +9,7 @@ import { EventsScreen } from '../screens/EventsScreen'; import { AttendanceSessionsScreen } from '../screens/AttendanceSessionsScreen'; import { ParticipantsScreen } from '../screens/ParticipantsScreen'; import { QrScannerScreen } from '../screens/QrScannerScreen'; +import { AssignBlankScreen } from '../screens/AssignBlankScreen'; import type { RootStackParamList } from './types'; import { colors } from '../theme'; import { getAccessToken } from '../api/client'; @@ -89,6 +90,7 @@ export function RootNavigator() { + ; refreshStatuses: () => Promise; - syncNow: (eventId?: string) => Promise<{ ok: boolean; flushed: number; error?: string }>; + syncNow: (opts?: SyncOpts) => Promise; }; const OfflineContext = createContext(null); +const AUTO_SYNC_MS = 60_000; + export function OfflineProvider({ children }: { children: React.ReactNode }) { const [ready, setReady] = useState(false); const [isOnline, setIsOnline] = useState(true); @@ -44,8 +46,8 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) { }, []); const syncNow = useCallback( - async (eventId?: string) => { - const result = await runSync(eventId); + async (opts?: SyncOpts) => { + const result = await runSync(opts); await refreshStatuses(); return result; }, @@ -81,7 +83,7 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) { const online = Boolean(state.isConnected && state.isInternetReachable !== false); setIsOnline(online); if (online) { - syncNow().catch(() => undefined); + syncNow({ pull: false }).catch(() => undefined); } }); return () => unsub(); @@ -90,13 +92,23 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const onAppState = (next: AppStateStatus) => { if (next === 'active') { - syncNow().catch(() => undefined); + syncNow({ pull: false }).catch(() => undefined); } }; const sub = AppState.addEventListener('change', onAppState); return () => sub.remove(); }, [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( () => ({ ready, diff --git a/src/offline/checkInService.ts b/src/offline/checkInService.ts index 2eb8dd0..87fffad 100644 --- a/src/offline/checkInService.ts +++ b/src/offline/checkInService.ts @@ -1,16 +1,18 @@ import NetInfo from '@react-native-community/netinfo'; import { v4 as uuidv4 } from 'uuid'; import { attendancesApi } from '../api/attendances'; -import type { AttendanceCheckInResult } from '../api/types'; +import type { AssignBlankProfile, AttendanceCheckInResult } from '../api/types'; import { getDeviceId } from './deviceId'; import { applyLocalCheckIn, - countPendingForSession, + applyLocalProfile, + applyLocalUndo, + deleteOutbox, + findLatestPendingOutboxForParticipant, getOfflineParticipant, getOfflineSession, insertOutbox, isOfflinePackActive, - updateParticipantFromSync, } from './repos'; export type CheckInServiceResult = @@ -22,17 +24,27 @@ async function isOnline(): Promise { 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: { eventId: string; attendanceId: string; attendanceName: string; participantId: string; + profile?: AssignBlankProfile; }): Promise { const session = await getOfflineSession(opts.attendanceId); if (!session) { 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); if (!participant) { return { @@ -57,10 +69,16 @@ async function queueLocalCheckIn(opts: { participantId: opts.participantId, checkedInAt, deviceId, + profile: opts.profile ?? null, }); + if (opts.profile) { + await applyLocalProfile(opts.attendanceId, opts.participantId, opts.profile); + } await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt); const nextCount = participant.checkInCount + 1; + const email = opts.profile?.email ?? participant.email ?? ''; + const phone = opts.profile?.phone ?? participant.phone ?? ''; return { ok: true, pendingSync: true, @@ -68,9 +86,11 @@ async function queueLocalCheckIn(opts: { attendanceId: opts.attendanceId, attendanceName: opts.attendanceName || session.name, participantId: opts.participantId, - displayName: participant.displayName, - email: participant.email || '', - phone: participant.phone || '', + displayName: opts.profile + ? displayNameFromProfile(opts.profile, participant.displayName) + : participant.displayName, + email, + phone, checkInCount: nextCount, lastCheckedInAt: checkedInAt, 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: { eventId: string; @@ -89,15 +109,12 @@ export async function performAppCheckIn(opts: { participantId: string; }): Promise { const packActive = await isOfflinePackActive(opts.eventId); - const pending = await countPendingForSession(opts.attendanceId); - const online = await isOnline(); - - if (packActive && (!online || pending > 0)) { + if (packActive) { return queueLocalCheckIn(opts); } + const online = await isOnline(); if (!online) { - if (packActive) return queueLocalCheckIn(opts); return { ok: false, 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); 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' }; } - if (packActive) { - await updateParticipantFromSync( - opts.attendanceId, - opts.participantId, - res.data.checkInCount, - res.data.lastCheckedInAt, - ); - } - + return { ok: true, result: res.data, pendingSync: false }; +} + +export async function performAppAssignBlank(opts: { + eventId: string; + attendanceId: string; + attendanceName: string; + participantId: string; + profile: AssignBlankProfile; +}): Promise { + 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 { + 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 }; } diff --git a/src/offline/db.ts b/src/offline/db.ts index 32791a2..6d7bca1 100644 --- a/src/offline/db.ts +++ b/src/offline/db.ts @@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS offline_sessions ( name TEXT NOT NULL, status TEXT NOT NULL, 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 ); @@ -59,6 +60,8 @@ CREATE TABLE IF NOT EXISTS outbox ( device_id TEXT NOT NULL, status TEXT NOT NULL, last_error TEXT, + profile_json TEXT, + action TEXT NOT NULL DEFAULT 'check_in', created_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); `; +async function ensureColumn(instance: DB, table: string, column: string, ddl: string): Promise { + 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 { if (db) return db; if (!readyPromise) { @@ -80,6 +91,19 @@ export async function getDb(): Promise { .filter(Boolean) .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; return instance; })(); diff --git a/src/offline/repos.ts b/src/offline/repos.ts index 955d711..74e8b5c 100644 --- a/src/offline/repos.ts +++ b/src/offline/repos.ts @@ -5,8 +5,10 @@ import type { OfflineEventRow, OfflineParticipantRow, OfflineSessionRow, + OutboxAction, OutboxRow, OutboxStatus, + AssignBlankProfile, } from './types'; function nowIso() { @@ -87,14 +89,15 @@ export async function replaceSessionsForEvent( await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]); for (const s of sessions) { await db.execute( - `INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in) - VALUES (?, ?, ?, ?, ?)`, + `INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in, allow_assign_blank) + VALUES (?, ?, ?, ?, ?, ?)`, [ s._id, eventId, s.name, s.status, s.settings?.allowDuplicateCheckIn ? 1 : 0, + s.settings?.allowAssignBlank ? 1 : 0, ], ); } @@ -112,6 +115,7 @@ export async function listOfflineSessions(eventId: string): Promise); } +export async function applyLocalProfile( + attendanceId: string, + participantId: string, + profile: AssignBlankProfile, +): Promise { + 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( attendanceId: string, participantId: string, @@ -236,6 +271,18 @@ export async function applyLocalCheckIn( ); } +export async function applyLocalUndo(attendanceId: string, participantId: string): Promise { + 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( attendanceId: string, participantId: string, @@ -260,14 +307,16 @@ export async function insertOutbox(row: { participantId: string; checkedInAt: string; deviceId: string; + profile?: AssignBlankProfile | null; + action?: OutboxAction; }): Promise { const db = await getDb(); const ts = nowIso(); await db.execute( `INSERT INTO outbox ( client_op_id, event_id, attendance_id, participant_id, checked_in_at, - device_id, status, last_error, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`, + device_id, status, last_error, profile_json, action, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?, ?, ?)`, [ row.clientOpId, row.eventId, @@ -275,12 +324,29 @@ export async function insertOutbox(row: { row.participantId, row.checkedInAt, row.deviceId, + row.profile ? JSON.stringify(row.profile) : null, + row.action || 'check_in', 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): OutboxRow { return { clientOpId: String(row.client_op_id), @@ -293,22 +359,50 @@ function mapOutbox(row: Record): OutboxRow { lastError: row.last_error != null ? String(row.last_error) : null, createdAt: String(row.created_at), updatedAt: String(row.updated_at), + profile: parseProfileJson(row.profile_json), + action: parseOutboxAction(row.action), }; } -export async function listPendingOutbox(eventId?: string): Promise { +export async function listPendingOutbox(scope?: { + eventId?: string; + attendanceId?: string; +}): Promise { const db = await getDb(); - const res = eventId + const res = scope?.attendanceId ? await db.execute( - `SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`, - [eventId], + `SELECT * FROM outbox WHERE attendance_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`, + [scope.attendanceId], ) - : await db.execute( - `SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`, - ); + : scope?.eventId + ? 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)); } +/** Overlay leftover outbox rows onto the local roster after a server pull. */ +export async function reapplyPendingOutboxToRoster(scope?: { + eventId?: string; + attendanceId?: string; +}): Promise { + 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 { const db = await getDb(); const res = await db.execute( @@ -340,6 +434,27 @@ export async function countPendingForSession(attendanceId: string): Promise { + 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); +} + +export async function deleteOutbox(clientOpId: string): Promise { + const db = await getDb(); + await db.execute(`DELETE FROM outbox WHERE client_op_id = ?`, [clientOpId]); +} + export async function markOutboxStatus( clientOpId: string, status: OutboxStatus, diff --git a/src/offline/syncEngine.ts b/src/offline/syncEngine.ts index 1831ed1..9643bb8 100644 --- a/src/offline/syncEngine.ts +++ b/src/offline/syncEngine.ts @@ -1,16 +1,33 @@ import NetInfo from '@react-native-community/netinfo'; import { attendancesApi } from '../api/attendances'; import { getDeviceId } from './deviceId'; -import { refreshSessionRoster } from './download'; +import { refreshOfflinePack, refreshSessionRoster } from './download'; import { + getOfflineEvent, + getOfflineSession, + listOfflineEventIds, listPendingOutbox, markOutboxStatus, markOutboxSyncing, + reapplyPendingOutboxToRoster, setEventLastSyncedAt, updateParticipantFromSync, } from './repos'; 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; let syncing = false; @@ -31,16 +48,75 @@ export function isSyncEngineRunning(): boolean { 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; + resolve: (r: SyncResult) => void; +}; + +function createDeferred(): Deferred { + let resolve: (r: SyncResult) => void = () => undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +let running = false; +let queuedOpts: SyncOpts | null = null; +let queuedDeferred: Deferred | null = null; + async function flushGroup( eventId: 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, + refreshRoster: boolean, ): Promise { await markOutboxSyncing(ops.map((o) => o.clientOpId)); const res = await attendancesApi.syncCheckIns(eventId, attendanceId, { 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) { @@ -88,38 +164,70 @@ async function flushGroup( } } - // Light roster refresh to pick up other devices - await refreshSessionRoster(eventId, attendanceId); + if (refreshRoster) { + const rr = await refreshSessionRoster(eventId, attendanceId); + if (rr.ok) { + await reapplyPendingOutboxToRoster({ attendanceId }); + } + } return null; } -/** - * Flush pending outbox. Optionally scope to one event. - */ -export async function runSync(eventId?: string): Promise<{ - ok: boolean; - flushed: number; - error?: string; -}> { - if (syncing) { - return { ok: false, flushed: 0, error: 'Sync already in progress' }; +async function pullSheets(opts: SyncOpts): Promise<{ eventIds: string[]; error?: string }> { + if (opts.attendanceId) { + let eventId = opts.eventId; + if (!eventId) { + const session = await getOfflineSession(opts.attendanceId); + eventId = session?.eventId; + } + if (!eventId) { + return { eventIds: [], error: 'Offline pack missing this attendance session.' }; + } + 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 { const net = await NetInfo.fetch(); 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; - emit(null); - - try { - const pending = await listPendingOutbox(eventId); - if (!pending.length) { - if (eventId) await setEventLastSyncedAt(eventId, new Date().toISOString()); - return { ok: true, flushed: 0 }; - } + const scope = { eventId: opts.eventId, attendanceId: opts.attendanceId }; + const pending = await listPendingOutbox(scope); + const pull = Boolean(opts.pull); + let flushed = 0; + let lastError: string | null = null; + const touchedEvents = new Set(); + if (pending.length) { const deviceId = await getDeviceId(); const groups = new Map(); for (const row of pending) { @@ -129,14 +237,9 @@ export async function runSync(eventId?: string): Promise<{ groups.set(key, list); } - let flushed = 0; - let lastError: string | null = null; - const touchedEvents = new Set(); - for (const [key, rows] of groups) { const [eid, aid] = key.split('::'); touchedEvents.add(eid); - // Chunk 200 for (let i = 0; i < rows.length; i += 200) { const chunk = rows.slice(i, i + 200); const err = await flushGroup( @@ -146,29 +249,80 @@ export async function runSync(eventId?: string): Promise<{ clientOpId: r.clientOpId, participantId: r.participantId, checkedInAt: r.checkedInAt, + profile: r.profile, + action: r.action, })), deviceId, + !pull, ); if (err) lastError = err; 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 { + 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); + } + })(); } diff --git a/src/offline/types.ts b/src/offline/types.ts index eb94604..d107aef 100644 --- a/src/offline/types.ts +++ b/src/offline/types.ts @@ -17,6 +17,7 @@ export type OfflineSessionRow = { name: string; status: string; allowDuplicateCheckIn: number; + allowAssignBlank: number; }; export type OfflineParticipantRow = { @@ -33,6 +34,15 @@ export type OfflineParticipantRow = { lastCheckedInAt: string | null; }; +export type AssignBlankProfile = { + firstName: string; + lastName?: string; + email?: string; + phone?: string; +}; + +export type OutboxAction = 'check_in' | 'undo'; + export type OutboxRow = { clientOpId: string; eventId: string; @@ -44,6 +54,8 @@ export type OutboxRow = { lastError: string | null; createdAt: string; updatedAt: string; + profile: AssignBlankProfile | null; + action: OutboxAction; }; export type SyncOpResultStatus = 'applied' | 'duplicate' | 'conflict' | 'rejected'; diff --git a/src/screens/AssignBlankScreen.tsx b/src/screens/AssignBlankScreen.tsx new file mode 100644 index 0000000..a4ce580 --- /dev/null +++ b/src/screens/AssignBlankScreen.tsx @@ -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; + +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(null); + const [resultError, setResultError] = useState(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 ( + + + + navigation.goBack()} hitSlop={10} style={styles.backBtn}> + + + {eventName} + + + Assign a Blank + {attendanceName} + + + + + + Update this spare badge’s details, then save to check the person in. + + + + + + { + void save(); + }} + /> + + + + + + ); +} + +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, + }, +}); diff --git a/src/screens/AttendanceSessionsScreen.tsx b/src/screens/AttendanceSessionsScreen.tsx index ea082a9..e9e4765 100644 --- a/src/screens/AttendanceSessionsScreen.tsx +++ b/src/screens/AttendanceSessionsScreen.tsx @@ -15,6 +15,7 @@ import { attendancesApi } from '../api/attendances'; import type { AttendanceRecord } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; import { useOffline } from '../offline/OfflineContext'; +import { formatSyncAlert } from '../offline/syncEngine'; import { dismissConflict, getOfflineEvent, @@ -31,6 +32,13 @@ const SESSION_ICONS: AppIconName[] = ['business-outline', 'people-outline']; const CHECKIN_GREEN = '#22C55E'; 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({ eventName, onBack, @@ -39,6 +47,7 @@ function SessionsHeader({ pendingCount, isOnline, offlineReady, + lastSyncedAt, }: { eventName: string; onBack: () => void; @@ -47,6 +56,7 @@ function SessionsHeader({ pendingCount: number; isOnline: boolean; offlineReady: boolean; + lastSyncedAt: string | null; }) { const insets = useSafeAreaInsets(); @@ -85,7 +95,7 @@ function SessionsHeader({ {offlineReady ? isOnline - ? 'Offline pack ready · Select a session' + ? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}` : 'Working offline · Select a session' : 'Select an attendance session'} @@ -115,6 +125,41 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) { const load = useCallback(async () => { 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) { const res = await attendancesApi.list(eventId); if (!res.error && res.data) { @@ -123,64 +168,35 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) { setFromCache(false); return; } - // fall through to cache on failure - 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.', - ); + setError(res.error || 'Failed to load sessions'); setSessions([]); setFromCache(false); 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 }, - uniqueParticipants: unique, - totalCheckedIn: total, - }); - } - setSessions(mapped); - setFromCache(true); + setError('No offline data for this event. Go online and Enable Offline first.'); + setSessions([]); + setFromCache(false); }, [eventId, isOnline]); useEffect(() => { + const unsub = navigation.addListener('focus', () => { + load(); + refreshStatuses(); + }); (async () => { setLoading(true); await load(); await refreshStatuses(); setLoading(false); })(); - }, [load, refreshStatuses]); + return unsub; + }, [navigation, load, refreshStatuses]); const handleSync = async () => { - const result = await syncNow(eventId); + const result = await syncNow({ eventId, pull: true }); await load(); - if (!result.ok && result.error) { - 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.'); - } + Alert.alert('Sync', formatSyncAlert(result)); const conflicts = await listConflictOutbox(eventId); if (conflicts.length) { @@ -218,6 +234,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) { attendanceId: item._id, attendanceName: item.name, allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn), + allowAssignBlank: Boolean(item.settings?.allowAssignBlank), }) } > @@ -265,6 +282,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) { pendingCount={status?.pendingCount ?? 0} isOnline={isOnline} offlineReady={offlineReady} + lastSyncedAt={status?.lastSyncedAt ?? null} /> {fromCache ? ( diff --git a/src/screens/EventsScreen.tsx b/src/screens/EventsScreen.tsx index a31eff7..3d2984b 100644 --- a/src/screens/EventsScreen.tsx +++ b/src/screens/EventsScreen.tsx @@ -18,6 +18,7 @@ import type { CheckinsEvent } from '../api/types'; import { useAuth } from '../context/AuthContext'; import { downloadOfflinePack, refreshOfflinePack } from '../offline/download'; import { useOffline } from '../offline/OfflineContext'; +import { formatSyncAlert } from '../offline/syncEngine'; import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos'; import type { RootStackParamList } from '../navigation/types'; import { colors, radii, shadows, spacing, typography } from '../theme'; @@ -47,6 +48,7 @@ function EventsHeader({ syncing, pendingTotal, isOnline, + hasOfflinePacks, }: { userEmail?: string | null; onLogout: () => void; @@ -54,6 +56,7 @@ function EventsHeader({ syncing: boolean; pendingTotal: number; isOnline: boolean; + hasOfflinePacks: boolean; }) { const insets = useSafeAreaInsets(); @@ -74,7 +77,7 @@ function EventsHeader({ onPress={onSync} hitSlop={10} style={styles.syncBtn} - disabled={syncing || !isOnline} + disabled={syncing || !isOnline || !hasOfflinePacks} > {syncing ? ( @@ -191,14 +194,9 @@ export function EventsScreen({ navigation }: Props) { }; const handleSync = async () => { - const result = await syncNow(); - if (!result.ok && result.error) { - 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 result = await syncNow({ pull: true }); + await load(); + Alert.alert('Sync', formatSyncAlert(result)); }; 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 hasOfflinePacks = Object.values(statusByEvent).some((s) => s.enabled); const renderItem = ({ item }: { item: CheckinsEvent }) => { const sessionCount = item.attendanceSessionCount; @@ -390,6 +389,7 @@ export function EventsScreen({ navigation }: Props) { syncing={syncing} pendingTotal={pendingTotal} isOnline={isOnline} + hasOfflinePacks={hasOfflinePacks} /> {loading ? ( diff --git a/src/screens/ParticipantsScreen.tsx b/src/screens/ParticipantsScreen.tsx index a3a3126..55580b0 100644 --- a/src/screens/ParticipantsScreen.tsx +++ b/src/screens/ParticipantsScreen.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { ActivityIndicator, + Alert, FlatList, Pressable, StyleSheet, @@ -13,9 +14,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { attendancesApi } from '../api/attendances'; import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types'; import { CheckInResultModal } from '../components/CheckInResultModal'; +import { SwipeRow } from '../components/SwipeRow'; import type { RootStackParamList } from '../navigation/types'; -import { performAppCheckIn } from '../offline/checkInService'; +import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService'; import { useOffline } from '../offline/OfflineContext'; +import { formatSyncAlert } from '../offline/syncEngine'; import { countPendingForSession, isOfflinePackActive, @@ -51,18 +54,41 @@ function formatCheckInTime(iso?: string | null) { 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({ eventName, sessionName, checkedInCount, totalCount, + onlyCheckedIn, + onToggleCheckedIn, onBack, + onSync, + syncing, + pendingCount, + isOnline, + offlineReady, + lastSyncedAt, }: { eventName: string; sessionName: string; checkedInCount: number; totalCount: number; + onlyCheckedIn: boolean; + onToggleCheckedIn: () => void; onBack: () => void; + onSync: () => void; + syncing: boolean; + pendingCount: number; + isOnline: boolean; + offlineReady: boolean; + lastSyncedAt: string | null; }) { const insets = useSafeAreaInsets(); @@ -71,12 +97,31 @@ function ParticipantsHeader({ - - - - {eventName} - - + + + + + {eventName} + + + {offlineReady ? ( + + {syncing ? ( + + ) : ( + + )} + + Sync{pendingCount > 0 ? ` (${pendingCount})` : ''} + + + ) : null} + @@ -84,13 +129,29 @@ function ParticipantsHeader({ {sessionName} - Attendance · {checkedInCount}/{totalCount} checked in + {offlineReady + ? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}` + : `Attendance · ${checkedInCount}/${totalCount} checked in`} - - {checkedInCount} - checked in - + + + {checkedInCount} + + + checked in + + ); @@ -127,9 +188,10 @@ function SearchBar({ } 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 { isOnline, refreshStatuses } = useOffline(); + const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline(); const [all, setAll] = useState([]); const [searchQ, setSearchQ] = useState(''); const [loading, setLoading] = useState(true); @@ -139,15 +201,26 @@ export function ParticipantsScreen({ navigation, route }: Props) { const [resultError, setResultError] = useState(null); const [pendingSync, setPendingSync] = useState(false); const [modalVisible, setModalVisible] = useState(false); + const [undoLoading, setUndoLoading] = useState(false); + const [openSwipeId, setOpenSwipeId] = useState(null); 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 () => { setError(null); const packActive = await isOfflinePackActive(eventId); const pending = packActive ? await countPendingForSession(attendanceId) : 0; + setSessionPending(pending); - // Prefer local roster when offline, or when unsynced outbox would diverge from server. - if (packActive && (!isOnline || pending > 0)) { + if (packActive) { const local = await listOfflineParticipants(attendanceId); setAll(local); setFromCache(true); @@ -161,12 +234,6 @@ export function ParticipantsScreen({ navigation, route }: Props) { setFromCache(false); return; } - if (packActive) { - const local = await listOfflineParticipants(attendanceId); - setAll(local); - setFromCache(true); - return; - } setError(res.error || 'Failed to load participants'); setAll([]); setFromCache(false); @@ -191,8 +258,11 @@ export function ParticipantsScreen({ navigation, route }: Props) { }, [navigation, load]); 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( @@ -225,13 +295,73 @@ export function ParticipantsScreen({ navigation, route }: Props) { await refreshStatuses(); }; + const handleSync = async () => { + const outcome = await syncNow({ eventId, attendanceId, pull: true }); + await load(); + Alert.alert('Sync', formatSyncAlert(outcome)); + }; + const closeModal = () => { + if (undoLoading) return; setModalVisible(false); 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); + 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 renderItem = ({ item }: { item: AttendanceSearchParticipant }) => { @@ -241,65 +371,88 @@ export function ParticipantsScreen({ navigation, route }: Props) { const meta = [item.email, item.phone].filter(Boolean).join(' · ') || '—'; const checkInTime = formatCheckInTime(item.lastCheckedInAt); - return ( - - - + + - - {getInitial(item.displayName)} - - + {getInitial(item.displayName)} + + - - - {item.displayName} - - - {meta} - - {item.alreadyCheckedIn ? ( - - - - Checked in{checkInTime ? ` · ${checkInTime}` : ''} - - - ) : null} - - - {showAction ? ( - checkIn(item._id)} - > - {busyId === item._id ? ( - - ) : isAgain ? ( - - - Again - - ) : ( - Check In - )} - + + + {item.displayName} + + + {meta} + + {item.alreadyCheckedIn ? ( + + + + Checked in{checkInTime ? ` · ${checkInTime}` : ''} + + ) : null} - + + {showAction ? ( + checkIn(item._id)} + > + {busyId === item._id ? ( + + ) : isAgain ? ( + + + Again + + ) : ( + Check In + )} + + ) : null} + + ); + + if (!item.alreadyCheckedIn) { + return ( + + {rowInner} + + ); + } + + return ( + setOpenSwipeId(item._id)} + onClose={() => setOpenSwipeId((id) => (id === item._id ? null : id))} + actionLabel="Uncheck-in" + actionBusy={busyId === item._id} + onActionPress={() => confirmUncheck(item)} + style={styles.swipeRow} + > + + {rowInner} + + ); }; @@ -310,7 +463,17 @@ export function ParticipantsScreen({ navigation, route }: Props) { sessionName={attendanceName} checkedInCount={checkedInCount} totalCount={all.length} + onlyCheckedIn={onlyCheckedIn} + onToggleCheckedIn={() => setOnlyCheckedIn((v) => !v)} onBack={() => navigation.goBack()} + onSync={() => { + handleSync(); + }} + syncing={syncing} + pendingCount={sessionPending} + isOnline={isOnline} + offlineReady={offlineReady} + lastSyncedAt={status?.lastSyncedAt ?? null} /> @@ -326,6 +489,25 @@ export function ParticipantsScreen({ navigation, route }: Props) { {displayed.length} participant{displayed.length === 1 ? '' : 's'} + {allowAssignBlank ? ( + + navigation.navigate('QrScanner', { + eventId, + eventName, + attendanceId, + attendanceName, + allowDuplicate, + allowAssignBlank: true, + mode: 'assignBlank', + }) + } + > + + Assign a Blank + + ) : null} {loading ? ( @@ -355,9 +537,12 @@ export function ParticipantsScreen({ navigation, route }: Props) { onPress={() => navigation.navigate('QrScanner', { eventId, + eventName, attendanceId, attendanceName, allowDuplicate, + allowAssignBlank: Boolean(allowAssignBlank), + mode: 'checkIn', }) } > @@ -371,6 +556,8 @@ export function ParticipantsScreen({ navigation, route }: Props) { error={resultError} pendingSync={pendingSync} onClose={closeModal} + onUndo={result ? handleUndo : undefined} + undoLoading={undoLoading} /> ); @@ -398,14 +585,35 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255,255,255,0.10)', zIndex: 0, }, + headerTopRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.md, + zIndex: 1, + }, backBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, - marginBottom: spacing.md, alignSelf: 'flex-start', - maxWidth: '85%', - zIndex: 1, + flex: 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: { color: 'rgba(255,255,255,0.85)', @@ -438,6 +646,17 @@ const styles = StyleSheet.create({ paddingVertical: 8, alignItems: 'center', 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: { color: '#fff', @@ -445,10 +664,16 @@ const styles = StyleSheet.create({ fontWeight: '700', lineHeight: 24, }, + checkedBadgeCountActive: { + color: colors.primaryDark, + }, checkedBadgeLabel: { color: 'rgba(255,255,255,0.95)', fontSize: 11, }, + checkedBadgeLabelActive: { + color: colors.primaryDark, + }, cacheBanner: { flexDirection: 'row', alignItems: 'center', @@ -487,16 +712,35 @@ const styles = StyleSheet.create({ paddingVertical: 0, }, countStrip: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, backgroundColor: '#fff', paddingHorizontal: spacing.lg, - paddingVertical: 10, + paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#F3F4F6', }, countText: { + flex: 1, fontSize: 12, 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: { paddingHorizontal: spacing.lg, paddingTop: spacing.md, @@ -513,6 +757,14 @@ const styles = StyleSheet.create({ borderRadius: radii.xl, borderWidth: 0, }, + swipeRow: { + marginBottom: 10, + }, + swipeCard: { + marginBottom: 0, + borderRadius: radii.xl, + borderWidth: 0, + }, row: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/QrScannerScreen.tsx b/src/screens/QrScannerScreen.tsx index f70b66c..9ccd3ad 100644 --- a/src/screens/QrScannerScreen.tsx +++ b/src/screens/QrScannerScreen.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { ActivityIndicator, + Alert, Pressable, StyleSheet, Text, @@ -15,11 +16,13 @@ import { useCameraPermission, useCodeScanner, } from 'react-native-vision-camera'; +import { attendancesApi } from '../api/attendances'; import type { AttendanceCheckInResult } from '../api/types'; import { CheckInResultModal } from '../components/CheckInResultModal'; import type { RootStackParamList } from '../navigation/types'; -import { performAppCheckIn } from '../offline/checkInService'; +import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService'; import { useOffline } from '../offline/OfflineContext'; +import { getOfflineParticipant, isOfflinePackActive } from '../offline/repos'; import { parseParticipantIdFromQr } from '../utils/parseParticipantQr'; import { colors, radii, spacing, typography } from '../theme'; import { PrimaryButton } from '../components/ui'; @@ -112,7 +115,8 @@ export function QrScannerScreen({ navigation, route }: Props) { const headerTop = insets.top + spacing.md; const [headerBottom, setHeaderBottom] = useState(headerTop + 96); 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 { hasPermission, requestPermission } = useCameraPermission(); const device = useCameraDevice('back'); @@ -122,6 +126,30 @@ export function QrScannerScreen({ navigation, route }: Props) { const [resultError, setResultError] = useState(null); const [pendingSync, setPendingSync] = 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( async (raw: string) => { @@ -141,6 +169,82 @@ export function QrScannerScreen({ navigation, route }: Props) { setResultError(null); 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({ eventId, attendanceId, @@ -161,7 +265,15 @@ export function QrScannerScreen({ navigation, route }: Props) { } setModalVisible(true); }, - [eventId, attendanceId, attendanceName, processing, refreshStatuses], + [ + assignBlank, + eventId, + attendanceId, + attendanceName, + processing, + refreshStatuses, + goToAssignForm, + ], ); const codeScanner = useCodeScanner({ @@ -173,6 +285,7 @@ export function QrScannerScreen({ navigation, route }: Props) { }); const closeModal = () => { + if (undoLoading) return; setModalVisible(false); setResult(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) { return ( @@ -234,7 +369,9 @@ export function QrScannerScreen({ navigation, route }: Props) { }} > {attendanceName} - Point at participant badge QR + + {assignBlank ? 'Scan blank badge QR' : 'Point at participant badge QR'} + {processing ? ( ) : null} @@ -252,6 +389,8 @@ export function QrScannerScreen({ navigation, route }: Props) { error={resultError} pendingSync={pendingSync} onClose={closeModal} + onUndo={result && !assignBlank ? handleUndo : undefined} + undoLoading={undoLoading} /> );