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**.
|
1. While online, open **Events** and tap the cloud icon (or long-press an event) → **Enable Offline**.
|
||||||
2. The app downloads active attendance sessions + participant rosters into local SQLite.
|
2. The app downloads active attendance sessions + participant rosters into local SQLite.
|
||||||
3. Check in via search or QR without internet; items queue in an outbox.
|
3. While Offline is on, search and QR check-ins always save locally (even with internet). They queue in an outbox.
|
||||||
4. Tap **Sync** (or wait for reconnect) to flush via `POST .../check-ins/sync`.
|
4. Every minute while online (app in the foreground), pending check-ins are **uploaded**. Reconnect and returning to the app also upload.
|
||||||
5. **Refresh Offline Data** updates the snapshot without clearing pending outbox rows.
|
5. Header **Sync** is two-way at that page’s scope:
|
||||||
|
- **Events** — all Offline-enabled events (upload pending + refresh all sheets)
|
||||||
|
- **Attendance sessions** — this event (upload pending + refresh all sheets)
|
||||||
|
- **Participants** — this attendance sheet only (upload pending + refresh this roster)
|
||||||
|
6. **Refresh Offline Data** (long-press an event) is a recovery pull of the snapshot without clearing pending outbox rows.
|
||||||
|
7. **Assign a Blank** (when enabled on the attendance session in the dashboard) scans a spare badge QR, lets staff edit name/email/phone, then saves — updating that participant and checking them in. It uses the same offline outbox as check-ins.
|
||||||
|
8. **Undo** on the check-in success sheet reverses the last check-in. For a later mistake, swipe a checked-in row left to reveal **Uncheck-in**. Staff-only; last check-in only; same offline queue. Does not revert Assign a Blank profile.
|
||||||
|
|
||||||
Native rebuild is required after installing `@op-engineering/op-sqlite` / NetInfo (`npm run android` / `npm run ios`).
|
Native rebuild is required after installing `@op-engineering/op-sqlite` / NetInfo (`npm run android` / `npm run ios`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
"react-native-config": "^1.6.1",
|
"react-native-config": "^1.6.1",
|
||||||
|
"react-native-gesture-handler": "^2.28.0",
|
||||||
"react-native-get-random-values": "^2.0.0",
|
"react-native-get-random-values": "^2.0.0",
|
||||||
"react-native-safe-area-context": "^5.5.2",
|
"react-native-safe-area-context": "^5.5.2",
|
||||||
"react-native-screens": "^4.25.1",
|
"react-native-screens": "^4.25.1",
|
||||||
|
|
@ -2026,6 +2027,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@egjs/hammerjs": {
|
||||||
|
"version": "2.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||||
|
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hammerjs": "^2.0.36"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
|
|
@ -3621,6 +3634,12 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/hammerjs": {
|
||||||
|
"version": "2.0.46",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
||||||
|
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
|
|
@ -7121,6 +7140,21 @@
|
||||||
"hermes-estree": "0.33.3"
|
"hermes-estree": "0.33.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
|
@ -10465,6 +10499,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-gesture-handler": {
|
||||||
|
"version": "2.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
|
||||||
|
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@egjs/hammerjs": "^2.0.17",
|
||||||
|
"hoist-non-react-statics": "^3.3.0",
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-get-random-values": {
|
"node_modules/react-native-get-random-values": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,30 @@ export const attendancesApi = {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
undoCheckIn(eventId: string, attendanceId: string, participantId: string) {
|
||||||
|
return api.post<AttendanceCheckInResult>(
|
||||||
|
`/events/${eventId}/attendances/${attendanceId}/undo-check-in`,
|
||||||
|
{ participantId },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
assignBlank(
|
||||||
|
eventId: string,
|
||||||
|
attendanceId: string,
|
||||||
|
body: {
|
||||||
|
participantId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return api.post<AttendanceCheckInResult>(
|
||||||
|
`/events/${eventId}/attendances/${attendanceId}/assign-blank`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
syncCheckIns(
|
syncCheckIns(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
attendanceId: string,
|
attendanceId: string,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface AttendanceRecord {
|
||||||
status: 'active' | 'archived';
|
status: 'active' | 'archived';
|
||||||
settings?: {
|
settings?: {
|
||||||
allowDuplicateCheckIn?: boolean;
|
allowDuplicateCheckIn?: boolean;
|
||||||
|
allowAssignBlank?: boolean;
|
||||||
publicLinkEnabled?: boolean;
|
publicLinkEnabled?: boolean;
|
||||||
};
|
};
|
||||||
uniqueParticipants?: number;
|
uniqueParticipants?: number;
|
||||||
|
|
@ -74,10 +75,19 @@ export interface AttendanceCheckInResult {
|
||||||
alreadyCheckedIn: boolean;
|
alreadyCheckedIn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssignBlankProfile {
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OfflineSyncOperation {
|
export interface OfflineSyncOperation {
|
||||||
clientOpId: string;
|
clientOpId: string;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
checkedInAt: string;
|
checkedInAt: string;
|
||||||
|
profile?: AssignBlankProfile;
|
||||||
|
action?: 'check_in' | 'undo';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OfflineSyncOpResult {
|
export interface OfflineSyncOpResult {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import type { AttendanceCheckInResult } from '../api/types';
|
import type { AttendanceCheckInResult } from '../api/types';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { colors, radii, spacing, typography } from '../theme';
|
||||||
|
|
@ -11,6 +11,8 @@ type Props = {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
pendingSync?: boolean;
|
pendingSync?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onUndo?: () => void | Promise<void>;
|
||||||
|
undoLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHECKIN_GREEN = '#16A34A';
|
const CHECKIN_GREEN = '#16A34A';
|
||||||
|
|
@ -22,8 +24,25 @@ export function CheckInResultModal({
|
||||||
error,
|
error,
|
||||||
pendingSync = false,
|
pendingSync = false,
|
||||||
onClose,
|
onClose,
|
||||||
|
onUndo,
|
||||||
|
undoLoading = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const showUndo = Boolean(result && onUndo && !error);
|
||||||
|
|
||||||
|
const confirmUndo = () => {
|
||||||
|
if (!result || !onUndo || undoLoading) return;
|
||||||
|
Alert.alert('Undo check-in', `Undo check-in for ${result.displayName}?`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Undo',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
void onUndo();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
|
@ -85,7 +104,23 @@ export function CheckInResultModal({
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PrimaryButton title="Done" onPress={onClose} style={styles.doneBtn} />
|
<View style={styles.actions}>
|
||||||
|
{showUndo ? (
|
||||||
|
<Pressable
|
||||||
|
style={styles.undoBtn}
|
||||||
|
onPress={confirmUndo}
|
||||||
|
disabled={undoLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.undoText}>{undoLoading ? 'Undoing…' : 'Undo'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
<PrimaryButton
|
||||||
|
title="Done"
|
||||||
|
onPress={onClose}
|
||||||
|
style={showUndo ? styles.doneBtnFlex : styles.doneBtn}
|
||||||
|
disabled={undoLoading}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -177,7 +212,30 @@ const styles = StyleSheet.create({
|
||||||
color: PENDING_AMBER,
|
color: PENDING_AMBER,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
doneBtn: {
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
|
undoBtn: {
|
||||||
|
height: 52,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
borderRadius: radii.md,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.error,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
undoText: {
|
||||||
|
color: colors.error,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
doneBtn: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
doneBtnFlex: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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'
|
| 'log-out-outline'
|
||||||
| 'mail-outline'
|
| 'mail-outline'
|
||||||
| 'people-outline'
|
| 'people-outline'
|
||||||
|
| 'person-add-outline'
|
||||||
| 'person-outline'
|
| 'person-outline'
|
||||||
| 'qr-code-outline'
|
| 'qr-code-outline'
|
||||||
| 'refresh-outline'
|
| 'refresh-outline'
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { EventsScreen } from '../screens/EventsScreen';
|
||||||
import { AttendanceSessionsScreen } from '../screens/AttendanceSessionsScreen';
|
import { AttendanceSessionsScreen } from '../screens/AttendanceSessionsScreen';
|
||||||
import { ParticipantsScreen } from '../screens/ParticipantsScreen';
|
import { ParticipantsScreen } from '../screens/ParticipantsScreen';
|
||||||
import { QrScannerScreen } from '../screens/QrScannerScreen';
|
import { QrScannerScreen } from '../screens/QrScannerScreen';
|
||||||
|
import { AssignBlankScreen } from '../screens/AssignBlankScreen';
|
||||||
import type { RootStackParamList } from './types';
|
import type { RootStackParamList } from './types';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
import { getAccessToken } from '../api/client';
|
import { getAccessToken } from '../api/client';
|
||||||
|
|
@ -89,6 +90,7 @@ export function RootNavigator() {
|
||||||
<Stack.Screen name="Events" component={EventsScreen} />
|
<Stack.Screen name="Events" component={EventsScreen} />
|
||||||
<Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} />
|
<Stack.Screen name="AttendanceSessions" component={AttendanceSessionsScreen} />
|
||||||
<Stack.Screen name="Participants" component={ParticipantsScreen} />
|
<Stack.Screen name="Participants" component={ParticipantsScreen} />
|
||||||
|
<Stack.Screen name="AssignBlank" component={AssignBlankScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="QrScanner"
|
name="QrScanner"
|
||||||
component={QrScannerScreen}
|
component={QrScannerScreen}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,26 @@ export type RootStackParamList = {
|
||||||
attendanceId: string;
|
attendanceId: string;
|
||||||
attendanceName: string;
|
attendanceName: string;
|
||||||
allowDuplicate: boolean;
|
allowDuplicate: boolean;
|
||||||
|
allowAssignBlank: boolean;
|
||||||
};
|
};
|
||||||
QrScanner: {
|
QrScanner: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
attendanceId: string;
|
attendanceId: string;
|
||||||
attendanceName: string;
|
attendanceName: string;
|
||||||
allowDuplicate: boolean;
|
allowDuplicate: boolean;
|
||||||
|
allowAssignBlank?: boolean;
|
||||||
|
mode?: 'checkIn' | 'assignBlank';
|
||||||
|
};
|
||||||
|
AssignBlank: {
|
||||||
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
|
attendanceId: string;
|
||||||
|
attendanceName: string;
|
||||||
|
participantId: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import React, {
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { AppState, type AppStateStatus } from 'react-native';
|
import { AppState, type AppStateStatus } from 'react-native';
|
||||||
import { getDb } from './db';
|
import { getDb } from './db';
|
||||||
import { runSync, subscribeSyncEngine } from './syncEngine';
|
import { runSync, subscribeSyncEngine, type SyncOpts, type SyncResult } from './syncEngine';
|
||||||
import type { EventOfflineStatus } from './types';
|
import type { EventOfflineStatus } from './types';
|
||||||
import { getEventOfflineStatus, listOfflineEventIds } from './repos';
|
import { getEventOfflineStatus, listOfflineEventIds } from './repos';
|
||||||
|
|
||||||
|
|
@ -20,11 +20,13 @@ type OfflineContextValue = {
|
||||||
lastSyncError: string | null;
|
lastSyncError: string | null;
|
||||||
statusByEvent: Record<string, EventOfflineStatus>;
|
statusByEvent: Record<string, EventOfflineStatus>;
|
||||||
refreshStatuses: () => Promise<void>;
|
refreshStatuses: () => Promise<void>;
|
||||||
syncNow: (eventId?: string) => Promise<{ ok: boolean; flushed: number; error?: string }>;
|
syncNow: (opts?: SyncOpts) => Promise<SyncResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OfflineContext = createContext<OfflineContextValue | null>(null);
|
const OfflineContext = createContext<OfflineContextValue | null>(null);
|
||||||
|
|
||||||
|
const AUTO_SYNC_MS = 60_000;
|
||||||
|
|
||||||
export function OfflineProvider({ children }: { children: React.ReactNode }) {
|
export function OfflineProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [isOnline, setIsOnline] = useState(true);
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
|
|
@ -44,8 +46,8 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const syncNow = useCallback(
|
const syncNow = useCallback(
|
||||||
async (eventId?: string) => {
|
async (opts?: SyncOpts) => {
|
||||||
const result = await runSync(eventId);
|
const result = await runSync(opts);
|
||||||
await refreshStatuses();
|
await refreshStatuses();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
@ -81,7 +83,7 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
|
||||||
const online = Boolean(state.isConnected && state.isInternetReachable !== false);
|
const online = Boolean(state.isConnected && state.isInternetReachable !== false);
|
||||||
setIsOnline(online);
|
setIsOnline(online);
|
||||||
if (online) {
|
if (online) {
|
||||||
syncNow().catch(() => undefined);
|
syncNow({ pull: false }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => unsub();
|
return () => unsub();
|
||||||
|
|
@ -90,13 +92,23 @@ export function OfflineProvider({ children }: { children: React.ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onAppState = (next: AppStateStatus) => {
|
const onAppState = (next: AppStateStatus) => {
|
||||||
if (next === 'active') {
|
if (next === 'active') {
|
||||||
syncNow().catch(() => undefined);
|
syncNow({ pull: false }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const sub = AppState.addEventListener('change', onAppState);
|
const sub = AppState.addEventListener('change', onAppState);
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, [syncNow]);
|
}, [syncNow]);
|
||||||
|
|
||||||
|
const hasPacks = Object.values(statusByEvent).some((s) => s.enabled);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || !isOnline || !hasPacks) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
syncNow({ pull: false }).catch(() => undefined);
|
||||||
|
}, AUTO_SYNC_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [ready, isOnline, hasPacks, syncNow]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
ready,
|
ready,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import NetInfo from '@react-native-community/netinfo';
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { attendancesApi } from '../api/attendances';
|
import { attendancesApi } from '../api/attendances';
|
||||||
import type { AttendanceCheckInResult } from '../api/types';
|
import type { AssignBlankProfile, AttendanceCheckInResult } from '../api/types';
|
||||||
import { getDeviceId } from './deviceId';
|
import { getDeviceId } from './deviceId';
|
||||||
import {
|
import {
|
||||||
applyLocalCheckIn,
|
applyLocalCheckIn,
|
||||||
countPendingForSession,
|
applyLocalProfile,
|
||||||
|
applyLocalUndo,
|
||||||
|
deleteOutbox,
|
||||||
|
findLatestPendingOutboxForParticipant,
|
||||||
getOfflineParticipant,
|
getOfflineParticipant,
|
||||||
getOfflineSession,
|
getOfflineSession,
|
||||||
insertOutbox,
|
insertOutbox,
|
||||||
isOfflinePackActive,
|
isOfflinePackActive,
|
||||||
updateParticipantFromSync,
|
|
||||||
} from './repos';
|
} from './repos';
|
||||||
|
|
||||||
export type CheckInServiceResult =
|
export type CheckInServiceResult =
|
||||||
|
|
@ -22,17 +24,27 @@ async function isOnline(): Promise<boolean> {
|
||||||
return Boolean(state.isConnected && state.isInternetReachable !== false);
|
return Boolean(state.isConnected && state.isInternetReachable !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayNameFromProfile(profile: AssignBlankProfile, fallback?: string | null) {
|
||||||
|
const fromNames = [profile.firstName, profile.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
return fromNames || profile.email || fallback || 'Participant';
|
||||||
|
}
|
||||||
|
|
||||||
async function queueLocalCheckIn(opts: {
|
async function queueLocalCheckIn(opts: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
attendanceId: string;
|
attendanceId: string;
|
||||||
attendanceName: string;
|
attendanceName: string;
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
profile?: AssignBlankProfile;
|
||||||
}): Promise<CheckInServiceResult> {
|
}): Promise<CheckInServiceResult> {
|
||||||
const session = await getOfflineSession(opts.attendanceId);
|
const session = await getOfflineSession(opts.attendanceId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
|
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.profile && session.allowAssignBlank !== 1) {
|
||||||
|
return { ok: false, error: 'Assign a Blank is not enabled for this attendance session.' };
|
||||||
|
}
|
||||||
|
|
||||||
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
|
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
|
||||||
if (!participant) {
|
if (!participant) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -57,10 +69,16 @@ async function queueLocalCheckIn(opts: {
|
||||||
participantId: opts.participantId,
|
participantId: opts.participantId,
|
||||||
checkedInAt,
|
checkedInAt,
|
||||||
deviceId,
|
deviceId,
|
||||||
|
profile: opts.profile ?? null,
|
||||||
});
|
});
|
||||||
|
if (opts.profile) {
|
||||||
|
await applyLocalProfile(opts.attendanceId, opts.participantId, opts.profile);
|
||||||
|
}
|
||||||
await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt);
|
await applyLocalCheckIn(opts.attendanceId, opts.participantId, checkedInAt);
|
||||||
|
|
||||||
const nextCount = participant.checkInCount + 1;
|
const nextCount = participant.checkInCount + 1;
|
||||||
|
const email = opts.profile?.email ?? participant.email ?? '';
|
||||||
|
const phone = opts.profile?.phone ?? participant.phone ?? '';
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
pendingSync: true,
|
pendingSync: true,
|
||||||
|
|
@ -68,9 +86,11 @@ async function queueLocalCheckIn(opts: {
|
||||||
attendanceId: opts.attendanceId,
|
attendanceId: opts.attendanceId,
|
||||||
attendanceName: opts.attendanceName || session.name,
|
attendanceName: opts.attendanceName || session.name,
|
||||||
participantId: opts.participantId,
|
participantId: opts.participantId,
|
||||||
displayName: participant.displayName,
|
displayName: opts.profile
|
||||||
email: participant.email || '',
|
? displayNameFromProfile(opts.profile, participant.displayName)
|
||||||
phone: participant.phone || '',
|
: participant.displayName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
checkInCount: nextCount,
|
checkInCount: nextCount,
|
||||||
lastCheckedInAt: checkedInAt,
|
lastCheckedInAt: checkedInAt,
|
||||||
allowDuplicateCheckIn: allowDuplicate,
|
allowDuplicateCheckIn: allowDuplicate,
|
||||||
|
|
@ -80,7 +100,7 @@ async function queueLocalCheckIn(opts: {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check-in: live API when online with empty outbox; otherwise local outbox.
|
* Check-in: always local outbox when Offline is enabled; live API otherwise.
|
||||||
*/
|
*/
|
||||||
export async function performAppCheckIn(opts: {
|
export async function performAppCheckIn(opts: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
|
@ -89,15 +109,12 @@ export async function performAppCheckIn(opts: {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
}): Promise<CheckInServiceResult> {
|
}): Promise<CheckInServiceResult> {
|
||||||
const packActive = await isOfflinePackActive(opts.eventId);
|
const packActive = await isOfflinePackActive(opts.eventId);
|
||||||
const pending = await countPendingForSession(opts.attendanceId);
|
if (packActive) {
|
||||||
const online = await isOnline();
|
|
||||||
|
|
||||||
if (packActive && (!online || pending > 0)) {
|
|
||||||
return queueLocalCheckIn(opts);
|
return queueLocalCheckIn(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const online = await isOnline();
|
||||||
if (!online) {
|
if (!online) {
|
||||||
if (packActive) return queueLocalCheckIn(opts);
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'You are offline. Enable Offline for this event first to check in without internet.',
|
error: 'You are offline. Enable Offline for this event first to check in without internet.',
|
||||||
|
|
@ -106,21 +123,136 @@ export async function performAppCheckIn(opts: {
|
||||||
|
|
||||||
const res = await attendancesApi.checkIn(opts.eventId, opts.attendanceId, opts.participantId);
|
const res = await attendancesApi.checkIn(opts.eventId, opts.attendanceId, opts.participantId);
|
||||||
if (res.error || !res.data) {
|
if (res.error || !res.data) {
|
||||||
// Network flake after we thought we were online — fall back to outbox if pack exists
|
|
||||||
if (res.status === 0 && packActive) {
|
|
||||||
return queueLocalCheckIn(opts);
|
|
||||||
}
|
|
||||||
return { ok: false, error: res.error || 'Check-in failed' };
|
return { ok: false, error: res.error || 'Check-in failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (packActive) {
|
return { ok: true, result: res.data, pendingSync: false };
|
||||||
await updateParticipantFromSync(
|
}
|
||||||
opts.attendanceId,
|
|
||||||
opts.participantId,
|
export async function performAppAssignBlank(opts: {
|
||||||
res.data.checkInCount,
|
eventId: string;
|
||||||
res.data.lastCheckedInAt,
|
attendanceId: string;
|
||||||
);
|
attendanceName: string;
|
||||||
}
|
participantId: string;
|
||||||
|
profile: AssignBlankProfile;
|
||||||
|
}): Promise<CheckInServiceResult> {
|
||||||
|
const firstName = opts.profile.firstName.trim();
|
||||||
|
if (!firstName) {
|
||||||
|
return { ok: false, error: 'First name is required' };
|
||||||
|
}
|
||||||
|
const profile: AssignBlankProfile = {
|
||||||
|
firstName,
|
||||||
|
lastName: (opts.profile.lastName || '').trim(),
|
||||||
|
email: (opts.profile.email || '').trim().toLowerCase(),
|
||||||
|
phone: (opts.profile.phone || '').trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const packActive = await isOfflinePackActive(opts.eventId);
|
||||||
|
if (packActive) {
|
||||||
|
return queueLocalCheckIn({ ...opts, profile });
|
||||||
|
}
|
||||||
|
|
||||||
|
const online = await isOnline();
|
||||||
|
if (!online) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'You are offline. Enable Offline for this event first to assign a blank badge.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await attendancesApi.assignBlank(opts.eventId, opts.attendanceId, {
|
||||||
|
participantId: opts.participantId,
|
||||||
|
...profile,
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
return { ok: false, error: res.error || 'Assign blank failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, result: res.data, pendingSync: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performAppUndoCheckIn(opts: {
|
||||||
|
eventId: string;
|
||||||
|
attendanceId: string;
|
||||||
|
attendanceName: string;
|
||||||
|
participantId: string;
|
||||||
|
}): Promise<CheckInServiceResult> {
|
||||||
|
const packActive = await isOfflinePackActive(opts.eventId);
|
||||||
|
if (packActive) {
|
||||||
|
const session = await getOfflineSession(opts.attendanceId);
|
||||||
|
if (!session) {
|
||||||
|
return { ok: false, error: 'Offline pack missing this attendance session. Refresh offline data.' };
|
||||||
|
}
|
||||||
|
const participant = await getOfflineParticipant(opts.attendanceId, opts.participantId);
|
||||||
|
if (!participant) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Participant not in offline pack. Refresh offline data while online.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (participant.alreadyCheckedIn !== 1 || participant.checkInCount < 1) {
|
||||||
|
return { ok: false, error: 'Participant is not checked in' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = await findLatestPendingOutboxForParticipant(
|
||||||
|
opts.attendanceId,
|
||||||
|
opts.participantId,
|
||||||
|
);
|
||||||
|
await applyLocalUndo(opts.attendanceId, opts.participantId);
|
||||||
|
|
||||||
|
let pendingSync = false;
|
||||||
|
if (latest && latest.action !== 'undo' && !latest.profile) {
|
||||||
|
await deleteOutbox(latest.clientOpId);
|
||||||
|
} else {
|
||||||
|
const clientOpId = uuidv4();
|
||||||
|
const deviceId = await getDeviceId();
|
||||||
|
await insertOutbox({
|
||||||
|
clientOpId,
|
||||||
|
eventId: opts.eventId,
|
||||||
|
attendanceId: opts.attendanceId,
|
||||||
|
participantId: opts.participantId,
|
||||||
|
checkedInAt: new Date().toISOString(),
|
||||||
|
deviceId,
|
||||||
|
action: 'undo',
|
||||||
|
});
|
||||||
|
pendingSync = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCount = Math.max(0, participant.checkInCount - 1);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
pendingSync,
|
||||||
|
result: {
|
||||||
|
attendanceId: opts.attendanceId,
|
||||||
|
attendanceName: opts.attendanceName || session.name,
|
||||||
|
participantId: opts.participantId,
|
||||||
|
displayName: participant.displayName,
|
||||||
|
email: participant.email ?? '',
|
||||||
|
phone: participant.phone ?? '',
|
||||||
|
checkInCount: nextCount,
|
||||||
|
lastCheckedInAt: nextCount > 0 ? participant.lastCheckedInAt : null,
|
||||||
|
allowDuplicateCheckIn: session.allowDuplicateCheckIn === 1,
|
||||||
|
alreadyCheckedIn: nextCount > 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const online = await isOnline();
|
||||||
|
if (!online) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'You are offline. Enable Offline for this event first to undo a check-in.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await attendancesApi.undoCheckIn(opts.eventId, opts.attendanceId, opts.participantId);
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
const err =
|
||||||
|
res.status === 404
|
||||||
|
? 'Undo API not found on this server. Point the app at a backend that includes POST .../undo-check-in (restart local backend, or deploy it).'
|
||||||
|
: res.error || 'Undo check-in failed';
|
||||||
|
return { ok: false, error: err };
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, result: res.data, pendingSync: false };
|
return { ok: true, result: res.data, pendingSync: false };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS offline_sessions (
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
allow_duplicate_check_in INTEGER NOT NULL DEFAULT 0,
|
allow_duplicate_check_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
allow_assign_blank INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (event_id) REFERENCES offline_events(id) ON DELETE CASCADE
|
FOREIGN KEY (event_id) REFERENCES offline_events(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -59,6 +60,8 @@ CREATE TABLE IF NOT EXISTS outbox (
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
|
profile_json TEXT,
|
||||||
|
action TEXT NOT NULL DEFAULT 'check_in',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
@ -68,6 +71,14 @@ CREATE INDEX IF NOT EXISTS idx_outbox_event ON outbox(event_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_outbox_session ON outbox(attendance_id, status);
|
CREATE INDEX IF NOT EXISTS idx_outbox_session ON outbox(attendance_id, status);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
async function ensureColumn(instance: DB, table: string, column: string, ddl: string): Promise<void> {
|
||||||
|
const res = await instance.execute(`PRAGMA table_info(${table})`);
|
||||||
|
const has = res.rows.some((r) => String(r.name) === column);
|
||||||
|
if (!has) {
|
||||||
|
await instance.execute(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDb(): Promise<DB> {
|
export async function getDb(): Promise<DB> {
|
||||||
if (db) return db;
|
if (db) return db;
|
||||||
if (!readyPromise) {
|
if (!readyPromise) {
|
||||||
|
|
@ -80,6 +91,19 @@ export async function getDb(): Promise<DB> {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((s) => [`${s};`] as [string]),
|
.map((s) => [`${s};`] as [string]),
|
||||||
);
|
);
|
||||||
|
await ensureColumn(
|
||||||
|
instance,
|
||||||
|
'offline_sessions',
|
||||||
|
'allow_assign_blank',
|
||||||
|
'allow_assign_blank INTEGER NOT NULL DEFAULT 0',
|
||||||
|
);
|
||||||
|
await ensureColumn(instance, 'outbox', 'profile_json', 'profile_json TEXT');
|
||||||
|
await ensureColumn(
|
||||||
|
instance,
|
||||||
|
'outbox',
|
||||||
|
'action',
|
||||||
|
"action TEXT NOT NULL DEFAULT 'check_in'",
|
||||||
|
);
|
||||||
db = instance;
|
db = instance;
|
||||||
return instance;
|
return instance;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import type {
|
||||||
OfflineEventRow,
|
OfflineEventRow,
|
||||||
OfflineParticipantRow,
|
OfflineParticipantRow,
|
||||||
OfflineSessionRow,
|
OfflineSessionRow,
|
||||||
|
OutboxAction,
|
||||||
OutboxRow,
|
OutboxRow,
|
||||||
OutboxStatus,
|
OutboxStatus,
|
||||||
|
AssignBlankProfile,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
|
|
@ -87,14 +89,15 @@ export async function replaceSessionsForEvent(
|
||||||
await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]);
|
await db.execute(`DELETE FROM offline_sessions WHERE event_id = ?`, [eventId]);
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
`INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in)
|
`INSERT INTO offline_sessions (id, event_id, name, status, allow_duplicate_check_in, allow_assign_blank)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
s._id,
|
s._id,
|
||||||
eventId,
|
eventId,
|
||||||
s.name,
|
s.name,
|
||||||
s.status,
|
s.status,
|
||||||
s.settings?.allowDuplicateCheckIn ? 1 : 0,
|
s.settings?.allowDuplicateCheckIn ? 1 : 0,
|
||||||
|
s.settings?.allowAssignBlank ? 1 : 0,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +115,7 @@ export async function listOfflineSessions(eventId: string): Promise<OfflineSessi
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
status: String(row.status),
|
status: String(row.status),
|
||||||
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
|
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
|
||||||
|
allowAssignBlank: Number(row.allow_assign_blank) || 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +130,7 @@ export async function getOfflineSession(attendanceId: string): Promise<OfflineSe
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
status: String(row.status),
|
status: String(row.status),
|
||||||
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
|
allowDuplicateCheckIn: Number(row.allow_duplicate_check_in) || 0,
|
||||||
|
allowAssignBlank: Number(row.allow_assign_blank) || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,6 +225,36 @@ export async function getOfflineParticipant(
|
||||||
return mapParticipant(row as Record<string, unknown>);
|
return mapParticipant(row as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyLocalProfile(
|
||||||
|
attendanceId: string,
|
||||||
|
participantId: string,
|
||||||
|
profile: AssignBlankProfile,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
const displayName =
|
||||||
|
[profile.firstName, profile.lastName].filter(Boolean).join(' ').trim() ||
|
||||||
|
profile.email ||
|
||||||
|
'Participant';
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE offline_participants
|
||||||
|
SET first_name = ?,
|
||||||
|
last_name = ?,
|
||||||
|
email = ?,
|
||||||
|
phone = ?,
|
||||||
|
display_name = ?
|
||||||
|
WHERE attendance_id = ? AND participant_id = ?`,
|
||||||
|
[
|
||||||
|
profile.firstName,
|
||||||
|
profile.lastName ?? '',
|
||||||
|
profile.email ?? '',
|
||||||
|
profile.phone ?? '',
|
||||||
|
displayName,
|
||||||
|
attendanceId,
|
||||||
|
participantId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyLocalCheckIn(
|
export async function applyLocalCheckIn(
|
||||||
attendanceId: string,
|
attendanceId: string,
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
|
@ -236,6 +271,18 @@ export async function applyLocalCheckIn(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyLocalUndo(attendanceId: string, participantId: string): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE offline_participants
|
||||||
|
SET already_checked_in = CASE WHEN check_in_count <= 1 THEN 0 ELSE 1 END,
|
||||||
|
last_checked_in_at = CASE WHEN check_in_count <= 1 THEN NULL ELSE last_checked_in_at END,
|
||||||
|
check_in_count = MAX(0, check_in_count - 1)
|
||||||
|
WHERE attendance_id = ? AND participant_id = ?`,
|
||||||
|
[attendanceId, participantId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateParticipantFromSync(
|
export async function updateParticipantFromSync(
|
||||||
attendanceId: string,
|
attendanceId: string,
|
||||||
participantId: string,
|
participantId: string,
|
||||||
|
|
@ -260,14 +307,16 @@ export async function insertOutbox(row: {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
checkedInAt: string;
|
checkedInAt: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
profile?: AssignBlankProfile | null;
|
||||||
|
action?: OutboxAction;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const ts = nowIso();
|
const ts = nowIso();
|
||||||
await db.execute(
|
await db.execute(
|
||||||
`INSERT INTO outbox (
|
`INSERT INTO outbox (
|
||||||
client_op_id, event_id, attendance_id, participant_id, checked_in_at,
|
client_op_id, event_id, attendance_id, participant_id, checked_in_at,
|
||||||
device_id, status, last_error, created_at, updated_at
|
device_id, status, last_error, profile_json, action, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
row.clientOpId,
|
row.clientOpId,
|
||||||
row.eventId,
|
row.eventId,
|
||||||
|
|
@ -275,12 +324,29 @@ export async function insertOutbox(row: {
|
||||||
row.participantId,
|
row.participantId,
|
||||||
row.checkedInAt,
|
row.checkedInAt,
|
||||||
row.deviceId,
|
row.deviceId,
|
||||||
|
row.profile ? JSON.stringify(row.profile) : null,
|
||||||
|
row.action || 'check_in',
|
||||||
ts,
|
ts,
|
||||||
ts,
|
ts,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOutboxAction(raw: unknown): OutboxAction {
|
||||||
|
return String(raw) === 'undo' ? 'undo' : 'check_in';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProfileJson(raw: unknown): AssignBlankProfile | null {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(raw)) as AssignBlankProfile;
|
||||||
|
if (!parsed || typeof parsed.firstName !== 'string') return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mapOutbox(row: Record<string, unknown>): OutboxRow {
|
function mapOutbox(row: Record<string, unknown>): OutboxRow {
|
||||||
return {
|
return {
|
||||||
clientOpId: String(row.client_op_id),
|
clientOpId: String(row.client_op_id),
|
||||||
|
|
@ -293,22 +359,50 @@ function mapOutbox(row: Record<string, unknown>): OutboxRow {
|
||||||
lastError: row.last_error != null ? String(row.last_error) : null,
|
lastError: row.last_error != null ? String(row.last_error) : null,
|
||||||
createdAt: String(row.created_at),
|
createdAt: String(row.created_at),
|
||||||
updatedAt: String(row.updated_at),
|
updatedAt: String(row.updated_at),
|
||||||
|
profile: parseProfileJson(row.profile_json),
|
||||||
|
action: parseOutboxAction(row.action),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPendingOutbox(eventId?: string): Promise<OutboxRow[]> {
|
export async function listPendingOutbox(scope?: {
|
||||||
|
eventId?: string;
|
||||||
|
attendanceId?: string;
|
||||||
|
}): Promise<OutboxRow[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const res = eventId
|
const res = scope?.attendanceId
|
||||||
? await db.execute(
|
? await db.execute(
|
||||||
`SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
|
`SELECT * FROM outbox WHERE attendance_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
|
||||||
[eventId],
|
[scope.attendanceId],
|
||||||
)
|
)
|
||||||
: await db.execute(
|
: scope?.eventId
|
||||||
`SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`,
|
? await db.execute(
|
||||||
);
|
`SELECT * FROM outbox WHERE event_id = ? AND status IN ('pending', 'syncing') ORDER BY created_at ASC`,
|
||||||
|
[scope.eventId],
|
||||||
|
)
|
||||||
|
: await db.execute(
|
||||||
|
`SELECT * FROM outbox WHERE status IN ('pending', 'syncing') ORDER BY created_at ASC`,
|
||||||
|
);
|
||||||
return res.rows.map((r) => mapOutbox(r as Record<string, unknown>));
|
return res.rows.map((r) => mapOutbox(r as Record<string, unknown>));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Overlay leftover outbox rows onto the local roster after a server pull. */
|
||||||
|
export async function reapplyPendingOutboxToRoster(scope?: {
|
||||||
|
eventId?: string;
|
||||||
|
attendanceId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const pending = await listPendingOutbox(scope);
|
||||||
|
for (const row of pending) {
|
||||||
|
if (row.action === 'undo') {
|
||||||
|
await applyLocalUndo(row.attendanceId, row.participantId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.profile) {
|
||||||
|
await applyLocalProfile(row.attendanceId, row.participantId, row.profile);
|
||||||
|
}
|
||||||
|
await applyLocalCheckIn(row.attendanceId, row.participantId, row.checkedInAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listConflictOutbox(eventId: string): Promise<OutboxRow[]> {
|
export async function listConflictOutbox(eventId: string): Promise<OutboxRow[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const res = await db.execute(
|
const res = await db.execute(
|
||||||
|
|
@ -340,6 +434,27 @@ export async function countPendingForSession(attendanceId: string): Promise<numb
|
||||||
return Number(res.rows[0]?.c) || 0;
|
return Number(res.rows[0]?.c) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findLatestPendingOutboxForParticipant(
|
||||||
|
attendanceId: string,
|
||||||
|
participantId: string,
|
||||||
|
): Promise<OutboxRow | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
const res = await db.execute(
|
||||||
|
`SELECT * FROM outbox
|
||||||
|
WHERE attendance_id = ? AND participant_id = ? AND status IN ('pending', 'syncing')
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[attendanceId, participantId],
|
||||||
|
);
|
||||||
|
const row = res.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return mapOutbox(row as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOutbox(clientOpId: string): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.execute(`DELETE FROM outbox WHERE client_op_id = ?`, [clientOpId]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function markOutboxStatus(
|
export async function markOutboxStatus(
|
||||||
clientOpId: string,
|
clientOpId: string,
|
||||||
status: OutboxStatus,
|
status: OutboxStatus,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
import NetInfo from '@react-native-community/netinfo';
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
import { attendancesApi } from '../api/attendances';
|
import { attendancesApi } from '../api/attendances';
|
||||||
import { getDeviceId } from './deviceId';
|
import { getDeviceId } from './deviceId';
|
||||||
import { refreshSessionRoster } from './download';
|
import { refreshOfflinePack, refreshSessionRoster } from './download';
|
||||||
import {
|
import {
|
||||||
|
getOfflineEvent,
|
||||||
|
getOfflineSession,
|
||||||
|
listOfflineEventIds,
|
||||||
listPendingOutbox,
|
listPendingOutbox,
|
||||||
markOutboxStatus,
|
markOutboxStatus,
|
||||||
markOutboxSyncing,
|
markOutboxSyncing,
|
||||||
|
reapplyPendingOutboxToRoster,
|
||||||
setEventLastSyncedAt,
|
setEventLastSyncedAt,
|
||||||
updateParticipantFromSync,
|
updateParticipantFromSync,
|
||||||
} from './repos';
|
} from './repos';
|
||||||
import type { SyncOpResult } from './types';
|
import type { SyncOpResult } from './types';
|
||||||
|
|
||||||
|
export type SyncOpts = {
|
||||||
|
eventId?: string;
|
||||||
|
attendanceId?: string;
|
||||||
|
pull?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncResult = {
|
||||||
|
ok: boolean;
|
||||||
|
flushed: number;
|
||||||
|
pulled: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type SyncListener = (state: { syncing: boolean; lastError: string | null }) => void;
|
type SyncListener = (state: { syncing: boolean; lastError: string | null }) => void;
|
||||||
|
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
|
|
@ -31,16 +48,75 @@ export function isSyncEngineRunning(): boolean {
|
||||||
return syncing;
|
return syncing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatSyncAlert(result: SyncResult): string {
|
||||||
|
if (!result.ok && result.error && result.flushed === 0 && !result.pulled) {
|
||||||
|
return result.error;
|
||||||
|
}
|
||||||
|
const bits: string[] = [];
|
||||||
|
if (result.flushed > 0) {
|
||||||
|
bits.push(`Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
if (result.pulled) {
|
||||||
|
bits.push(result.flushed > 0 ? 'roster refreshed' : 'Roster updated');
|
||||||
|
}
|
||||||
|
if (!bits.length) {
|
||||||
|
return result.error || 'Already up to date.';
|
||||||
|
}
|
||||||
|
const msg = bits.join(' · ');
|
||||||
|
return result.error ? `${msg}. ${result.error}` : msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSyncOpts(a: SyncOpts, b: SyncOpts): SyncOpts {
|
||||||
|
const pull = Boolean(a.pull || b.pull);
|
||||||
|
if (!a.eventId || !b.eventId || a.eventId !== b.eventId) {
|
||||||
|
return { pull };
|
||||||
|
}
|
||||||
|
if (!a.attendanceId || !b.attendanceId || a.attendanceId !== b.attendanceId) {
|
||||||
|
return { eventId: a.eventId, pull };
|
||||||
|
}
|
||||||
|
return { eventId: a.eventId, attendanceId: a.attendanceId, pull };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Deferred = {
|
||||||
|
promise: Promise<SyncResult>;
|
||||||
|
resolve: (r: SyncResult) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDeferred(): Deferred {
|
||||||
|
let resolve: (r: SyncResult) => void = () => undefined;
|
||||||
|
const promise = new Promise<SyncResult>((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = false;
|
||||||
|
let queuedOpts: SyncOpts | null = null;
|
||||||
|
let queuedDeferred: Deferred | null = null;
|
||||||
|
|
||||||
async function flushGroup(
|
async function flushGroup(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
attendanceId: string,
|
attendanceId: string,
|
||||||
ops: { clientOpId: string; participantId: string; checkedInAt: string }[],
|
ops: {
|
||||||
|
clientOpId: string;
|
||||||
|
participantId: string;
|
||||||
|
checkedInAt: string;
|
||||||
|
profile?: { firstName: string; lastName?: string; email?: string; phone?: string } | null;
|
||||||
|
action?: 'check_in' | 'undo';
|
||||||
|
}[],
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
|
refreshRoster: boolean,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
await markOutboxSyncing(ops.map((o) => o.clientOpId));
|
await markOutboxSyncing(ops.map((o) => o.clientOpId));
|
||||||
const res = await attendancesApi.syncCheckIns(eventId, attendanceId, {
|
const res = await attendancesApi.syncCheckIns(eventId, attendanceId, {
|
||||||
deviceId,
|
deviceId,
|
||||||
operations: ops,
|
operations: ops.map((o) => ({
|
||||||
|
clientOpId: o.clientOpId,
|
||||||
|
participantId: o.participantId,
|
||||||
|
checkedInAt: o.checkedInAt,
|
||||||
|
...(o.profile ? { profile: o.profile } : {}),
|
||||||
|
...(o.action === 'undo' ? { action: 'undo' as const } : {}),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.error || !res.data) {
|
if (res.error || !res.data) {
|
||||||
|
|
@ -88,38 +164,70 @@ async function flushGroup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Light roster refresh to pick up other devices
|
if (refreshRoster) {
|
||||||
await refreshSessionRoster(eventId, attendanceId);
|
const rr = await refreshSessionRoster(eventId, attendanceId);
|
||||||
|
if (rr.ok) {
|
||||||
|
await reapplyPendingOutboxToRoster({ attendanceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function pullSheets(opts: SyncOpts): Promise<{ eventIds: string[]; error?: string }> {
|
||||||
* Flush pending outbox. Optionally scope to one event.
|
if (opts.attendanceId) {
|
||||||
*/
|
let eventId = opts.eventId;
|
||||||
export async function runSync(eventId?: string): Promise<{
|
if (!eventId) {
|
||||||
ok: boolean;
|
const session = await getOfflineSession(opts.attendanceId);
|
||||||
flushed: number;
|
eventId = session?.eventId;
|
||||||
error?: string;
|
}
|
||||||
}> {
|
if (!eventId) {
|
||||||
if (syncing) {
|
return { eventIds: [], error: 'Offline pack missing this attendance session.' };
|
||||||
return { ok: false, flushed: 0, error: 'Sync already in progress' };
|
}
|
||||||
|
const res = await refreshSessionRoster(eventId, opts.attendanceId);
|
||||||
|
if (!res.ok) {
|
||||||
|
return { eventIds: [], error: res.error };
|
||||||
|
}
|
||||||
|
await reapplyPendingOutboxToRoster({ attendanceId: opts.attendanceId });
|
||||||
|
return { eventIds: [eventId] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ids = opts.eventId ? [opts.eventId] : await listOfflineEventIds();
|
||||||
|
let lastError: string | undefined;
|
||||||
|
const eventIds: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const event = await getOfflineEvent(id);
|
||||||
|
if (!event) continue;
|
||||||
|
const res = await refreshOfflinePack({
|
||||||
|
_id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
startDate: event.startDate ?? undefined,
|
||||||
|
endDate: event.endDate ?? undefined,
|
||||||
|
location: event.location ?? undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
lastError = res.error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eventIds.push(id);
|
||||||
|
await reapplyPendingOutboxToRoster({ eventId: id });
|
||||||
|
}
|
||||||
|
return { eventIds, error: lastError };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSync(opts: SyncOpts): Promise<SyncResult> {
|
||||||
const net = await NetInfo.fetch();
|
const net = await NetInfo.fetch();
|
||||||
if (!net.isConnected || net.isInternetReachable === false) {
|
if (!net.isConnected || net.isInternetReachable === false) {
|
||||||
return { ok: false, flushed: 0, error: 'No internet connection' };
|
return { ok: false, flushed: 0, pulled: false, error: 'No internet connection' };
|
||||||
}
|
}
|
||||||
|
|
||||||
syncing = true;
|
const scope = { eventId: opts.eventId, attendanceId: opts.attendanceId };
|
||||||
emit(null);
|
const pending = await listPendingOutbox(scope);
|
||||||
|
const pull = Boolean(opts.pull);
|
||||||
try {
|
let flushed = 0;
|
||||||
const pending = await listPendingOutbox(eventId);
|
let lastError: string | null = null;
|
||||||
if (!pending.length) {
|
const touchedEvents = new Set<string>();
|
||||||
if (eventId) await setEventLastSyncedAt(eventId, new Date().toISOString());
|
|
||||||
return { ok: true, flushed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (pending.length) {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = await getDeviceId();
|
||||||
const groups = new Map<string, typeof pending>();
|
const groups = new Map<string, typeof pending>();
|
||||||
for (const row of pending) {
|
for (const row of pending) {
|
||||||
|
|
@ -129,14 +237,9 @@ export async function runSync(eventId?: string): Promise<{
|
||||||
groups.set(key, list);
|
groups.set(key, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
let flushed = 0;
|
|
||||||
let lastError: string | null = null;
|
|
||||||
const touchedEvents = new Set<string>();
|
|
||||||
|
|
||||||
for (const [key, rows] of groups) {
|
for (const [key, rows] of groups) {
|
||||||
const [eid, aid] = key.split('::');
|
const [eid, aid] = key.split('::');
|
||||||
touchedEvents.add(eid);
|
touchedEvents.add(eid);
|
||||||
// Chunk 200
|
|
||||||
for (let i = 0; i < rows.length; i += 200) {
|
for (let i = 0; i < rows.length; i += 200) {
|
||||||
const chunk = rows.slice(i, i + 200);
|
const chunk = rows.slice(i, i + 200);
|
||||||
const err = await flushGroup(
|
const err = await flushGroup(
|
||||||
|
|
@ -146,29 +249,80 @@ export async function runSync(eventId?: string): Promise<{
|
||||||
clientOpId: r.clientOpId,
|
clientOpId: r.clientOpId,
|
||||||
participantId: r.participantId,
|
participantId: r.participantId,
|
||||||
checkedInAt: r.checkedInAt,
|
checkedInAt: r.checkedInAt,
|
||||||
|
profile: r.profile,
|
||||||
|
action: r.action,
|
||||||
})),
|
})),
|
||||||
deviceId,
|
deviceId,
|
||||||
|
!pull,
|
||||||
);
|
);
|
||||||
if (err) lastError = err;
|
if (err) lastError = err;
|
||||||
else flushed += chunk.length;
|
else flushed += chunk.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ts = new Date().toISOString();
|
|
||||||
for (const eid of touchedEvents) {
|
|
||||||
await setEventLastSyncedAt(eid, ts);
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(lastError);
|
|
||||||
return lastError
|
|
||||||
? { ok: false, flushed, error: lastError }
|
|
||||||
: { ok: true, flushed };
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'Sync failed';
|
|
||||||
emit(msg);
|
|
||||||
return { ok: false, flushed: 0, error: msg };
|
|
||||||
} finally {
|
|
||||||
syncing = false;
|
|
||||||
emit(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pulled = false;
|
||||||
|
if (pull) {
|
||||||
|
const pullResult = await pullSheets(opts);
|
||||||
|
for (const id of pullResult.eventIds) touchedEvents.add(id);
|
||||||
|
if (pullResult.error) {
|
||||||
|
lastError = lastError || pullResult.error;
|
||||||
|
}
|
||||||
|
if (pullResult.eventIds.length) {
|
||||||
|
pulled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
for (const eid of touchedEvents) {
|
||||||
|
await setEventLastSyncedAt(eid, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(lastError);
|
||||||
|
return lastError
|
||||||
|
? { ok: false, flushed, pulled, error: lastError }
|
||||||
|
: { ok: true, flushed, pulled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush pending outbox, optionally pull latest sheets.
|
||||||
|
* Overlapping calls are queued and merged (widest scope + pull wins).
|
||||||
|
*/
|
||||||
|
export function runSync(opts: SyncOpts = {}): Promise<SyncResult> {
|
||||||
|
if (running) {
|
||||||
|
queuedOpts = queuedOpts ? mergeSyncOpts(queuedOpts, opts) : { ...opts };
|
||||||
|
if (!queuedDeferred) queuedDeferred = createDeferred();
|
||||||
|
return queuedDeferred.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
syncing = true;
|
||||||
|
emit(null);
|
||||||
|
|
||||||
|
return (async () => {
|
||||||
|
try {
|
||||||
|
const first = await executeSync(opts);
|
||||||
|
while (queuedOpts) {
|
||||||
|
const next = queuedOpts;
|
||||||
|
const deferred = queuedDeferred;
|
||||||
|
queuedOpts = null;
|
||||||
|
queuedDeferred = null;
|
||||||
|
const queuedResult = await executeSync(next);
|
||||||
|
deferred?.resolve(queuedResult);
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Sync failed';
|
||||||
|
const fail: SyncResult = { ok: false, flushed: 0, pulled: false, error: msg };
|
||||||
|
queuedDeferred?.resolve(fail);
|
||||||
|
queuedOpts = null;
|
||||||
|
queuedDeferred = null;
|
||||||
|
emit(msg);
|
||||||
|
return fail;
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
syncing = false;
|
||||||
|
emit(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export type OfflineSessionRow = {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
allowDuplicateCheckIn: number;
|
allowDuplicateCheckIn: number;
|
||||||
|
allowAssignBlank: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OfflineParticipantRow = {
|
export type OfflineParticipantRow = {
|
||||||
|
|
@ -33,6 +34,15 @@ export type OfflineParticipantRow = {
|
||||||
lastCheckedInAt: string | null;
|
lastCheckedInAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AssignBlankProfile = {
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OutboxAction = 'check_in' | 'undo';
|
||||||
|
|
||||||
export type OutboxRow = {
|
export type OutboxRow = {
|
||||||
clientOpId: string;
|
clientOpId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
|
@ -44,6 +54,8 @@ export type OutboxRow = {
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
profile: AssignBlankProfile | null;
|
||||||
|
action: OutboxAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SyncOpResultStatus = 'applied' | 'duplicate' | 'conflict' | 'rejected';
|
export type SyncOpResultStatus = 'applied' | 'duplicate' | 'conflict' | 'rejected';
|
||||||
|
|
|
||||||
|
|
@ -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 { AttendanceRecord } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { useOffline } from '../offline/OfflineContext';
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
|
import { formatSyncAlert } from '../offline/syncEngine';
|
||||||
import {
|
import {
|
||||||
dismissConflict,
|
dismissConflict,
|
||||||
getOfflineEvent,
|
getOfflineEvent,
|
||||||
|
|
@ -31,6 +32,13 @@ const SESSION_ICONS: AppIconName[] = ['business-outline', 'people-outline'];
|
||||||
const CHECKIN_GREEN = '#22C55E';
|
const CHECKIN_GREEN = '#22C55E';
|
||||||
const PEOPLE_BLUE = '#60A5FA';
|
const PEOPLE_BLUE = '#60A5FA';
|
||||||
|
|
||||||
|
function formatLastSynced(iso?: string | null) {
|
||||||
|
if (!iso) return 'not yet';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return 'not yet';
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
function SessionsHeader({
|
function SessionsHeader({
|
||||||
eventName,
|
eventName,
|
||||||
onBack,
|
onBack,
|
||||||
|
|
@ -39,6 +47,7 @@ function SessionsHeader({
|
||||||
pendingCount,
|
pendingCount,
|
||||||
isOnline,
|
isOnline,
|
||||||
offlineReady,
|
offlineReady,
|
||||||
|
lastSyncedAt,
|
||||||
}: {
|
}: {
|
||||||
eventName: string;
|
eventName: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
|
@ -47,6 +56,7 @@ function SessionsHeader({
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
offlineReady: boolean;
|
offlineReady: boolean;
|
||||||
|
lastSyncedAt: string | null;
|
||||||
}) {
|
}) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|
@ -85,7 +95,7 @@ function SessionsHeader({
|
||||||
<Text style={styles.headerSubtitle}>
|
<Text style={styles.headerSubtitle}>
|
||||||
{offlineReady
|
{offlineReady
|
||||||
? isOnline
|
? isOnline
|
||||||
? 'Offline pack ready · Select a session'
|
? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
|
||||||
: 'Working offline · Select a session'
|
: 'Working offline · Select a session'
|
||||||
: 'Select an attendance session'}
|
: 'Select an attendance session'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -115,6 +125,41 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (await getOfflineEvent(eventId)) {
|
||||||
|
const local = await listOfflineSessions(eventId);
|
||||||
|
if (!local.length) {
|
||||||
|
setError(
|
||||||
|
isOnline
|
||||||
|
? 'No active attendance sessions.'
|
||||||
|
: 'No offline data for this event. Go online and Enable Offline first.',
|
||||||
|
);
|
||||||
|
setSessions([]);
|
||||||
|
setFromCache(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped: AttendanceRecord[] = [];
|
||||||
|
for (const s of local) {
|
||||||
|
const participants = await listOfflineParticipants(s.id);
|
||||||
|
const unique = participants.filter((p) => p.alreadyCheckedIn).length;
|
||||||
|
const total = participants.reduce((n, p) => n + (p.checkInCount || 0), 0);
|
||||||
|
mapped.push({
|
||||||
|
_id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status as 'active' | 'archived',
|
||||||
|
settings: {
|
||||||
|
allowDuplicateCheckIn: s.allowDuplicateCheckIn === 1,
|
||||||
|
allowAssignBlank: s.allowAssignBlank === 1,
|
||||||
|
},
|
||||||
|
uniqueParticipants: unique,
|
||||||
|
totalCheckedIn: total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSessions(mapped);
|
||||||
|
setFromCache(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isOnline) {
|
if (isOnline) {
|
||||||
const res = await attendancesApi.list(eventId);
|
const res = await attendancesApi.list(eventId);
|
||||||
if (!res.error && res.data) {
|
if (!res.error && res.data) {
|
||||||
|
|
@ -123,64 +168,35 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
|
||||||
setFromCache(false);
|
setFromCache(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// fall through to cache on failure
|
setError(res.error || 'Failed to load sessions');
|
||||||
if (!(await getOfflineEvent(eventId))) {
|
|
||||||
setError(res.error || 'Failed to load sessions');
|
|
||||||
setSessions([]);
|
|
||||||
setFromCache(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const local = await listOfflineSessions(eventId);
|
|
||||||
if (!local.length) {
|
|
||||||
setError(
|
|
||||||
isOnline
|
|
||||||
? 'No active attendance sessions.'
|
|
||||||
: 'No offline data for this event. Go online and Enable Offline first.',
|
|
||||||
);
|
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
setFromCache(false);
|
setFromCache(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped: AttendanceRecord[] = [];
|
setError('No offline data for this event. Go online and Enable Offline first.');
|
||||||
for (const s of local) {
|
setSessions([]);
|
||||||
const participants = await listOfflineParticipants(s.id);
|
setFromCache(false);
|
||||||
const unique = participants.filter((p) => p.alreadyCheckedIn).length;
|
|
||||||
const total = participants.reduce((n, p) => n + (p.checkInCount || 0), 0);
|
|
||||||
mapped.push({
|
|
||||||
_id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
status: s.status as 'active' | 'archived',
|
|
||||||
settings: { allowDuplicateCheckIn: s.allowDuplicateCheckIn === 1 },
|
|
||||||
uniqueParticipants: unique,
|
|
||||||
totalCheckedIn: total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setSessions(mapped);
|
|
||||||
setFromCache(true);
|
|
||||||
}, [eventId, isOnline]);
|
}, [eventId, isOnline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const unsub = navigation.addListener('focus', () => {
|
||||||
|
load();
|
||||||
|
refreshStatuses();
|
||||||
|
});
|
||||||
(async () => {
|
(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await load();
|
await load();
|
||||||
await refreshStatuses();
|
await refreshStatuses();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})();
|
})();
|
||||||
}, [load, refreshStatuses]);
|
return unsub;
|
||||||
|
}, [navigation, load, refreshStatuses]);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
const result = await syncNow(eventId);
|
const result = await syncNow({ eventId, pull: true });
|
||||||
await load();
|
await load();
|
||||||
if (!result.ok && result.error) {
|
Alert.alert('Sync', formatSyncAlert(result));
|
||||||
Alert.alert('Sync', result.error);
|
|
||||||
} else if (result.flushed > 0) {
|
|
||||||
Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
|
|
||||||
} else {
|
|
||||||
Alert.alert('Sync', 'Nothing pending to sync.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const conflicts = await listConflictOutbox(eventId);
|
const conflicts = await listConflictOutbox(eventId);
|
||||||
if (conflicts.length) {
|
if (conflicts.length) {
|
||||||
|
|
@ -218,6 +234,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
|
||||||
attendanceId: item._id,
|
attendanceId: item._id,
|
||||||
attendanceName: item.name,
|
attendanceName: item.name,
|
||||||
allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
|
allowDuplicate: Boolean(item.settings?.allowDuplicateCheckIn),
|
||||||
|
allowAssignBlank: Boolean(item.settings?.allowAssignBlank),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -265,6 +282,7 @@ export function AttendanceSessionsScreen({ navigation, route }: Props) {
|
||||||
pendingCount={status?.pendingCount ?? 0}
|
pendingCount={status?.pendingCount ?? 0}
|
||||||
isOnline={isOnline}
|
isOnline={isOnline}
|
||||||
offlineReady={offlineReady}
|
offlineReady={offlineReady}
|
||||||
|
lastSyncedAt={status?.lastSyncedAt ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fromCache ? (
|
{fromCache ? (
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type { CheckinsEvent } from '../api/types';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { downloadOfflinePack, refreshOfflinePack } from '../offline/download';
|
import { downloadOfflinePack, refreshOfflinePack } from '../offline/download';
|
||||||
import { useOffline } from '../offline/OfflineContext';
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
|
import { formatSyncAlert } from '../offline/syncEngine';
|
||||||
import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos';
|
import { listOfflineEvents, listOfflineSessions, removeOfflineEventData } from '../offline/repos';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { colors, radii, shadows, spacing, typography } from '../theme';
|
import { colors, radii, shadows, spacing, typography } from '../theme';
|
||||||
|
|
@ -47,6 +48,7 @@ function EventsHeader({
|
||||||
syncing,
|
syncing,
|
||||||
pendingTotal,
|
pendingTotal,
|
||||||
isOnline,
|
isOnline,
|
||||||
|
hasOfflinePacks,
|
||||||
}: {
|
}: {
|
||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
|
@ -54,6 +56,7 @@ function EventsHeader({
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
pendingTotal: number;
|
pendingTotal: number;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
|
hasOfflinePacks: boolean;
|
||||||
}) {
|
}) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|
@ -74,7 +77,7 @@ function EventsHeader({
|
||||||
onPress={onSync}
|
onPress={onSync}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
style={styles.syncBtn}
|
style={styles.syncBtn}
|
||||||
disabled={syncing || !isOnline}
|
disabled={syncing || !isOnline || !hasOfflinePacks}
|
||||||
>
|
>
|
||||||
{syncing ? (
|
{syncing ? (
|
||||||
<ActivityIndicator color="#fff" size="small" />
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
|
@ -191,14 +194,9 @@ export function EventsScreen({ navigation }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
const result = await syncNow();
|
const result = await syncNow({ pull: true });
|
||||||
if (!result.ok && result.error) {
|
await load();
|
||||||
Alert.alert('Sync', result.error);
|
Alert.alert('Sync', formatSyncAlert(result));
|
||||||
} else if (result.flushed > 0) {
|
|
||||||
Alert.alert('Sync', `Synced ${result.flushed} check-in${result.flushed === 1 ? '' : 's'}.`);
|
|
||||||
} else {
|
|
||||||
Alert.alert('Sync', 'Nothing pending to sync.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const enableOffline = async (item: CheckinsEvent) => {
|
const enableOffline = async (item: CheckinsEvent) => {
|
||||||
|
|
@ -285,6 +283,7 @@ export function EventsScreen({ navigation }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingTotal = Object.values(statusByEvent).reduce((n, s) => n + s.pendingCount, 0);
|
const pendingTotal = Object.values(statusByEvent).reduce((n, s) => n + s.pendingCount, 0);
|
||||||
|
const hasOfflinePacks = Object.values(statusByEvent).some((s) => s.enabled);
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: CheckinsEvent }) => {
|
const renderItem = ({ item }: { item: CheckinsEvent }) => {
|
||||||
const sessionCount = item.attendanceSessionCount;
|
const sessionCount = item.attendanceSessionCount;
|
||||||
|
|
@ -390,6 +389,7 @@ export function EventsScreen({ navigation }: Props) {
|
||||||
syncing={syncing}
|
syncing={syncing}
|
||||||
pendingTotal={pendingTotal}
|
pendingTotal={pendingTotal}
|
||||||
isOnline={isOnline}
|
isOnline={isOnline}
|
||||||
|
hasOfflinePacks={hasOfflinePacks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
FlatList,
|
FlatList,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
|
@ -13,9 +14,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import { attendancesApi } from '../api/attendances';
|
import { attendancesApi } from '../api/attendances';
|
||||||
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
|
import type { AttendanceCheckInResult, AttendanceSearchParticipant } from '../api/types';
|
||||||
import { CheckInResultModal } from '../components/CheckInResultModal';
|
import { CheckInResultModal } from '../components/CheckInResultModal';
|
||||||
|
import { SwipeRow } from '../components/SwipeRow';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { performAppCheckIn } from '../offline/checkInService';
|
import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService';
|
||||||
import { useOffline } from '../offline/OfflineContext';
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
|
import { formatSyncAlert } from '../offline/syncEngine';
|
||||||
import {
|
import {
|
||||||
countPendingForSession,
|
countPendingForSession,
|
||||||
isOfflinePackActive,
|
isOfflinePackActive,
|
||||||
|
|
@ -51,18 +54,41 @@ function formatCheckInTime(iso?: string | null) {
|
||||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLastSynced(iso?: string | null) {
|
||||||
|
if (!iso) return 'not yet';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return 'not yet';
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
function ParticipantsHeader({
|
function ParticipantsHeader({
|
||||||
eventName,
|
eventName,
|
||||||
sessionName,
|
sessionName,
|
||||||
checkedInCount,
|
checkedInCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
onlyCheckedIn,
|
||||||
|
onToggleCheckedIn,
|
||||||
onBack,
|
onBack,
|
||||||
|
onSync,
|
||||||
|
syncing,
|
||||||
|
pendingCount,
|
||||||
|
isOnline,
|
||||||
|
offlineReady,
|
||||||
|
lastSyncedAt,
|
||||||
}: {
|
}: {
|
||||||
eventName: string;
|
eventName: string;
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
checkedInCount: number;
|
checkedInCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
onlyCheckedIn: boolean;
|
||||||
|
onToggleCheckedIn: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onSync: () => void;
|
||||||
|
syncing: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
isOnline: boolean;
|
||||||
|
offlineReady: boolean;
|
||||||
|
lastSyncedAt: string | null;
|
||||||
}) {
|
}) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|
@ -71,12 +97,31 @@ function ParticipantsHeader({
|
||||||
<View style={styles.headerBg} />
|
<View style={styles.headerBg} />
|
||||||
<View style={styles.headerCircle} />
|
<View style={styles.headerCircle} />
|
||||||
|
|
||||||
<Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}>
|
<View style={styles.headerTopRow}>
|
||||||
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
|
<Pressable onPress={onBack} hitSlop={10} style={styles.backBtn}>
|
||||||
<Text style={styles.backLabel} numberOfLines={1}>
|
<AppIcon name="arrow-back" size={18} color="rgba(255,255,255,0.85)" />
|
||||||
{eventName}
|
<Text style={styles.backLabel} numberOfLines={1}>
|
||||||
</Text>
|
{eventName}
|
||||||
</Pressable>
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{offlineReady ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onSync}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.syncBtn}
|
||||||
|
disabled={syncing || !isOnline}
|
||||||
|
>
|
||||||
|
{syncing ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<AppIcon name="cloud-upload-outline" size={16} color="#fff" />
|
||||||
|
)}
|
||||||
|
<Text style={styles.syncText}>
|
||||||
|
Sync{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.headerMain}>
|
<View style={styles.headerMain}>
|
||||||
<View style={styles.headerTextCol}>
|
<View style={styles.headerTextCol}>
|
||||||
|
|
@ -84,13 +129,29 @@ function ParticipantsHeader({
|
||||||
{sessionName}
|
{sessionName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.headerSubtitle}>
|
<Text style={styles.headerSubtitle}>
|
||||||
Attendance · {checkedInCount}/{totalCount} checked in
|
{offlineReady
|
||||||
|
? `Offline mode · last synced ${formatLastSynced(lastSyncedAt)}`
|
||||||
|
: `Attendance · ${checkedInCount}/${totalCount} checked in`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.checkedBadge}>
|
<Pressable
|
||||||
<Text style={styles.checkedBadgeCount}>{checkedInCount}</Text>
|
onPress={onToggleCheckedIn}
|
||||||
<Text style={styles.checkedBadgeLabel}>checked in</Text>
|
accessibilityRole="button"
|
||||||
</View>
|
accessibilityState={{ selected: onlyCheckedIn }}
|
||||||
|
accessibilityLabel="Show only checked-in participants"
|
||||||
|
style={[styles.checkedBadge, onlyCheckedIn ? styles.checkedBadgeActive : null]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.checkedBadgeCount, onlyCheckedIn ? styles.checkedBadgeCountActive : null]}
|
||||||
|
>
|
||||||
|
{checkedInCount}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.checkedBadgeLabel, onlyCheckedIn ? styles.checkedBadgeLabelActive : null]}
|
||||||
|
>
|
||||||
|
checked in
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -127,9 +188,10 @@ function SearchBar({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ParticipantsScreen({ navigation, route }: Props) {
|
export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
const { eventId, eventName, attendanceId, attendanceName, allowDuplicate } = route.params;
|
const { eventId, eventName, attendanceId, attendanceName, allowDuplicate, allowAssignBlank } =
|
||||||
|
route.params;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { isOnline, refreshStatuses } = useOffline();
|
const { isOnline, syncing, statusByEvent, refreshStatuses, syncNow } = useOffline();
|
||||||
const [all, setAll] = useState<AttendanceSearchParticipant[]>([]);
|
const [all, setAll] = useState<AttendanceSearchParticipant[]>([]);
|
||||||
const [searchQ, setSearchQ] = useState('');
|
const [searchQ, setSearchQ] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -139,15 +201,26 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
const [resultError, setResultError] = useState<string | null>(null);
|
const [resultError, setResultError] = useState<string | null>(null);
|
||||||
const [pendingSync, setPendingSync] = useState(false);
|
const [pendingSync, setPendingSync] = useState(false);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [undoLoading, setUndoLoading] = useState(false);
|
||||||
|
const [openSwipeId, setOpenSwipeId] = useState<string | null>(null);
|
||||||
const [fromCache, setFromCache] = useState(false);
|
const [fromCache, setFromCache] = useState(false);
|
||||||
|
const [sessionPending, setSessionPending] = useState(0);
|
||||||
|
const [onlyCheckedIn, setOnlyCheckedIn] = useState(false);
|
||||||
|
|
||||||
|
const status = statusByEvent[eventId];
|
||||||
|
const offlineReady = Boolean(status?.enabled);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({ gestureEnabled: openSwipeId == null });
|
||||||
|
}, [navigation, openSwipeId]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
const packActive = await isOfflinePackActive(eventId);
|
const packActive = await isOfflinePackActive(eventId);
|
||||||
const pending = packActive ? await countPendingForSession(attendanceId) : 0;
|
const pending = packActive ? await countPendingForSession(attendanceId) : 0;
|
||||||
|
setSessionPending(pending);
|
||||||
|
|
||||||
// Prefer local roster when offline, or when unsynced outbox would diverge from server.
|
if (packActive) {
|
||||||
if (packActive && (!isOnline || pending > 0)) {
|
|
||||||
const local = await listOfflineParticipants(attendanceId);
|
const local = await listOfflineParticipants(attendanceId);
|
||||||
setAll(local);
|
setAll(local);
|
||||||
setFromCache(true);
|
setFromCache(true);
|
||||||
|
|
@ -161,12 +234,6 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
setFromCache(false);
|
setFromCache(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (packActive) {
|
|
||||||
const local = await listOfflineParticipants(attendanceId);
|
|
||||||
setAll(local);
|
|
||||||
setFromCache(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(res.error || 'Failed to load participants');
|
setError(res.error || 'Failed to load participants');
|
||||||
setAll([]);
|
setAll([]);
|
||||||
setFromCache(false);
|
setFromCache(false);
|
||||||
|
|
@ -191,8 +258,11 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
}, [navigation, load]);
|
}, [navigation, load]);
|
||||||
|
|
||||||
const displayed = useMemo(
|
const displayed = useMemo(
|
||||||
() => all.filter((p) => matchesParticipant(p, searchQ)),
|
() =>
|
||||||
[all, searchQ],
|
all.filter(
|
||||||
|
(p) => (!onlyCheckedIn || p.alreadyCheckedIn) && matchesParticipant(p, searchQ),
|
||||||
|
),
|
||||||
|
[all, searchQ, onlyCheckedIn],
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkedInCount = useMemo(
|
const checkedInCount = useMemo(
|
||||||
|
|
@ -225,13 +295,73 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
await refreshStatuses();
|
await refreshStatuses();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
const outcome = await syncNow({ eventId, attendanceId, pull: true });
|
||||||
|
await load();
|
||||||
|
Alert.alert('Sync', formatSyncAlert(outcome));
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
|
if (undoLoading) return;
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
setPendingSync(false);
|
setPendingSync(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
if (!result) return;
|
||||||
|
setUndoLoading(true);
|
||||||
|
const outcome = await performAppUndoCheckIn({
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
participantId: result.participantId,
|
||||||
|
});
|
||||||
|
setUndoLoading(false);
|
||||||
|
if (!outcome.ok) {
|
||||||
|
Alert.alert('Undo failed', outcome.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModalVisible(false);
|
||||||
|
setResult(null);
|
||||||
|
setResultError(null);
|
||||||
|
setPendingSync(false);
|
||||||
|
await load();
|
||||||
|
await refreshStatuses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoFromList = async (item: AttendanceSearchParticipant) => {
|
||||||
|
setOpenSwipeId(null);
|
||||||
|
setBusyId(item._id);
|
||||||
|
const outcome = await performAppUndoCheckIn({
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
participantId: item._id,
|
||||||
|
});
|
||||||
|
setBusyId(null);
|
||||||
|
if (!outcome.ok) {
|
||||||
|
Alert.alert('Undo failed', outcome.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
await refreshStatuses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmUncheck = (item: AttendanceSearchParticipant) => {
|
||||||
|
Alert.alert('Uncheck-in', `Remove last check-in for ${item.displayName}?`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Uncheck-in',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
void undoFromList(item);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const listBottomPad = Math.max(insets.bottom, 16) + 88;
|
const listBottomPad = Math.max(insets.bottom, 16) + 88;
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: AttendanceSearchParticipant }) => {
|
const renderItem = ({ item }: { item: AttendanceSearchParticipant }) => {
|
||||||
|
|
@ -241,65 +371,88 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
const meta = [item.email, item.phone].filter(Boolean).join(' · ') || '—';
|
const meta = [item.email, item.phone].filter(Boolean).join(' · ') || '—';
|
||||||
const checkInTime = formatCheckInTime(item.lastCheckedInAt);
|
const checkInTime = formatCheckInTime(item.lastCheckedInAt);
|
||||||
|
|
||||||
return (
|
const rowInner = (
|
||||||
<Card style={styles.rowCard} padded>
|
<View style={styles.row}>
|
||||||
<View style={styles.row}>
|
<View
|
||||||
<View
|
style={[
|
||||||
|
styles.avatar,
|
||||||
|
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.avatar,
|
styles.avatarText,
|
||||||
item.alreadyCheckedIn ? styles.avatarChecked : styles.avatarPending,
|
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
{getInitial(item.displayName)}
|
||||||
style={[
|
</Text>
|
||||||
styles.avatarText,
|
</View>
|
||||||
item.alreadyCheckedIn ? styles.avatarTextChecked : styles.avatarTextPending,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{getInitial(item.displayName)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.rowText}>
|
<View style={styles.rowText}>
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
{item.displayName}
|
{item.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.meta} numberOfLines={1}>
|
<Text style={styles.meta} numberOfLines={1}>
|
||||||
{meta}
|
{meta}
|
||||||
</Text>
|
</Text>
|
||||||
{item.alreadyCheckedIn ? (
|
{item.alreadyCheckedIn ? (
|
||||||
<View style={styles.checkedRow}>
|
<View style={styles.checkedRow}>
|
||||||
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
|
<AppIcon name="checkmark-circle-outline" size={11} color={CHECKIN_GREEN} />
|
||||||
<Text style={styles.checked}>
|
<Text style={styles.checked}>
|
||||||
Checked in{checkInTime ? ` · ${checkInTime}` : ''}
|
Checked in{checkInTime ? ` · ${checkInTime}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{showAction ? (
|
|
||||||
<Pressable
|
|
||||||
style={[
|
|
||||||
styles.checkBtn,
|
|
||||||
isAgain ? styles.checkBtnAgain : styles.checkBtnPrimary,
|
|
||||||
]}
|
|
||||||
disabled={busyId === item._id}
|
|
||||||
onPress={() => checkIn(item._id)}
|
|
||||||
>
|
|
||||||
{busyId === item._id ? (
|
|
||||||
<ActivityIndicator color="#fff" size="small" />
|
|
||||||
) : isAgain ? (
|
|
||||||
<View style={styles.checkBtnInner}>
|
|
||||||
<AppIcon name="refresh-outline" size={11} color="#fff" />
|
|
||||||
<Text style={styles.checkBtnText}>Again</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.checkBtnText}>Check In</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
|
||||||
|
{showAction ? (
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.checkBtn,
|
||||||
|
isAgain ? styles.checkBtnAgain : styles.checkBtnPrimary,
|
||||||
|
]}
|
||||||
|
disabled={busyId === item._id}
|
||||||
|
onPress={() => checkIn(item._id)}
|
||||||
|
>
|
||||||
|
{busyId === item._id ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : isAgain ? (
|
||||||
|
<View style={styles.checkBtnInner}>
|
||||||
|
<AppIcon name="refresh-outline" size={11} color="#fff" />
|
||||||
|
<Text style={styles.checkBtnText}>Again</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.checkBtnText}>Check In</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item.alreadyCheckedIn) {
|
||||||
|
return (
|
||||||
|
<Card style={styles.rowCard} padded>
|
||||||
|
{rowInner}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeRow
|
||||||
|
enabled
|
||||||
|
open={openSwipeId === item._id}
|
||||||
|
onOpen={() => setOpenSwipeId(item._id)}
|
||||||
|
onClose={() => setOpenSwipeId((id) => (id === item._id ? null : id))}
|
||||||
|
actionLabel="Uncheck-in"
|
||||||
|
actionBusy={busyId === item._id}
|
||||||
|
onActionPress={() => confirmUncheck(item)}
|
||||||
|
style={styles.swipeRow}
|
||||||
|
>
|
||||||
|
<Card style={styles.swipeCard} padded>
|
||||||
|
{rowInner}
|
||||||
|
</Card>
|
||||||
|
</SwipeRow>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -310,7 +463,17 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
sessionName={attendanceName}
|
sessionName={attendanceName}
|
||||||
checkedInCount={checkedInCount}
|
checkedInCount={checkedInCount}
|
||||||
totalCount={all.length}
|
totalCount={all.length}
|
||||||
|
onlyCheckedIn={onlyCheckedIn}
|
||||||
|
onToggleCheckedIn={() => setOnlyCheckedIn((v) => !v)}
|
||||||
onBack={() => navigation.goBack()}
|
onBack={() => navigation.goBack()}
|
||||||
|
onSync={() => {
|
||||||
|
handleSync();
|
||||||
|
}}
|
||||||
|
syncing={syncing}
|
||||||
|
pendingCount={sessionPending}
|
||||||
|
isOnline={isOnline}
|
||||||
|
offlineReady={offlineReady}
|
||||||
|
lastSyncedAt={status?.lastSyncedAt ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchBar value={searchQ} onChangeText={setSearchQ} />
|
<SearchBar value={searchQ} onChangeText={setSearchQ} />
|
||||||
|
|
@ -326,6 +489,25 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
<Text style={styles.countText}>
|
<Text style={styles.countText}>
|
||||||
{displayed.length} participant{displayed.length === 1 ? '' : 's'}
|
{displayed.length} participant{displayed.length === 1 ? '' : 's'}
|
||||||
</Text>
|
</Text>
|
||||||
|
{allowAssignBlank ? (
|
||||||
|
<Pressable
|
||||||
|
style={styles.assignBtn}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate('QrScanner', {
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
allowDuplicate,
|
||||||
|
allowAssignBlank: true,
|
||||||
|
mode: 'assignBlank',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AppIcon name="person-add-outline" size={14} color="#fff" />
|
||||||
|
<Text style={styles.assignBtnText}>Assign a Blank</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -355,9 +537,12 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigation.navigate('QrScanner', {
|
navigation.navigate('QrScanner', {
|
||||||
eventId,
|
eventId,
|
||||||
|
eventName,
|
||||||
attendanceId,
|
attendanceId,
|
||||||
attendanceName,
|
attendanceName,
|
||||||
allowDuplicate,
|
allowDuplicate,
|
||||||
|
allowAssignBlank: Boolean(allowAssignBlank),
|
||||||
|
mode: 'checkIn',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -371,6 +556,8 @@ export function ParticipantsScreen({ navigation, route }: Props) {
|
||||||
error={resultError}
|
error={resultError}
|
||||||
pendingSync={pendingSync}
|
pendingSync={pendingSync}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
|
onUndo={result ? handleUndo : undefined}
|
||||||
|
undoLoading={undoLoading}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -398,14 +585,35 @@ const styles = StyleSheet.create({
|
||||||
backgroundColor: 'rgba(255,255,255,0.10)',
|
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
},
|
},
|
||||||
|
headerTopRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
backBtn: {
|
backBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: spacing.md,
|
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
maxWidth: '85%',
|
flex: 1,
|
||||||
zIndex: 1,
|
minWidth: 0,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
syncBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.18)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: radii.pill,
|
||||||
|
},
|
||||||
|
syncText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
backLabel: {
|
backLabel: {
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
|
@ -438,6 +646,17 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minWidth: 64,
|
minWidth: 64,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
checkedBadgeActive: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderColor: '#fff',
|
||||||
|
shadowColor: '#fff',
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 1,
|
||||||
|
shadowRadius: 14,
|
||||||
|
elevation: 14,
|
||||||
},
|
},
|
||||||
checkedBadgeCount: {
|
checkedBadgeCount: {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
|
|
@ -445,10 +664,16 @@ const styles = StyleSheet.create({
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
lineHeight: 24,
|
lineHeight: 24,
|
||||||
},
|
},
|
||||||
|
checkedBadgeCountActive: {
|
||||||
|
color: colors.primaryDark,
|
||||||
|
},
|
||||||
checkedBadgeLabel: {
|
checkedBadgeLabel: {
|
||||||
color: 'rgba(255,255,255,0.95)',
|
color: 'rgba(255,255,255,0.95)',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
},
|
},
|
||||||
|
checkedBadgeLabelActive: {
|
||||||
|
color: colors.primaryDark,
|
||||||
|
},
|
||||||
cacheBanner: {
|
cacheBanner: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -487,16 +712,35 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
},
|
},
|
||||||
countStrip: {
|
countStrip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingVertical: 10,
|
paddingVertical: 8,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#F3F4F6',
|
borderBottomColor: '#F3F4F6',
|
||||||
},
|
},
|
||||||
countText: {
|
countText: {
|
||||||
|
flex: 1,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.muted,
|
color: colors.muted,
|
||||||
},
|
},
|
||||||
|
assignBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: colors.primaryDark,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
},
|
||||||
|
assignBtnText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
list: {
|
list: {
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingTop: spacing.md,
|
paddingTop: spacing.md,
|
||||||
|
|
@ -513,6 +757,14 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: radii.xl,
|
borderRadius: radii.xl,
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
|
swipeRow: {
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
swipeCard: {
|
||||||
|
marginBottom: 0,
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -15,11 +16,13 @@ import {
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
useCodeScanner,
|
useCodeScanner,
|
||||||
} from 'react-native-vision-camera';
|
} from 'react-native-vision-camera';
|
||||||
|
import { attendancesApi } from '../api/attendances';
|
||||||
import type { AttendanceCheckInResult } from '../api/types';
|
import type { AttendanceCheckInResult } from '../api/types';
|
||||||
import { CheckInResultModal } from '../components/CheckInResultModal';
|
import { CheckInResultModal } from '../components/CheckInResultModal';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { performAppCheckIn } from '../offline/checkInService';
|
import { performAppCheckIn, performAppUndoCheckIn } from '../offline/checkInService';
|
||||||
import { useOffline } from '../offline/OfflineContext';
|
import { useOffline } from '../offline/OfflineContext';
|
||||||
|
import { getOfflineParticipant, isOfflinePackActive } from '../offline/repos';
|
||||||
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
|
import { parseParticipantIdFromQr } from '../utils/parseParticipantQr';
|
||||||
import { colors, radii, spacing, typography } from '../theme';
|
import { colors, radii, spacing, typography } from '../theme';
|
||||||
import { PrimaryButton } from '../components/ui';
|
import { PrimaryButton } from '../components/ui';
|
||||||
|
|
@ -112,7 +115,8 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
const headerTop = insets.top + spacing.md;
|
const headerTop = insets.top + spacing.md;
|
||||||
const [headerBottom, setHeaderBottom] = useState(headerTop + 96);
|
const [headerBottom, setHeaderBottom] = useState(headerTop + 96);
|
||||||
const scanTop = headerBottom + SCAN_GAP_BELOW_HEADER;
|
const scanTop = headerBottom + SCAN_GAP_BELOW_HEADER;
|
||||||
const { eventId, attendanceId, attendanceName } = route.params;
|
const { eventId, eventName, attendanceId, attendanceName, mode = 'checkIn' } = route.params;
|
||||||
|
const assignBlank = mode === 'assignBlank';
|
||||||
const { refreshStatuses } = useOffline();
|
const { refreshStatuses } = useOffline();
|
||||||
const { hasPermission, requestPermission } = useCameraPermission();
|
const { hasPermission, requestPermission } = useCameraPermission();
|
||||||
const device = useCameraDevice('back');
|
const device = useCameraDevice('back');
|
||||||
|
|
@ -122,6 +126,30 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
const [resultError, setResultError] = useState<string | null>(null);
|
const [resultError, setResultError] = useState<string | null>(null);
|
||||||
const [pendingSync, setPendingSync] = useState(false);
|
const [pendingSync, setPendingSync] = useState(false);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [undoLoading, setUndoLoading] = useState(false);
|
||||||
|
|
||||||
|
const goToAssignForm = useCallback(
|
||||||
|
(draft: {
|
||||||
|
participantId: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
}) => {
|
||||||
|
navigation.replace('AssignBlank', {
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
participantId: draft.participantId,
|
||||||
|
firstName: draft.firstName,
|
||||||
|
lastName: draft.lastName,
|
||||||
|
email: draft.email,
|
||||||
|
phone: draft.phone,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigation, eventId, eventName, attendanceId, attendanceName],
|
||||||
|
);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
async (raw: string) => {
|
async (raw: string) => {
|
||||||
|
|
@ -141,6 +169,82 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
setPendingSync(false);
|
setPendingSync(false);
|
||||||
|
|
||||||
|
if (assignBlank) {
|
||||||
|
const packActive = await isOfflinePackActive(eventId);
|
||||||
|
let firstName = '';
|
||||||
|
let lastName = '';
|
||||||
|
let email = '';
|
||||||
|
let phone = '';
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
if (packActive) {
|
||||||
|
const local = await getOfflineParticipant(attendanceId, parsed.participantId);
|
||||||
|
if (local) {
|
||||||
|
found = true;
|
||||||
|
firstName = local.firstName || '';
|
||||||
|
lastName = local.lastName || '';
|
||||||
|
email = local.email || '';
|
||||||
|
phone = local.phone || '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const preview = await attendancesApi.getParticipantPreview(
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
parsed.participantId,
|
||||||
|
);
|
||||||
|
if (!preview.error && preview.data) {
|
||||||
|
found = true;
|
||||||
|
firstName = preview.data.firstName || '';
|
||||||
|
lastName = preview.data.lastName || '';
|
||||||
|
email = preview.data.email || '';
|
||||||
|
phone = preview.data.phone || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(false);
|
||||||
|
if (!found) {
|
||||||
|
setResult(null);
|
||||||
|
setResultError(
|
||||||
|
packActive
|
||||||
|
? 'Participant not in offline pack. Refresh offline data while online.'
|
||||||
|
: 'Participant not in this attendance list.',
|
||||||
|
);
|
||||||
|
setModalVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = () =>
|
||||||
|
goToAssignForm({
|
||||||
|
participantId: parsed.participantId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstName.trim() || email.trim()) {
|
||||||
|
const label = [firstName, lastName].filter(Boolean).join(' ').trim() || email;
|
||||||
|
Alert.alert(
|
||||||
|
'This badge already has details',
|
||||||
|
`${label} is already named. Assign this blank anyway?`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Cancel',
|
||||||
|
style: 'cancel',
|
||||||
|
onPress: () => {
|
||||||
|
scannedRef.current = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ text: 'Continue', onPress: openForm },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const outcome = await performAppCheckIn({
|
const outcome = await performAppCheckIn({
|
||||||
eventId,
|
eventId,
|
||||||
attendanceId,
|
attendanceId,
|
||||||
|
|
@ -161,7 +265,15 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
}
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
},
|
},
|
||||||
[eventId, attendanceId, attendanceName, processing, refreshStatuses],
|
[
|
||||||
|
assignBlank,
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
processing,
|
||||||
|
refreshStatuses,
|
||||||
|
goToAssignForm,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const codeScanner = useCodeScanner({
|
const codeScanner = useCodeScanner({
|
||||||
|
|
@ -173,6 +285,7 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
|
if (undoLoading) return;
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setResultError(null);
|
setResultError(null);
|
||||||
|
|
@ -183,6 +296,28 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
if (!result) return;
|
||||||
|
setUndoLoading(true);
|
||||||
|
const outcome = await performAppUndoCheckIn({
|
||||||
|
eventId,
|
||||||
|
attendanceId,
|
||||||
|
attendanceName,
|
||||||
|
participantId: result.participantId,
|
||||||
|
});
|
||||||
|
setUndoLoading(false);
|
||||||
|
if (!outcome.ok) {
|
||||||
|
Alert.alert('Undo failed', outcome.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModalVisible(false);
|
||||||
|
setResult(null);
|
||||||
|
setResultError(null);
|
||||||
|
setPendingSync(false);
|
||||||
|
scannedRef.current = false;
|
||||||
|
refreshStatuses().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
|
|
@ -234,7 +369,9 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>{attendanceName}</Text>
|
<Text style={styles.title}>{attendanceName}</Text>
|
||||||
<Text style={styles.hint}>Point at participant badge QR</Text>
|
<Text style={styles.hint}>
|
||||||
|
{assignBlank ? 'Scan blank badge QR' : 'Point at participant badge QR'}
|
||||||
|
</Text>
|
||||||
{processing ? (
|
{processing ? (
|
||||||
<ActivityIndicator color="#fff" size="large" style={styles.spinner} />
|
<ActivityIndicator color="#fff" size="large" style={styles.spinner} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -252,6 +389,8 @@ export function QrScannerScreen({ navigation, route }: Props) {
|
||||||
error={resultError}
|
error={resultError}
|
||||||
pendingSync={pendingSync}
|
pendingSync={pendingSync}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
|
onUndo={result && !assignBlank ? handleUndo : undefined}
|
||||||
|
undoLoading={undoLoading}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue