Add swipe-to-delete functionality for requests and improve QR Code scanning

This commit is contained in:
2026-02-17 16:12:15 +01:00
parent 68e25fac71
commit ed25c5299d
7 changed files with 249 additions and 73 deletions

View File

@@ -1,3 +1,4 @@
EXPO_PUBLIC_API_URL=[YOUR_API_URL] EXPO_PUBLIC_API_URL=[YOUR_API_URL]
EXPO_PUBLIC_HA_API_URL=[YOUR_HOME_ASSISTANT_API_URL] EXPO_PUBLIC_HA_API_URL=[YOUR_HOME_ASSISTANT_API_URL]
EXPO_PUBLIC_HA_TOKEN=[YOUR_HOME_ASSISTANT_TOKEN] EXPO_PUBLIC_HA_TOKEN=[YOUR_HOME_ASSISTANT_TOKEN]
EXPO_PUBLIC_ENABLE_NFC=[true|false]

View File

@@ -21,8 +21,7 @@ export default function AttendanceScreen() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const checkNfcAvailability = async () => { const checkNfcAvailability = async () => {
// TODO: add env variable to disable NFC checks in development or if not needed if (process.env.EXPO_PUBLIC_ENABLE_NFC!=='true') return;
// if (!ENABLE_NFC) return;
try { try {
const isSupported = await NfcManager.isSupported(); const isSupported = await NfcManager.isSupported();
if (isSupported) setScannerType('nfc'); if (isSupported) setScannerType('nfc');
@@ -252,4 +251,4 @@ export default function AttendanceScreen() {
)} )}
</View> </View>
); );
} }

View File

@@ -5,10 +5,11 @@ import RequestPermitModal from '@/components/RequestPermitModal';
import { TimeOffRequest, TimeOffRequestType } from '@/types/types'; import { TimeOffRequest, TimeOffRequestType } from '@/types/types';
import api from '@/utils/api'; import api from '@/utils/api';
import { formatDate, formatTime } from '@/utils/dateTime'; import { formatDate, formatTime } from '@/utils/dateTime';
import { Calendar as CalendarIcon, CalendarX, Clock, Plus, Thermometer } from 'lucide-react-native'; import { Calendar as CalendarIcon, CalendarX, Clock, Plus, Thermometer, Trash2 } from 'lucide-react-native';
import React, { JSX, useEffect, useMemo, useState } from 'react'; import React, { JSX, useEffect, useMemo, useState } from 'react';
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native'; import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
// Icon Mapping // Icon Mapping
const typeIcons: Record<string, (color: string) => JSX.Element> = { const typeIcons: Record<string, (color: string) => JSX.Element> = {
@@ -78,6 +79,73 @@ export default function PermitsScreen() {
fetchPermits(); fetchPermits();
}; };
// Funzione per eliminare una richiesta
const deletePermitRequest = async (id: number, itemRef?: React.ElementRef<typeof Swipeable> | null) => {
try {
itemRef?.close();
await api.post(`/time-off-request/delete-request`, {id: id});
// Optimistic update
setPermits(prevPermits => prevPermits.filter(p => p.id !== id));
alert.showAlert('success', 'Richiesta eliminata', 'La richiesta è stata eliminata con successo.');
// Refresh
fetchPermits();
} catch (error: any) {
console.error('Errore eliminazione richiesta:', error);
const errorMessage = error?.response?.data?.message || 'Impossibile eliminare la richiesta.';
alert.showAlert('error', 'Errore', errorMessage);
fetchPermits(); // Ripristina stato corretto
}
};
// Dialogo di conferma
const confirmDelete = (item: TimeOffRequest, itemRef?: React.ElementRef<typeof Swipeable> | null) => {
const requestType = item.timeOffRequestType.name;
const dateRange = item.end_date
? `${formatDate(item.start_date?.toLocaleString())} - ${formatDate(item.end_date.toLocaleString())}`
: formatDate(item.start_date?.toLocaleString());
alert.showConfirm(
'Conferma eliminazione',
`Sei sicuro di voler eliminare questa richiesta?\n\n${requestType}\n${dateRange}`,
[
{
text: 'Annulla',
style: 'cancel',
onPress: () => itemRef?.close()
},
{
text: 'Elimina',
style: 'destructive',
onPress: () => deletePermitRequest(item.id, itemRef)
}
]
);
};
// Renderizza pulsante DELETE al swipe
const renderRightActions = (
progress: any,
dragX: any,
item: TimeOffRequest,
swipeableRef: React.RefObject<React.ElementRef<typeof Swipeable> | null>
) => {
return (
<TouchableOpacity
onPress={() => confirmDelete(item, swipeableRef.current)}
className="bg-red-500 justify-center items-center px-6 rounded-3xl ml-3"
activeOpacity={0.7}
style={{margin: 2}}
>
<View className="items-center gap-1">
<Trash2 size={24} color="white" strokeWidth={2.5} pointerEvents="none" />
<Text className="text-white font-bold text-sm">Elimina</Text>
</View>
</TouchableOpacity>
);
};
if (isLoading && !refreshing) { if (isLoading && !refreshing) {
return <LoadingScreen />; return <LoadingScreen />;
} }
@@ -119,32 +187,57 @@ export default function PermitsScreen() {
) : ( ) : (
<View className="gap-4"> <View className="gap-4">
<Text className="text-xl font-bold text-gray-800 px-1">Le tue richieste</Text> <Text className="text-xl font-bold text-gray-800 px-1">Le tue richieste</Text>
{filteredPermits.map((item) => ( {filteredPermits.map((item) => {
<View key={item.id} className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-row justify-between items-center"> const swipeableRef = React.createRef<React.ElementRef<typeof Swipeable>>();
<View className="flex-row items-center gap-4"> const canDelete = item.status === null; // Solo "In Attesa"
<View className={`p-4 rounded-2xl`} style={{ backgroundColor: item.timeOffRequestType.color ? `${item.timeOffRequestType.color}25` : '#E5E7EB' }}> const cardContent = (
{typeIcons[item.timeOffRequestType.name]?.(item.timeOffRequestType.color)} <View className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-row justify-between items-center">
</View> <View className="flex-row items-center gap-4">
<View> <View className={`p-4 rounded-2xl`} style={{ backgroundColor: item.timeOffRequestType.color ? `${item.timeOffRequestType.color}25` : '#E5E7EB' }}>
<Text className="font-bold text-gray-800 text-lg">{item.timeOffRequestType.name}</Text> {typeIcons[item.timeOffRequestType.name]?.(item.timeOffRequestType.color)}
<Text className="text-base text-gray-500 mt-0.5"> </View>
{formatDate(item.start_date?.toLocaleString())} {item.end_date ? `- ${formatDate(item.end_date.toLocaleString())}` : ''} <View>
</Text> <Text className="font-bold text-gray-800 text-lg">{item.timeOffRequestType.name}</Text>
{item.timeOffRequestType.name === 'Permesso' && ( <Text className="text-base text-gray-500 mt-0.5">
<Text className="text-sm text-orange-600 font-bold mt-1"> {formatDate(item.start_date?.toLocaleString())} {item.end_date ? `- ${formatDate(item.end_date.toLocaleString())}` : ''}
{formatTime(item.start_time)} - {formatTime(item.end_time)}
</Text> </Text>
)} {item.timeOffRequestType.name === 'Permesso' && (
<Text className="text-sm text-orange-600 font-bold mt-1">
{formatTime(item.start_time)} - {formatTime(item.end_time)}
</Text>
)}
</View>
</View>
<View className={`px-3 py-1.5 rounded-lg ${item.status===1 ? 'bg-green-100' : item.status===0 ? 'bg-red-100' : 'bg-yellow-100'}`}>
<Text className={`text-xs font-bold uppercase tracking-wide ${item.status===1 ? 'text-green-700' : item.status===0 ? 'text-red-700' : 'text-yellow-700'}`}>
{item.status===1 ? 'Approvata' : item.status===0 ? 'Rifiutata' : 'In Attesa'}
</Text>
</View> </View>
</View> </View>
{/* TODO: Add functionality to edit/remove the request */} );
<View className={`px-3 py-1.5 rounded-lg ${item.status ? 'bg-green-100' : 'bg-yellow-100'}`}>
<Text className={`text-xs font-bold uppercase tracking-wide ${item.status ? 'text-green-700' : 'text-yellow-700'}`}> // Wrappa solo richieste "In Attesa" con Swipeable
{item.status ? 'Approvato' : 'In Attesa'} if (canDelete) {
</Text> return (
</View> <Swipeable
</View> key={item.id}
))} ref={swipeableRef}
renderRightActions={(progress, dragX) =>
renderRightActions(progress, dragX, item, swipeableRef)
}
rightThreshold={40}
friction={2}
overshootFriction={8}
containerStyle={{padding: 2}}
>
{cardContent}
</Swipeable>
);
}
// Richieste approvate senza swipe
return <View key={item.id}>{cardContent}</View>;
})}
</View> </View>
)} )}
</View> </View>
@@ -159,4 +252,4 @@ export default function PermitsScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
} }

View File

@@ -5,22 +5,25 @@ import { AlertProvider } from '@/components/AlertComponent';
import { NetworkProvider } from '@/utils/networkProvider'; import { NetworkProvider } from '@/utils/networkProvider';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import {GestureHandlerRootView} from "react-native-gesture-handler";
export default function AppLayout() { export default function AppLayout() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<KeyboardProvider> <GestureHandlerRootView>
<NetworkProvider> <KeyboardProvider>
<AuthProvider> <NetworkProvider>
<AlertProvider> <AuthProvider>
<Stack screenOptions={{ headerShown: false, animation: 'flip' }}> <AlertProvider>
<Stack.Screen name="(protected)" /> <Stack screenOptions={{ headerShown: false, animation: 'flip' }}>
<Stack.Screen name="login" /> <Stack.Screen name="(protected)" />
</Stack> <Stack.Screen name="login" />
</AlertProvider> </Stack>
</AuthProvider> </AlertProvider>
</NetworkProvider> </AuthProvider>
</KeyboardProvider> </NetworkProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</SafeAreaProvider> </SafeAreaProvider>
); );
} }

View File

@@ -4,8 +4,21 @@ import { CheckCircle, XCircle, Info, AlertTriangle } from 'lucide-react-native';
type AlertType = 'success' | 'error' | 'info' | 'warning'; type AlertType = 'success' | 'error' | 'info' | 'warning';
type ConfirmButtonStyle = 'default' | 'destructive' | 'cancel';
interface ConfirmButton {
text: string;
onPress: () => void;
style?: ConfirmButtonStyle;
}
interface AlertContextData { interface AlertContextData {
showAlert: (type: AlertType, title: string, message: string) => void; showAlert: (type: AlertType, title: string, message: string) => void;
showConfirm: (
title: string,
message: string,
buttons: [ConfirmButton, ConfirmButton]
) => void;
hideAlert: () => void; hideAlert: () => void;
} }
@@ -44,11 +57,29 @@ export const AlertProvider = ({ children }: { children: ReactNode }) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [type, setType] = useState<AlertType>('info'); const [type, setType] = useState<AlertType>('info');
const [isConfirmMode, setIsConfirmMode] = useState(false);
const [confirmButtons, setConfirmButtons] = useState<[ConfirmButton, ConfirmButton]>([
{ text: 'Annulla', onPress: () => {}, style: 'cancel' },
{ text: 'Conferma', onPress: () => {}, style: 'default' }
]);
const showAlert = (newType: AlertType, newTitle: string, newMessage: string) => { const showAlert = (newType: AlertType, newTitle: string, newMessage: string) => {
setType(newType); setType(newType);
setTitle(newTitle); setTitle(newTitle);
setMessage(newMessage); setMessage(newMessage);
setIsConfirmMode(false);
setVisible(true);
};
const showConfirm = (
newTitle: string,
newMessage: string,
buttons: [ConfirmButton, ConfirmButton]
) => {
setTitle(newTitle);
setMessage(newMessage);
setConfirmButtons(buttons);
setIsConfirmMode(true);
setVisible(true); setVisible(true);
}; };
@@ -60,7 +91,7 @@ export const AlertProvider = ({ children }: { children: ReactNode }) => {
// TODO: Need to refactor component styles // TODO: Need to refactor component styles
return ( return (
<AlertContext.Provider value={{ showAlert, hideAlert }}> <AlertContext.Provider value={{ showAlert, showConfirm, hideAlert }}>
{children} {children}
<Modal <Modal
@@ -78,31 +109,67 @@ export const AlertProvider = ({ children }: { children: ReactNode }) => {
{/* Alert Container */} {/* Alert Container */}
<TouchableWithoutFeedback> <TouchableWithoutFeedback>
<View className="bg-white w-full max-w-sm rounded-3xl p-6 items-center shadow-2xl"> <View className="bg-white w-full max-w-sm rounded-3xl p-6 items-center shadow-2xl">
{/* Icon Circle */} {/* Icon Circle - Solo per alert normali */}
<View className={`${bgColor} p-4 rounded-full mb-4`}> {!isConfirmMode && (
<Icon size={32} className={color} strokeWidth={2.5} pointerEvents="none" /> <View className={`${bgColor} p-4 rounded-full mb-4`}>
</View> <Icon size={32} className={color} strokeWidth={2.5} pointerEvents="none" />
</View>
)}
{/* Texts */} {/* Texts */}
<Text className="text-xl font-bold text-gray-900 text-center mb-2"> <Text className="text-xl font-bold text-gray-900 text-center mb-2">
{title} {title}
</Text> </Text>
<Text className="text-lg text-gray-500 text-center leading-relaxed mb-8"> <Text className="text-lg text-gray-500 text-center leading-relaxed mb-8">
{message} {message}
</Text> </Text>
{/* OK Button */} {/* Buttons - Condizionale */}
<TouchableOpacity {isConfirmMode ? (
onPress={hideAlert} // Conferma: 2 bottoni orizzontali
className={`w-full py-3.5 rounded-3xl ${btnColor} active:opacity-90 shadow-sm`} <View className="flex-row gap-3 w-full">
> {confirmButtons.map((button, index) => {
<Text className="text-white text-center font-bold text-lg"> const isDestructive = button.style === 'destructive';
Ok, ho capito const isCancel = button.style === 'cancel';
</Text>
</TouchableOpacity> return (
<TouchableOpacity
key={index}
onPress={() => {
hideAlert();
button.onPress();
}}
className={`flex-1 py-3.5 rounded-3xl ${
isDestructive
? 'bg-red-600'
: isCancel
? 'bg-gray-200'
: 'bg-[#099499]'
} active:opacity-90 shadow-sm`}
>
<Text className={`text-center font-bold text-lg ${
isCancel ? 'text-gray-700' : 'text-white'
}`}>
{button.text}
</Text>
</TouchableOpacity>
);
})}
</View>
) : (
// Alert normale: singolo bottone OK
<TouchableOpacity
onPress={hideAlert}
className={`w-full py-3.5 rounded-3xl ${btnColor} active:opacity-90 shadow-sm`}
>
<Text className="text-white text-center font-bold text-lg">
Ok, ho capito
</Text>
</TouchableOpacity>
)}
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { View, Text, Modal, TouchableOpacity, Vibration, StyleSheet, Dimensions } from 'react-native'; import { View, Text, Modal, TouchableOpacity, Vibration, StyleSheet, Dimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
@@ -13,6 +13,7 @@ interface QrScanModalProps {
export default function QrScanModal({ visible, onClose, onScan }: QrScanModalProps) { export default function QrScanModal({ visible, onClose, onScan }: QrScanModalProps) {
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false); const [scanned, setScanned] = useState(false);
const scanInProgress = useRef(false);
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const squareSize = Math.min(width * 0.8, height * 0.8, 400); const squareSize = Math.min(width * 0.8, height * 0.8, 400);
@@ -20,6 +21,7 @@ export default function QrScanModal({ visible, onClose, onScan }: QrScanModalPro
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
setScanned(false); setScanned(false);
scanInProgress.current = false;
if (permission && !permission.granted && permission.canAskAgain) { if (permission && !permission.granted && permission.canAskAgain) {
requestPermission(); requestPermission();
} }
@@ -27,7 +29,9 @@ export default function QrScanModal({ visible, onClose, onScan }: QrScanModalPro
}, [visible, permission]); }, [visible, permission]);
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => { const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
if (scanned) return; if (scanInProgress.current) return;
scanInProgress.current = true;
setScanned(true); setScanned(true);
Vibration.vibrate(); Vibration.vibrate();
console.log(`Bar code with type ${type} and data ${data} has been scanned!`); console.log(`Bar code with type ${type} and data ${data} has been scanned!`);

View File

@@ -7,7 +7,7 @@ import { TimePickerModal } from './TimePickerModal';
import api from '@/utils/api'; import api from '@/utils/api';
import { formatPickerDate } from '@/utils/dateTime'; import { formatPickerDate } from '@/utils/dateTime';
import { AppDatePicker } from '@/components/AppDatePicker'; import { AppDatePicker } from '@/components/AppDatePicker';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
interface RequestPermitModalProps { interface RequestPermitModalProps {
visible: boolean; visible: boolean;
@@ -116,13 +116,19 @@ export default function RequestPermitModal({ visible, types, onClose, onSubmit }
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<KeyboardAvoidingView <KeyboardAwareScrollView
behavior={"padding"} bottomOffset={Platform.OS === 'ios' ? 50 : 80}
keyboardVerticalOffset={100} disableScrollOnKeyboardHide={false}
className='flex-1 mh-600' enabled={true}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: 70,
flexGrow: 1
}}
className="flex-1"
> >
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 40 }}> <View className="space-y-6">
<View className="space-y-6">
{/* Permit Type */} {/* Permit Type */}
<View className='mb-6'> <View className='mb-6'>
<Text className="text-lg font-bold text-gray-700 mb-3">Tipologia Assenza</Text> <Text className="text-lg font-bold text-gray-700 mb-3">Tipologia Assenza</Text>
@@ -181,6 +187,7 @@ export default function RequestPermitModal({ visible, types, onClose, onSubmit }
value={startTime} value={startTime}
onChangeText={setStartTime} onChangeText={setStartTime}
editable={false} editable={false}
pointerEvents="none"
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -194,6 +201,7 @@ export default function RequestPermitModal({ visible, types, onClose, onSubmit }
value={endTime} value={endTime}
onChangeText={setEndTime} onChangeText={setEndTime}
editable={false} editable={false}
pointerEvents="none"
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -222,10 +230,12 @@ export default function RequestPermitModal({ visible, types, onClose, onSubmit }
<TextInput <TextInput
placeholder="Scrivi qui il motivo..." placeholder="Scrivi qui il motivo..."
placeholderTextColor="#9CA3AF" placeholderTextColor="#9CA3AF"
className="w-full px-3 py-4 bg-white font-bold text-gray-800 rounded-lg border border-orange-200 text-gray-800" className="w-full px-3 py-3 bg-white font-bold text-gray-800 rounded-lg border border-orange-200"
textAlignVertical="top" textAlignVertical="top"
value={message} value={message}
onChangeText={setMessage} onChangeText={setMessage}
multiline
numberOfLines={3}
/> />
</View> </View>
</View> </View>
@@ -249,11 +259,10 @@ export default function RequestPermitModal({ visible, types, onClose, onSubmit }
<Text className="text-white text-center font-bold text-lg">Invia Richiesta</Text> <Text className="text-white text-center font-bold text-lg">Invia Richiesta</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</ScrollView> </KeyboardAwareScrollView>
</KeyboardAvoidingView>
</View> </View>
</View> </View>
</Modal> </Modal>
); );
}; };