Add blank-badge assignment and improve offline check-in flows.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2f6b838939
commit
51e679d6ec
BIN
Archive.zip
BIN
Archive.zip
Binary file not shown.
12
README.md
12
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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
eventId: string,
|
||||
attendanceId: string,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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 (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
|
|
@ -85,7 +104,23 @@ export function CheckInResultModal({
|
|||
</>
|
||||
) : 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>
|
||||
</Modal>
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ export type AppIconName =
|
|||
| 'log-out-outline'
|
||||
| 'mail-outline'
|
||||
| 'people-outline'
|
||||
| 'person-add-outline'
|
||||
| 'person-outline'
|
||||
| 'qr-code-outline'
|
||||
| 'refresh-outline'
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Stack.Screen name="Events" component={EventsScreen} />
|
||||
<Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} />
|
||||
<Stack.Screen name="Participants" component={ParticipantsScreen} />
|
||||
<Stack.Screen name="AssignBlank" component={AssignBlankScreen} />
|
||||
<Stack.Screen
|
||||
name="QrScanner"
|
||||
component={QrScannerScreen}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,26 @@ export type RootStackParamList = {
|
|||
attendanceId: string;
|
||||
attendanceName: string;
|
||||
allowDuplicate: boolean;
|
||||
allowAssignBlank: boolean;
|
||||
};
|
||||
QrScanner: {
|
||||
eventId: string;
|
||||
eventName: string;
|
||||
attendanceId: string;
|
||||
attendanceName: string;
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import React, {
|
|||
} from 'react';
|
||||
import { AppState, type AppStateStatus } from 'react-native';
|
||||
import { getDb } from './db';
|
||||
import { runSync, subscribeSyncEngine } from './syncEngine';
|
||||
import { runSync, subscribeSyncEngine, type SyncOpts, type SyncResult } from './syncEngine';
|
||||
import type { EventOfflineStatus } from './types';
|
||||
import { getEventOfflineStatus, listOfflineEventIds } from './repos';
|
||||
|
||||
|
|
@ -20,11 +20,13 @@ type OfflineContextValue = {
|
|||
lastSyncError: string | null;
|
||||
statusByEvent: Record<string, EventOfflineStatus>;
|
||||
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 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,
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
|||
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<CheckInServiceResult> {
|
||||
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<CheckInServiceResult> {
|
||||
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<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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
if (db) return db;
|
||||
if (!readyPromise) {
|
||||
|
|
@ -80,6 +91,19 @@ export async function getDb(): Promise<DB> {
|
|||
.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;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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<OfflineSessi
|
|||
name: String(row.name),
|
||||
status: String(row.status),
|
||||
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),
|
||||
status: String(row.status),
|
||||
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>);
|
||||
}
|
||||
|
||||
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(
|
||||
attendanceId: 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(
|
||||
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<void> {
|
||||
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<string, unknown>): OutboxRow {
|
||||
return {
|
||||
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,
|
||||
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<OutboxRow[]> {
|
||||
export async function listPendingOutbox(scope?: {
|
||||
eventId?: string;
|
||||
attendanceId?: string;
|
||||
}): Promise<OutboxRow[]> {
|
||||
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<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[]> {
|
||||
const db = await getDb();
|
||||
const res = await db.execute(
|
||||
|
|
@ -340,6 +434,27 @@ export async function countPendingForSession(attendanceId: string): Promise<numb
|
|||
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(
|
||||
clientOpId: string,
|
||||
status: OutboxStatus,
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
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<string | null> {
|
||||
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<SyncResult> {
|
||||
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<string>();
|
||||
|
||||
if (pending.length) {
|
||||
const deviceId = await getDeviceId();
|
||||
const groups = new Map<string, typeof pending>();
|
||||
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<string>();
|
||||
|
||||
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<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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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({
|
|||
<Text style={styles.headerSubtitle}>
|
||||
{offlineReady
|
||||
? isOnline
|
||||
? 'Offline pack ready · Select a session'
|
||||
? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
|
||||
: 'Working offline · Select a session'
|
||||
: 'Select an attendance session'}
|
||||
</Text>
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<View style={styles.headerBg} />
|
||||
<View style={styles.headerCircle} />
|
||||
|
||||
<Pressable onPress={onBack} 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>
|
||||
<View style={styles.headerTopRow}>
|
||||
<Pressable onPress={onBack} 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>
|
||||
{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.headerTextCol}>
|
||||
|
|
@ -84,13 +129,29 @@ function ParticipantsHeader({
|
|||
{sessionName}
|
||||
</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Attendance · {checkedInCount}/{totalCount} checked in
|
||||
{offlineReady
|
||||
? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
|
||||
: `Attendance · ${checkedInCount}/${totalCount} checked in`}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.checkedBadge}>
|
||||
<Text style={styles.checkedBadgeCount}>{checkedInCount}</Text>
|
||||
<Text style={styles.checkedBadgeLabel}>checked in</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={onToggleCheckedIn}
|
||||
accessibilityRole="button"
|
||||
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>
|
||||
);
|
||||
|
|
@ -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<AttendanceSearchParticipant[]>([]);
|
||||
const [searchQ, setSearchQ] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -139,15 +201,26 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
|||
const [resultError, setResultError] = useState<string | null>(null);
|
||||
const [pendingSync, setPendingSync] = 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 [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 (
|
||||
<Card style={styles.rowCard} padded>
|
||||
<View style={styles.row}>
|
||||
<View
|
||||
const rowInner = (
|
||||
<View style={styles.row}>
|
||||
<View
|
||||
style={[
|
||||
styles.avatar,
|
||||
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.avatar,
|
||||
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
|
||||
styles.avatarText,
|
||||
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.avatarText,
|
||||
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
|
||||
]}
|
||||
>
|
||||
{getInitial(item.displayName)}
|
||||
</Text>
|
||||
</View>
|
||||
{getInitial(item.displayName)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
{item.alreadyCheckedIn ? (
|
||||
<View style={styles.checkedRow}>
|
||||
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
|
||||
<Text style={styles.checked}>
|
||||
Checked in{checkInTime ? ` · ${checkInTime}` : ''}
|
||||
</Text>
|
||||
</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>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{meta}
|
||||
</Text>
|
||||
{item.alreadyCheckedIn ? (
|
||||
<View style={styles.checkedRow}>
|
||||
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
|
||||
<Text style={styles.checked}>
|
||||
Checked in{checkInTime ? ` · ${checkInTime}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</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}
|
||||
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}
|
||||
/>
|
||||
|
||||
<SearchBar value={searchQ} onChangeText={setSearchQ} />
|
||||
|
|
@ -326,6 +489,25 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
|||
<Text style={styles.countText}>
|
||||
{displayed.length} participant{displayed.length === 1 ? '' : 's'}
|
||||
</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>
|
||||
|
||||
{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}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<View style={styles.centered}>
|
||||
|
|
@ -234,7 +369,9 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
|||
}}
|
||||
>
|
||||
<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 ? (
|
||||
<ActivityIndicator color="#fff" size="large" style={styles.spinner} />
|
||||
) : null}
|
||||
|
|
@ -252,6 +389,8 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
|||
error={resultError}
|
||||
pendingSync={pendingSync}
|
||||
onClose={closeModal}
|
||||
onUndo={result && !assignBlank ? handleUndo : undefined}
|
||||
undoLoading={undoLoading}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue