diff --git a/mobile/App.tsx b/mobile/App.tsx
index b64b98b..9be5f03 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -8,6 +8,7 @@ import { UploadScreen } from './app/upload';
import { ScanScreen } from './app/scan';
import { SearchScreen } from './app/search';
import { BatchReviewScreen } from './app/batch-review';
+import { PendingReviewScreen } from './app/pending-review';
const Stack = createNativeStackNavigator();
const queryClient = new QueryClient();
@@ -22,6 +23,7 @@ export default function App() {
+
diff --git a/mobile/app/batch-review.tsx b/mobile/app/batch-review.tsx
index 605b76d..4c06cd4 100644
--- a/mobile/app/batch-review.tsx
+++ b/mobile/app/batch-review.tsx
@@ -1,24 +1,27 @@
-import React, { useState } from 'react';
-import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Alert, ActivityIndicator, Share } from 'react-native';
+import React, { useState, useCallback } from 'react';
+import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Alert, ActivityIndicator } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
+import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useBatchStore } from '../hooks/useBatchStore';
import { usePdfGeneration } from '../hooks/usePdfGeneration';
-type BatchReviewRouteParams = {
+type RootStackParamList = {
+ Home: undefined;
BatchReview: { batchId: string };
+ PendingReview: { batchId: string; photoIds: string[] };
};
+type NavigationProp = NativeStackNavigationProp;
+type BatchReviewRouteParams = { BatchReview: { batchId: string } };
+
export function BatchReviewScreen() {
const route = useRoute>();
- const navigation = useNavigation();
- const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore();
- const { generatePdf, generating } = usePdfGeneration();
+ const navigation = useNavigation();
+ const { getBatch, removePhotoFromBatch } = useBatchStore();
const batch = getBatch(route.params.batchId);
const [selectedIds, setSelectedIds] = useState>(new Set());
- const [editingTags, setEditingTags] = useState(false);
- const [tagInput, setTagInput] = useState('');
if (!batch) {
return (
@@ -37,44 +40,39 @@ export function BatchReviewScreen() {
});
};
- const selectedPhotos = batch.photos.filter((p) => selectedIds.has(p.id));
const selectedCount = selectedIds.size;
- const handleGeneratePdf = async () => {
- const photos = selectedCount > 0 ? selectedPhotos : batch.photos;
- if (photos.length === 0) return;
-
- const uri = await generatePdf(photos);
- if (uri) {
- Alert.alert('PDF généré', 'Voulez-vous partager le fichier ?', [
+ const handleDelete = () => {
+ if (selectedCount === 0) return;
+ const label = selectedCount === 1 ? 'cette photo' : `ces ${selectedCount} photos`;
+ Alert.alert(
+ 'Supprimer',
+ `Retirer ${label} du lot ?`,
+ [
{ text: 'Annuler', style: 'cancel' },
{
- text: 'Partager',
+ text: 'Supprimer',
+ style: 'destructive',
onPress: () => {
- Share.share({ url: uri, title: batch.name });
+ selectedIds.forEach((id) => removePhotoFromBatch(batch.id, id));
+ setSelectedIds(new Set());
},
},
- ]);
- } else {
- Alert.alert('Erreur', 'Impossible de générer le PDF');
- }
+ ]
+ );
};
- const handleAddTag = () => {
- const tag = tagInput.trim().toLowerCase();
- if (!tag || batch.tags.includes(tag)) return;
- addTagToBatch(batch.id, tag);
- setTagInput('');
+ const handleGroup = () => {
+ if (selectedCount === 0) return;
+ const ids = Array.from(selectedIds);
+ navigation.navigate('PendingReview', { batchId: batch.id, photoIds: ids });
};
const formatDate = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString('fr-FR', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
+ day: '2-digit', month: '2-digit', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
});
};
@@ -111,36 +109,25 @@ export function BatchReviewScreen() {
/>
-
-
- {batch.tags.map((tag) => (
- removeTagFromBatch(batch.id, tag)}
- >
- {tag} ✕
-
- ))}
-
- Tags disponibles avec le backend (bientôt)
-
+
+ {selectedCount > 0 ? `${selectedCount} sélectionnée${selectedCount > 1 ? 's' : ''}` : 'Touchez une photo pour sélectionner'}
+
-
- {selectedCount > 0 ? `${selectedCount} sélectionnée${selectedCount > 1 ? 's' : ''}` : 'Toutes les photos'}
-
+
+ 🗑 Supprimer
+
- {generating ? (
-
- ) : (
- 📄 Générer PDF
- )}
+ 📦 Regrouper
@@ -212,47 +199,29 @@ const styles = StyleSheet.create({
borderTopColor: '#e0e0e0',
gap: 12,
},
- tagSection: {
- gap: 8,
- },
- tagRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: 6,
- },
- tagChip: {
- backgroundColor: '#E3F2FD',
- paddingHorizontal: 10,
- paddingVertical: 4,
- borderRadius: 12,
- },
- tagText: {
- fontSize: 13,
- color: '#1976D2',
- },
- disabledNote: {
- fontSize: 12,
- color: '#999',
- fontStyle: 'italic',
- },
- actions: {
- gap: 8,
- alignItems: 'center',
- },
selectionInfo: {
fontSize: 14,
color: '#666',
+ textAlign: 'center',
},
- actionButton: {
- backgroundColor: '#1976D2',
- paddingHorizontal: 24,
+ actions: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ actionBtn: {
+ flex: 1,
paddingVertical: 12,
borderRadius: 8,
- width: '100%',
alignItems: 'center',
},
- actionButtonDisabled: {
- opacity: 0.6,
+ actionBtnDisabled: {
+ opacity: 0.4,
+ },
+ deleteBtn: {
+ backgroundColor: '#F44336',
+ },
+ groupBtn: {
+ backgroundColor: '#4CAF50',
},
actionText: {
color: '#fff',
diff --git a/mobile/app/pending-review.tsx b/mobile/app/pending-review.tsx
new file mode 100644
index 0000000..bf3a2ef
--- /dev/null
+++ b/mobile/app/pending-review.tsx
@@ -0,0 +1,339 @@
+import React, { useState, useCallback } from 'react';
+import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, TextInput, Alert, ActivityIndicator } from 'react-native';
+import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
+import { useBatchStore } from '../hooks/useBatchStore';
+import { usePdfGeneration } from '../hooks/usePdfGeneration';
+import { useUpload } from '../hooks/useUpload';
+import { CapturedPhoto } from '../types';
+
+type PendingReviewRouteParams = {
+ PendingReview: { batchId: string; photoIds: string[] };
+};
+
+export function PendingReviewScreen() {
+ const route = useRoute>();
+ const navigation = useNavigation();
+ const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore();
+ const { generatePdf, generating, progress } = usePdfGeneration();
+ const upload = useUpload();
+
+ const batch = getBatch(route.params.batchId);
+
+ const initialPhotos = (batch?.photos ?? []).filter((p) =>
+ route.params.photoIds.includes(p.id)
+ );
+
+ const [photos, setPhotos] = useState(initialPhotos);
+ const [batchTags, setBatchTags] = useState(batch?.tags ?? []);
+ const [tagInput, setTagInput] = useState('');
+
+ const moveUp = useCallback((index: number) => {
+ if (index === 0) return;
+ setPhotos((prev) => {
+ const next = [...prev];
+ [next[index - 1], next[index]] = [next[index], next[index - 1]];
+ return next;
+ });
+ }, []);
+
+ const moveDown = useCallback((index: number) => {
+ if (index >= photos.length - 1) return;
+ setPhotos((prev) => {
+ const next = [...prev];
+ [next[index], next[index + 1]] = [next[index + 1], next[index]];
+ return next;
+ });
+ }, [photos.length]);
+
+ const handleAddTag = () => {
+ const tag = tagInput.trim().toLowerCase();
+ if (!tag || batchTags.includes(tag)) return;
+ setBatchTags((prev) => [...prev, tag]);
+ addTagToBatch(route.params.batchId, tag);
+ setTagInput('');
+ };
+
+ const handleRemoveTag = (tag: string) => {
+ setBatchTags((prev) => prev.filter((t) => t !== tag));
+ removeTagFromBatch(route.params.batchId, tag);
+ };
+
+ const handleFinalize = async () => {
+ if (photos.length === 0) return;
+
+ const pdfUri = await generatePdf(photos.map((p) => ({ uri: p.uri })));
+ if (!pdfUri) {
+ Alert.alert('Erreur', 'Échec de la génération du PDF');
+ return;
+ }
+
+ Alert.alert(
+ 'PDF généré',
+ 'Voulez-vous uploader le fichier ?',
+ [
+ { text: 'Annuler', style: 'cancel', onPress: () => navigation.goBack() },
+ {
+ text: 'Uploader',
+ onPress: async () => {
+ try {
+ await upload.mutateAsync([
+ {
+ uri: 'file://' + pdfUri,
+ type: 'application/pdf',
+ name: `${batch?.name ?? 'document'}.pdf`,
+ },
+ ]);
+ Alert.alert('Succès', 'PDF uploadé avec succès', [
+ { text: 'OK', onPress: () => navigation.navigate('Home' as never) },
+ ]);
+ } catch {
+ Alert.alert('Erreur', "Échec de l'upload du PDF");
+ }
+ },
+ },
+ ]
+ );
+ };
+
+ if (!batch) {
+ return (
+
+ Lot introuvable
+
+ );
+ }
+
+ return (
+
+
+ Réorganiser les photos
+ {photos.length} photo{photos.length > 1 ? 's' : ''}
+
+
+ item.id}
+ contentContainerStyle={styles.list}
+ renderItem={({ item, index }) => (
+
+
+ #{index + 1}
+
+ moveUp(index)}
+ disabled={index === 0}
+ >
+ ▲
+
+ = photos.length - 1 && styles.arrowDisabled]}
+ onPress={() => moveDown(index)}
+ disabled={index >= photos.length - 1}
+ >
+ ▼
+
+
+
+ )}
+ />
+
+
+ Tags
+
+ {batchTags.map((tag) => (
+ handleRemoveTag(tag)}>
+ {tag} ✕
+
+ ))}
+
+
+
+
+ +
+
+
+
+
+
+ {generating && (
+
+
+ Génération du PDF... {progress}%
+
+ )}
+
+ {generating ? (
+
+ ) : (
+ 📄 Finaliser et uploader le PDF
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ },
+ center: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ errorText: {
+ fontSize: 16,
+ color: '#666',
+ },
+ header: {
+ padding: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: '#e0e0e0',
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 4,
+ },
+ subtitle: {
+ fontSize: 14,
+ color: '#666',
+ },
+ list: {
+ padding: 16,
+ },
+ photoRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 12,
+ backgroundColor: '#f9f9f9',
+ borderRadius: 8,
+ padding: 8,
+ },
+ thumb: {
+ width: 60,
+ height: 60,
+ borderRadius: 6,
+ backgroundColor: '#e0e0e0',
+ },
+ photoIndex: {
+ marginLeft: 12,
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ flex: 1,
+ },
+ arrows: {
+ gap: 4,
+ },
+ arrowBtn: {
+ width: 36,
+ height: 28,
+ borderRadius: 4,
+ backgroundColor: '#e0e0e0',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ arrowDisabled: {
+ opacity: 0.3,
+ },
+ arrowText: {
+ fontSize: 14,
+ color: '#333',
+ },
+ tagSection: {
+ padding: 16,
+ borderTopWidth: 1,
+ borderTopColor: '#e0e0e0',
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ },
+ tagRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 6,
+ marginBottom: 8,
+ },
+ tagChip: {
+ backgroundColor: '#E3F2FD',
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 12,
+ },
+ tagText: {
+ fontSize: 13,
+ color: '#1976D2',
+ },
+ tagInputRow: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ tagInput: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ borderRadius: 8,
+ padding: 10,
+ fontSize: 14,
+ },
+ tagAddBtn: {
+ width: 40,
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: '#1976D2',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ tagAddText: {
+ color: '#fff',
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ footer: {
+ padding: 16,
+ borderTopWidth: 1,
+ borderTopColor: '#e0e0e0',
+ gap: 8,
+ },
+ progressRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ progressText: {
+ fontSize: 14,
+ color: '#666',
+ },
+ finalizeBtn: {
+ backgroundColor: '#1976D2',
+ paddingHorizontal: 24,
+ paddingVertical: 14,
+ borderRadius: 8,
+ alignItems: 'center',
+ },
+ finalizeBtnDisabled: {
+ opacity: 0.6,
+ },
+ finalizeText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/mobile/hooks/useBatchStore.ts b/mobile/hooks/useBatchStore.ts
index 90218f8..878df8a 100644
--- a/mobile/hooks/useBatchStore.ts
+++ b/mobile/hooks/useBatchStore.ts
@@ -48,6 +48,17 @@ export function useBatchStore() {
setBatchIds((batchIds ?? []).filter((id) => id !== batchId));
}, [batchIds, setBatchIds]);
+ const removePhotoFromBatch = useCallback((batchId: string, photoId: string) => {
+ const batch = getBatch(batchId);
+ if (!batch) return;
+ batch.photos = batch.photos.filter((p) => p.id !== photoId);
+ if (batch.photos.length === 0) {
+ deleteBatch(batchId);
+ } else {
+ storage.set(`batch_${batchId}`, JSON.stringify(batch));
+ }
+ }, [getBatch, deleteBatch]);
+
return {
saveBatch,
getBatch,
@@ -55,5 +66,6 @@ export function useBatchStore() {
addTagToBatch,
removeTagFromBatch,
deleteBatch,
+ removePhotoFromBatch,
};
}
diff --git a/mobile/hooks/usePdfGeneration.ts b/mobile/hooks/usePdfGeneration.ts
index ef90d33..25694be 100644
--- a/mobile/hooks/usePdfGeneration.ts
+++ b/mobile/hooks/usePdfGeneration.ts
@@ -1,22 +1,21 @@
import { useState, useCallback } from 'react';
import * as Print from 'expo-print';
-import * as FileSystem from 'expo-file-system';
-import { CapturedPhoto } from '../types';
export function usePdfGeneration() {
const [generating, setGenerating] = useState(false);
+ const [progress, setProgress] = useState(0);
- const generatePdf = useCallback(async (photos: CapturedPhoto[]): Promise => {
- if (photos.length === 0) return null;
+ const generatePdf = useCallback(async (items: { uri: string }[], progressCb?: (pct: number) => void): Promise => {
+ if (items.length === 0) return null;
setGenerating(true);
+ setProgress(0);
+ progressCb?.(0);
try {
- const imagesHtml = photos.map((p) => {
- const ext = p.filePath.split('.').pop()?.toLowerCase() || 'jpeg';
- const mime = ext === 'png' ? 'image/png' : 'image/jpeg';
+ const imagesHtml = items.map((item) => {
return `
-

+
`;
}).join('');
@@ -37,6 +36,8 @@ export function usePdfGeneration() {