Initial commit
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { Redirect, Tabs } from 'expo-router';
|
||||
import { Home, Clock, ShoppingBag, CalendarRange } from 'lucide-react-native';
|
||||
import { useContext } from 'react';
|
||||
import { AuthContext } from '@/utils/authContext';
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function ProtectedLayout() {
|
||||
const authState = useContext(AuthContext);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!authState.isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!authState.isAuthenticated) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f1f5f9',
|
||||
height: 70 + insets.bottom,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
tabBarActiveTintColor: '#1071C2',
|
||||
tabBarInactiveTintColor: '#94a3b8',
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 4
|
||||
}
|
||||
}}
|
||||
backBehavior='history'
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color, size }) => <Home pointerEvents="none" color={color} size={28} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="attendance/index"
|
||||
options={{
|
||||
title: 'Presenze',
|
||||
tabBarIcon: ({ color, size }) => <Clock pointerEvents="none" color={color} size={28} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="activity"
|
||||
options={{
|
||||
title: 'Attività',
|
||||
tabBarIcon: ({ color, size }) => <ShoppingBag pointerEvents="none" color={color} size={28} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="permits/index"
|
||||
options={{
|
||||
title: 'Ferie',
|
||||
tabBarIcon: ({ color, size }) => <CalendarRange pointerEvents="none" color={color} size={28} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
href: null,
|
||||
title: 'Profilo',
|
||||
tabBarStyle: { display: 'none' },
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="construction-site"
|
||||
options={{
|
||||
href: null,
|
||||
headerShown: false,
|
||||
tabBarStyle: { display: 'none' },
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="dashboard/index"
|
||||
options={{
|
||||
href: null,
|
||||
headerShown: false,
|
||||
tabBarStyle: { display: 'none' },
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import api from '@/utils/api';
|
||||
import { Image } from 'expo-image';
|
||||
import ImageView from "react-native-image-viewing";
|
||||
import { useLocalSearchParams, useRouter, useFocusEffect } from 'expo-router';
|
||||
import { ChevronLeft, ImageIcon, HardHat, MapPin, Calendar as CalendarIcon, TextAlignStart, CheckCircle2, Wrench, Pencil } from 'lucide-react-native';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Dimensions, RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
export default function ActivityDetailScreen() {
|
||||
const router = useRouter();
|
||||
const alert = useAlert();
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const [activityData, setActivityData] = useState<any>(null);
|
||||
const [placeName, setPlaceName] = useState<string>('');
|
||||
const [photos, setPhotos] = useState<{uri: string}[]>([]);
|
||||
|
||||
// Labor states
|
||||
const [operatorLabor, setOperatorLabor] = useState<any[]>([]);
|
||||
const [equipmentLabor, setEquipmentLabor] = useState<any[]>([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Image Viewer states
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const fetchDetails = useCallback(async (isRefreshing = false) => {
|
||||
try {
|
||||
if (!isRefreshing) setIsLoading(true);
|
||||
|
||||
const paramsData = JSON.stringify({ id: params.id });
|
||||
const [activityRes, subactivitiesRes] = await Promise.all([
|
||||
api.post('/activity/get-activity-data', { params: paramsData }),
|
||||
api.get('/subactivity/get-subactivities')
|
||||
]);
|
||||
|
||||
if (activityRes.data?.success) {
|
||||
const data = activityRes.data;
|
||||
setActivityData(data.activity);
|
||||
setPhotos(data.attachments || []);
|
||||
setOperatorLabor(data.operator_labor || []);
|
||||
setEquipmentLabor(data.materials || []);
|
||||
|
||||
if (subactivitiesRes.data?.success) {
|
||||
const subactivities = subactivitiesRes.data.subactivities;
|
||||
const match = subactivities.find((s: any) => s.id === data.activity.id_subactivity);
|
||||
if (match) {
|
||||
setPlaceName(match.label);
|
||||
} else {
|
||||
setPlaceName('Cantiere Non Specificato');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert.showAlert('error', 'Errore', 'Impossibile caricare i dettagli dell\'attività.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero del dettaglio attività:', error);
|
||||
alert.showAlert('error', 'Errore', 'Si è verificato un errore di rete.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [params.id]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (params.id) {
|
||||
fetchDetails(true);
|
||||
}
|
||||
}, [params.id, fetchDetails])
|
||||
);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchDetails(true);
|
||||
};
|
||||
|
||||
if (isLoading && !refreshing) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Calculate dimensions for the grid layout of photos
|
||||
const windowWidth = Dimensions.get('window').width;
|
||||
const padding = 40;
|
||||
const gap = 8;
|
||||
const itemSize = (windowWidth - padding - (gap * 2)) / 3;
|
||||
|
||||
const imageSource = photos.map(photo => ({ uri: photo.uri }));
|
||||
|
||||
const renderLaborSection = (title: string, icon: React.ReactNode, laborData: any[]) => {
|
||||
if (!laborData || laborData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-4">
|
||||
<View className="flex-row items-center border-b border-gray-50 pb-3 mb-3">
|
||||
<View className="bg-blue-50 p-2 rounded-xl mr-3">
|
||||
{icon}
|
||||
</View>
|
||||
<Text className="text-[#082963] font-bold text-lg">{title}</Text>
|
||||
</View>
|
||||
<View className="gap-3">
|
||||
{laborData.map((labor, idx) => (
|
||||
<View key={idx} className="flex-row justify-between items-center bg-gray-50 p-3 rounded-2xl">
|
||||
<Text className="text-gray-800 font-medium flex-1 mr-2" numberOfLines={3}>
|
||||
{labor.name}
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="bg-white px-3 py-1.5 rounded-xl border border-gray-200">
|
||||
<Text className="text-[#1071C2] font-bold">
|
||||
{labor.hours}h {labor.minutes}m
|
||||
</Text>
|
||||
</View>
|
||||
{labor.sync && (
|
||||
<CheckCircle2 size={16} color="#0F9D58" />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<StatusBar style="dark" />
|
||||
|
||||
{/* Header */}
|
||||
<View className="bg-white px-4 pb-6 shadow-sm border-b border-gray-100">
|
||||
<SafeAreaView edges={['top']} className='pt-5'>
|
||||
<View className='flex-row items-center justify-between px-2'>
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100 w-12 items-center justify-center">
|
||||
<ChevronLeft size={28} color="#4b5563" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text
|
||||
className="text-xl font-bold text-gray-800 leading-tight uppercase flex-1 text-center"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
Dettaglio Attività
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity onPress={() => router.push(`/activity/add?id=${params.id}`)} className="p-2 -mr-2 active:bg-gray-100 rounded-full w-12 items-center justify-center">
|
||||
<Pencil size={20} color="#082963" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />}
|
||||
>
|
||||
{/* General Info */}
|
||||
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-4">
|
||||
<View className="flex-row items-center mb-4">
|
||||
<View className="bg-blue-50 p-3 rounded-2xl mr-4">
|
||||
<MapPin size={24} color="#082963" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-gray-400 text-sm font-bold uppercase mb-1">Cantiere</Text>
|
||||
<Text className="text-[#082963] font-bold text-md leading-tight">
|
||||
{placeName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center mb-4 pt-4 border-t border-gray-50">
|
||||
<View className="bg-blue-50 p-3 rounded-2xl mr-4">
|
||||
<CalendarIcon size={24} color="#082963" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-gray-400 text-sm font-bold uppercase mb-1">Data</Text>
|
||||
<Text className="text-[#082963] font-bold text-md leading-tight">
|
||||
{activityData?.date ? new Date(activityData.date).toLocaleDateString('it-IT') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="pt-4 border-t border-gray-50">
|
||||
<View className="flex-row items-center gap-2 mb-2">
|
||||
<TextAlignStart size={20} color="#9ca3af" className="mr-2" />
|
||||
<Text className="text-gray-400 text-xs font-bold uppercase">Descrizione</Text>
|
||||
</View>
|
||||
<Text className="text-gray-700 text-md font-medium leading-relaxed">
|
||||
{activityData?.description || 'Nessuna descrizione.'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Labor and Materials Sections */}
|
||||
{renderLaborSection('Operai', <HardHat size={24} color="#082963" />, operatorLabor)}
|
||||
{renderLaborSection('Attrezzature', <Wrench size={24} color="#082963" />, equipmentLabor)}
|
||||
|
||||
{/* Photos */}
|
||||
<View className="mt-4 mb-2 flex-row items-center justify-between">
|
||||
<Text className="text-lg font-bold text-[#082963] ml-2">Allegati</Text>
|
||||
</View>
|
||||
|
||||
{photos.length === 0 ? (
|
||||
<View className="bg-white p-8 rounded-3xl border border-gray-100 items-center justify-center border-dashed mb-4">
|
||||
<ImageIcon size={48} color="#d1d5db" />
|
||||
<Text className="text-gray-400 font-medium text-center mt-4">Nessun allegato presente per questa attività.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row flex-wrap" style={{ gap: gap }}>
|
||||
{photos.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
setCurrentIndex(index);
|
||||
setIsVisible(true);
|
||||
}}
|
||||
style={{ width: itemSize }}
|
||||
className="mb-2"
|
||||
>
|
||||
<View className="bg-gray-100 rounded-2xl overflow-hidden shadow-sm border border-gray-200 aspect-square items-center justify-center">
|
||||
<Image
|
||||
source={{ uri: item.uri }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<ImageView
|
||||
images={imageSource}
|
||||
imageIndex={currentIndex}
|
||||
visible={isVisible}
|
||||
onRequestClose={() => setIsVisible(false)}
|
||||
swipeToCloseEnabled={true}
|
||||
doubleTapToZoomEnabled={true}
|
||||
presentationStyle="overFullScreen"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {Stack} from 'expo-router';
|
||||
|
||||
export default function JournalLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{headerShown: false}}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="add" />
|
||||
<Stack.Screen name="[id]" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, TextInput, Dimensions, ActivityIndicator, Modal } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ChevronLeft, ImageIcon, Calendar as CalendarIcon } from 'lucide-react-native';
|
||||
import { AppDatePicker } from '@/components/AppDatePicker';
|
||||
import { DateType } from 'react-native-ui-datepicker';
|
||||
import { formatDate, formatPickerDate } from '@/utils/dateTime';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
import api from '@/utils/api';
|
||||
import { uploadDocument } from '@/utils/documentUtils';
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import GenericDropdown from '@/components/GenericDropdown';
|
||||
import ActivityLaborCard from '@/components/ActivityLaborCard';
|
||||
import RemovablePhotoTile from '@/components/RemovablePhotoTile';
|
||||
import CameraAddTile from '@/components/CameraAddTile';
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
||||
|
||||
export default function ActivityFormScreen() {
|
||||
const router = useRouter();
|
||||
const alert = useAlert();
|
||||
const { id } = useLocalSearchParams();
|
||||
const isEditing = !!id;
|
||||
|
||||
// Data lists
|
||||
const [subactivities, setSubactivities] = useState<any[]>([]);
|
||||
|
||||
// Form states
|
||||
const [date, setDate] = useState<DateType>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [selectedSubactivityUuid, setSelectedSubactivityUuid] = useState<string | null>(null);
|
||||
const [selectedSubactivityId, setSelectedSubactivityId] = useState<number | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Labor states
|
||||
const [operatorLabor, setOperatorLabor] = useState<any[]>([]);
|
||||
const [equipmentLabor, setEquipmentLabor] = useState<any[]>([]);
|
||||
|
||||
// Photos
|
||||
const [photos, setPhotos] = useState<any[]>([]); // New photos
|
||||
const [existingPhotos, setExistingPhotos] = useState<any[]>([]); // Existing (readonly)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Load subactivities
|
||||
const subRes = await api.get('/subactivity/get-subactivities');
|
||||
if (subRes.data?.success) {
|
||||
setSubactivities(subRes.data.subactivities);
|
||||
}
|
||||
|
||||
// If editing, load activity data
|
||||
if (isEditing) {
|
||||
const actRes = await api.post('/activity/get-activity-data', {
|
||||
params: JSON.stringify({ id: id })
|
||||
});
|
||||
|
||||
if (actRes.data?.success) {
|
||||
const data = actRes.data;
|
||||
const activity = data.activity;
|
||||
|
||||
setDate(new Date(activity.date));
|
||||
setDescription(activity.description || '');
|
||||
|
||||
if (subRes.data?.success) {
|
||||
const match = subRes.data.subactivities.find((s: any) => s.id === activity.id_subactivity);
|
||||
if (match) {
|
||||
setSelectedSubactivityUuid(match.uuid);
|
||||
setSelectedSubactivityId(match.id);
|
||||
}
|
||||
}
|
||||
|
||||
setOperatorLabor(data.operator_labor || []);
|
||||
setEquipmentLabor(data.materials || []);
|
||||
setExistingPhotos(data.attachments || []);
|
||||
} else {
|
||||
alert.showAlert('error', 'Errore', 'Impossibile caricare i dati dell\'attività.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
alert.showAlert('error', 'Errore', 'Si è verificato un errore di connessione.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = (params: any) => {
|
||||
setDate(params.date);
|
||||
setShowDatePicker(false);
|
||||
};
|
||||
|
||||
const pickFromGallery = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (permissionResult.granted === false) {
|
||||
alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla galleria.');
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = 50 - photos.length;
|
||||
if (limit <= 0) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: limit,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
setPhotos(prev => [...prev, ...result.assets]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore gallery:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const takePhoto = async () => {
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (permissionResult.granted === false) {
|
||||
alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla fotocamera.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (photos.length >= 50) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
setPhotos(prev => [...prev, result.assets[0]]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore fotocamera:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const removePhoto = (index: number) => {
|
||||
setPhotos(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedSubactivityUuid) {
|
||||
alert.showAlert('error', 'Campi obbligatori', 'Selezionare un Cantiere.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Se in edit mode dobbiamo passare subactivity_id, se in add mode subactivity_uuid.
|
||||
let subactivity_id = selectedSubactivityId;
|
||||
if (!subactivity_id) {
|
||||
const match = subactivities.find(s => s.uuid === selectedSubactivityUuid);
|
||||
if (match) subactivity_id = match.id;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
description: description,
|
||||
date: date ? formatPickerDate(date) : new Date().toISOString().split('T')[0], // YYYY-MM-DD
|
||||
n_files: photos.length,
|
||||
operator_labor: operatorLabor,
|
||||
equipment_labor: equipmentLabor,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
payload.id = id;
|
||||
payload.subactivity_id = subactivity_id;
|
||||
} else {
|
||||
payload.subactivity_uuid = selectedSubactivityUuid;
|
||||
}
|
||||
|
||||
const params = {
|
||||
post: JSON.stringify(payload)
|
||||
};
|
||||
|
||||
const endpoint = isEditing ? '/activity/edit' : '/activity/add';
|
||||
const res = await api.post(endpoint, params);
|
||||
|
||||
if (res.data?.success) {
|
||||
const savedId = res.data.id;
|
||||
|
||||
// Upload new photos sequentially
|
||||
if (photos.length > 0) {
|
||||
for (const file of photos) {
|
||||
const fileName = file.fileName || file.uri.split('/').pop() || 'photo.jpg';
|
||||
const mimeType = file.mimeType || 'image/jpeg';
|
||||
|
||||
await uploadDocument({
|
||||
uri: file.uri,
|
||||
name: fileName,
|
||||
mimeType: mimeType
|
||||
}, {
|
||||
endpoint: '/activity/upload',
|
||||
fileKey: 'files',
|
||||
extraData: {
|
||||
model_classname: 'Activity',
|
||||
model_id: savedId.toString(),
|
||||
method: 'put',
|
||||
name: fileName,
|
||||
type: mimeType
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
alert.showAlert('success', 'Salvato', 'Attività salvata con successo.');
|
||||
if (isEditing) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/(protected)/activity');
|
||||
}
|
||||
} else {
|
||||
alert.showAlert('error', 'Errore', res.data?.message || 'Impossibile salvare l\'attività.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore salvataggio:', error);
|
||||
alert.showAlert('error', 'Errore di connessione', 'Verifica la connessione e riprova.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#1071C2" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const windowWidth = Dimensions.get('window').width;
|
||||
const itemsPerRow = 4;
|
||||
const padding = 20;
|
||||
const gap = 12;
|
||||
const tileWidth = (windowWidth - (padding * 2) - (gap * (itemsPerRow - 1))) / itemsPerRow;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<StatusBar style="dark" />
|
||||
|
||||
{/* Header */}
|
||||
<View className="bg-white px-4 pb-4 shadow-sm border-b border-gray-100">
|
||||
<SafeAreaView edges={['top']} className="pt-2">
|
||||
<View className="flex-row items-center justify-between px-2">
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100 w-12 items-center justify-center">
|
||||
<ChevronLeft size={28} color="#4b5563" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-xl font-bold text-gray-800 uppercase flex-1 text-center" numberOfLines={1}>
|
||||
{isEditing ? 'Modifica Attività' : 'Nuova Attività'}
|
||||
</Text>
|
||||
|
||||
<View className="w-12" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={{ padding: 20 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* General Info Card */}
|
||||
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-4">
|
||||
<View className="mb-4">
|
||||
<Text className="text-[#082963] font-bold text-sm mb-2 uppercase">Cantiere <Text className="text-red-500">*</Text></Text>
|
||||
<View>
|
||||
<GenericDropdown
|
||||
options={subactivities.map(s => ({ id: s.uuid, label: s.label }))}
|
||||
selectedId={selectedSubactivityUuid}
|
||||
onSelect={(id) => setSelectedSubactivityUuid(id as string)}
|
||||
placeholder="Seleziona il cantiere"
|
||||
searchPlaceholder="Cerca cantiere"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-[#082963] font-bold text-sm mb-2 uppercase">Data</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowDatePicker(true)}
|
||||
className="bg-gray-50 border border-gray-200 p-3 rounded-2xl flex-row items-center justify-between"
|
||||
>
|
||||
<Text className="text-gray-800 text-base">{date ? formatDate(formatPickerDate(date) || undefined) : ''}</Text>
|
||||
<CalendarIcon size={20} color="#9ca3af" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{showDatePicker && (
|
||||
<Modal visible={showDatePicker} transparent animationType="fade">
|
||||
<View className="flex-1 bg-black/50 justify-center px-4">
|
||||
<View className="bg-white rounded-3xl p-5 w-full max-w-sm self-center shadow-lg">
|
||||
<AppDatePicker
|
||||
date={date}
|
||||
mode="single"
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowDatePicker(false)}
|
||||
className="mt-4 p-3 rounded-xl items-center border border-gray-200 active:bg-gray-50"
|
||||
>
|
||||
<Text className="text-gray-600 font-bold">Chiudi</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-[#082963] font-bold text-sm mb-2 uppercase">Descrizione</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Inserisci una descrizione (opzionale)"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
className="bg-gray-50 border border-gray-200 p-4 rounded-2xl text-gray-800 text-base min-h-[100px]"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Labor Cards */}
|
||||
<View>
|
||||
<ActivityLaborCard
|
||||
title="Operai"
|
||||
fetchUrl="/activity/get-operators"
|
||||
laborList={operatorLabor}
|
||||
onAddLabor={(labor) => setOperatorLabor([...operatorLabor, labor])}
|
||||
onRemoveLabor={(idx) => setOperatorLabor(operatorLabor.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
|
||||
<ActivityLaborCard
|
||||
title="Attrezzature"
|
||||
fetchUrl="/activity/get-equipment"
|
||||
laborList={equipmentLabor}
|
||||
onAddLabor={(labor) => setEquipmentLabor([...equipmentLabor, labor])}
|
||||
onRemoveLabor={(idx) => setEquipmentLabor(equipmentLabor.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Photos */}
|
||||
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-6">
|
||||
<Text className="text-[#082963] font-bold text-lg mb-4">Allegati</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: gap }}>
|
||||
{/* Existing Photos (Read-only) */}
|
||||
{existingPhotos.map((photo, index) => (
|
||||
<View key={`ext-${index}`} style={{ width: tileWidth, height: tileWidth }} className="rounded-2xl overflow-hidden border border-gray-200">
|
||||
<Image source={{ uri: photo.uri }} style={{ width: '100%', height: '100%' }} contentFit="cover" />
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* New Photos */}
|
||||
{photos.map((photo, index) => (
|
||||
<RemovablePhotoTile key={`new-${index}`} uri={photo.uri} onRemove={() => removePhoto(index)} size={tileWidth} />
|
||||
))}
|
||||
|
||||
{/* Add Buttons */}
|
||||
{(photos.length + existingPhotos.length) < 50 && (
|
||||
<>
|
||||
<CameraAddTile onPress={takePhoto} size={tileWidth} />
|
||||
<TouchableOpacity
|
||||
onPress={pickFromGallery}
|
||||
style={{ width: tileWidth, height: tileWidth }}
|
||||
className="bg-blue-50 items-center justify-center rounded-2xl border border-blue-100 border-dashed"
|
||||
>
|
||||
<ImageIcon size={24} color="#1071C2" />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Submit */}
|
||||
<TouchableOpacity
|
||||
onPress={handleSave}
|
||||
disabled={isSubmitting}
|
||||
className={`bg-[#1071C2] p-4 rounded-full items-center justify-center mt-2 flex-row gap-2 ${isSubmitting ? 'opacity-70' : 'active:bg-[#0d5a9b]'}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-white font-bold text-lg uppercase tracking-wider">
|
||||
{isEditing ? 'Salva Modifiche' : 'Salva Attività'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</KeyboardAwareScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import api from '@/utils/api';
|
||||
import { Plus, Filter, Newspaper, ShoppingBag } from 'lucide-react-native';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import FilterModal from '@/components/FilterModal';
|
||||
import { Place, ActivityItem } from '@/types/types';
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
export default function JournalScreen() {
|
||||
const router = useRouter();
|
||||
const alert = useAlert();
|
||||
const [updates, setUpdates] = useState<ActivityItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Filters state
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
const [places, setPlaces] = useState<Place[]>([]);
|
||||
const [filterRange, setFilterRange] = useState<{ startDate: string | null; endDate: string | null }>({ startDate: null, endDate: null });
|
||||
const [filterPlace, setFilterPlace] = useState<any>(null);
|
||||
|
||||
const activeFiltersCount = (filterRange.startDate ? 1 : 0) + (filterPlace ? 1 : 0);
|
||||
|
||||
const fetchPlaces = async () => {
|
||||
try {
|
||||
const response = await api.get('/construction-site/get-construction-sites');
|
||||
if (response.data?.success) {
|
||||
setPlaces(response.data.constructionSites || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei cantieri:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUpdates = async (currentRange = filterRange, currentPlace = filterPlace) => {
|
||||
try {
|
||||
if (!refreshing) setIsLoading(true);
|
||||
|
||||
const rangeParam = currentRange.startDate ? currentRange : null;
|
||||
const params = { range: rangeParam, constructionSite: currentPlace };
|
||||
const response = await api.post('/activity/list', { params });
|
||||
|
||||
if (response.data?.success) {
|
||||
setUpdates(response.data.result || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero del giornale:', error);
|
||||
alert.showAlert('error', 'Errore', 'Impossibile recuperare il giornale di cantiere.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaces();
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchUpdates();
|
||||
}, [filterRange, filterPlace])
|
||||
);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchUpdates();
|
||||
};
|
||||
|
||||
// The fetch is triggered by useFocusEffect, which reacts to filter changes:
|
||||
// calling fetchUpdates here too would fire a second, identical request.
|
||||
const handleApplyFilters = (range: any, place: any) => {
|
||||
setFilterRange(range);
|
||||
setFilterPlace(place);
|
||||
setShowFilterModal(false);
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilterRange({ startDate: null, endDate: null });
|
||||
setFilterPlace(null);
|
||||
setShowFilterModal(false);
|
||||
};
|
||||
|
||||
if (isLoading && !refreshing) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
{/* Header */}
|
||||
<SafeAreaView edges={['top']} className='pt-5'>
|
||||
<View className="pb-6 px-6 z-10 flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="text-white text-md font-semibold uppercase tracking-wider mb-1">Lista delle attività nei cantieri</Text>
|
||||
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Attività Cantieri</Text>
|
||||
</View>
|
||||
<View className="bg-white/10 p-4 rounded-full">
|
||||
<Newspaper size={32} color="white" pointerEvents="none" />
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<View className="flex-1 bg-gray-50 rounded-t-[2.5rem] overflow-hidden">
|
||||
{/* List */}
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 180 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />}
|
||||
>
|
||||
{updates.length === 0 ? (
|
||||
<View className="bg-white p-8 rounded-3xl border border-gray-100 items-center justify-center border-dashed mt-4">
|
||||
<Text className="text-gray-400 font-medium text-center">Nessun invio registrato alla lista delle attività di cantiere</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="gap-4">
|
||||
{updates.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 active:bg-gray-50"
|
||||
onPress={() => router.push(`/activity/${item.id}`)}
|
||||
>
|
||||
<View className="flex-row items-center mb-3">
|
||||
<View className="bg-blue-50 p-3 rounded-full items-center justify-center mr-4">
|
||||
<ShoppingBag size={24} color="#082963" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-bold text-[#082963] text-lg uppercase leading-tight mb-1">
|
||||
{item.constructionSite}
|
||||
</Text>
|
||||
<Text className="text-[#082963] text-sm font-medium leading-tight mb-1">
|
||||
{item.subactivity ?? '-'}
|
||||
</Text>
|
||||
<Text className="text-gray-400 text-sm font-bold">
|
||||
{item.date}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.description ? (
|
||||
<View className="flex-row items-center pt-3 border-t border-gray-50">
|
||||
<Text numberOfLines={4} className="text-[#082963] text-[13px] font-medium leading-tight">
|
||||
{item.description}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* FAB Add Journal */}
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/activity/add')}
|
||||
className="absolute bottom-[6.5rem] right-6 w-16 h-16 bg-white border border-[#1071C2] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||
>
|
||||
<Plus size={32} color="#1071C2" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* FAB Filter */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowFilterModal(true)}
|
||||
className="absolute bottom-8 right-6 w-16 h-16 bg-[#1071C2] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||
>
|
||||
<Filter size={28} color="white" pointerEvents="none" />
|
||||
{activeFiltersCount > 0 && (
|
||||
<View className="absolute top-0 right-0 bg-red-500 w-6 h-6 rounded-full items-center justify-center border-2 border-white">
|
||||
<Text className="text-white text-xs font-bold">{activeFiltersCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<FilterModal
|
||||
visible={showFilterModal}
|
||||
places={places}
|
||||
currentRange={filterRange}
|
||||
currentPlace={filterPlace}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
onApply={handleApplyFilters}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import AttendanceCard from '@/components/AttendanceCard';
|
||||
import FilterModal from '@/components/FilterModal';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import QrScanModal from '@/components/QrScanModal';
|
||||
import api from '@/utils/api';
|
||||
import { formatTime } from '@/utils/dateTime';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { CheckCircle2, Filter, IdCardLanyard, QrCode } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Place, AttendanceRecord } from '@/types/types';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
|
||||
export default function AttendanceScreen() {
|
||||
const alert = useAlert();
|
||||
const { autoScan } = useLocalSearchParams();
|
||||
const [showScanner, setShowScanner] = useState(false);
|
||||
const [lastScan, setLastScan] = useState<{ type: string; time: string; site: string } | null>(null);
|
||||
const [attendances, setAttendances] = useState<AttendanceRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Filters state
|
||||
const [isFilterVisible, setIsFilterVisible] = useState(false);
|
||||
const [places, setPlaces] = useState<Place[]>([]);
|
||||
const [filterRange, setFilterRange] = useState<{ startDate: string | null; endDate: string | null }>({ startDate: null, endDate: null });
|
||||
const [filterPlace, setFilterPlace] = useState<any>(null);
|
||||
|
||||
const activeFiltersCount = (filterRange.startDate ? 1 : 0) + (filterPlace ? 1 : 0);
|
||||
|
||||
const fetchPlaces = async () => {
|
||||
try {
|
||||
const response = await api.get('/construction-site/get-construction-sites');
|
||||
if (response.data?.success) {
|
||||
setPlaces(response.data.constructionSites || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei cantieri:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAttendances = async (currentRange = filterRange, currentPlace = filterPlace) => {
|
||||
try {
|
||||
if (!refreshing) setIsLoading(true);
|
||||
|
||||
// Pass range directly as an object { startDate, endDate } or null
|
||||
const rangeParam = currentRange.startDate ? currentRange : null;
|
||||
const params = { range: rangeParam, constructionSite: currentPlace };
|
||||
const response = await api.post('/attendance/list', { params });
|
||||
|
||||
if (response.data?.success) {
|
||||
setAttendances(response.data.result || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero delle presenze:', error);
|
||||
alert.showAlert('error', 'Errore', 'Impossibile recuperare le presenze. Riprova più tardi.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaces();
|
||||
fetchAttendances();
|
||||
setLastScan(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScan === 'true') {
|
||||
setShowScanner(true);
|
||||
}
|
||||
}, [autoScan]);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchAttendances();
|
||||
setLastScan(null);
|
||||
};
|
||||
|
||||
const handleStartScan = () => {
|
||||
setShowScanner(true);
|
||||
};
|
||||
|
||||
const onScan = async (data: string) => {
|
||||
console.log('Scanned data:', data);
|
||||
try {
|
||||
const response = await api.post('/attendance/scan', { uuid: data });
|
||||
if (response.data?.success) {
|
||||
console.log('Scan data sent successfully:', response.data);
|
||||
fetchAttendances();
|
||||
setLastScan({
|
||||
type: response.data.type,
|
||||
time: formatTime(response.data.time),
|
||||
site: response.data.site
|
||||
});
|
||||
} else {
|
||||
alert.showAlert('error', 'Errore', response.data?.message || 'Impossibile registrare la presenza.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Errore nell\'invio dei dati di scansione:', error);
|
||||
const serverMsg = error?.response?.data?.message;
|
||||
alert.showAlert('error', 'Errore', serverMsg || 'Impossibile registrare la presenza. Riprova più tardi.');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !refreshing) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
{/* Header */}
|
||||
<SafeAreaView edges={['top']} className='pt-5'>
|
||||
<View className="pb-6 px-6 z-10 flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="text-white text-md font-semibold uppercase tracking-wider mb-1">Elenco delle tue presenze</Text>
|
||||
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Presenze</Text>
|
||||
</View>
|
||||
<View className="bg-white/10 p-4 rounded-full">
|
||||
<IdCardLanyard size={32} color="white" pointerEvents="none" />
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<View className="flex-1 bg-gray-50 rounded-t-[2.5rem] overflow-hidden">
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />
|
||||
}
|
||||
>
|
||||
<View className="flex-1 p-5 items-center pt-8">
|
||||
|
||||
{/* Feedback Card */}
|
||||
{lastScan ? (
|
||||
<View className="w-full bg-green-50 border border-green-200 rounded-3xl p-5 mb-8 flex-row items-center gap-4 shadow-sm">
|
||||
<View className="bg-green-500 rounded-full p-3 shadow-lg shadow-green-500/40 flex-shrink-0">
|
||||
<CheckCircle2 size={32} color="white" pointerEvents="none" />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="font-bold text-green-800 text-xl leading-tight"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{lastScan.type} Registrata
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className="text-base text-green-700 font-medium mt-0.5 leading-snug"
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{lastScan.site} alle {lastScan.time}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Scanner Section */}
|
||||
<View className="w-full mb-6">
|
||||
<View className="bg-white rounded-3xl p-8 shadow-sm border border-gray-100">
|
||||
<Text className="text-2xl font-bold text-gray-800 mb-6 text-center">Scansione QR Code</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleStartScan}
|
||||
className="bg-[#1071C2] rounded-2xl py-6 flex-row items-center justify-center active:bg-blue-700 shadow-lg shadow-blue-900/20 active:scale-[0.98]"
|
||||
>
|
||||
<QrCode color="white" size={32} pointerEvents="none" />
|
||||
<Text className="text-white text-xl font-bold ml-3 uppercase">Scansiona Codice</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-gray-500 text-center mt-6 text-base px-2 leading-relaxed">
|
||||
Posiziona il codice QR davanti alla fotocamera per registrare l'ingresso o l'uscita dal cantiere
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* History using AttendanceCard component */}
|
||||
<View className="w-full mt-4">
|
||||
<Text className="text-gray-500 font-bold text-base mb-4 uppercase tracking-wider px-2">Ultime Presenze</Text>
|
||||
{attendances.length === 0 ? (
|
||||
<View className="bg-white p-6 rounded-3xl border border-gray-100 items-center justify-center border-dashed">
|
||||
<Text className="text-gray-400 font-medium">Nessuna presenza registrata</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
{attendances.map((item, index) => (
|
||||
<AttendanceCard key={index} item={item} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* FAB Filter */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsFilterVisible(true)}
|
||||
className="absolute bottom-8 right-6 w-16 h-16 bg-[#1071C2] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||
>
|
||||
<Filter size={28} color="white" pointerEvents="none" />
|
||||
{activeFiltersCount > 0 && (
|
||||
<View className="absolute top-0 right-0 bg-red-500 w-6 h-6 rounded-full items-center justify-center border-2 border-white">
|
||||
<Text className="text-white text-xs font-bold">{activeFiltersCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<FilterModal
|
||||
visible={isFilterVisible}
|
||||
places={places}
|
||||
currentRange={filterRange}
|
||||
currentPlace={filterPlace}
|
||||
onClose={() => setIsFilterVisible(false)}
|
||||
onApply={(range, place) => {
|
||||
setFilterRange(range);
|
||||
setFilterPlace(place);
|
||||
setIsFilterVisible(false);
|
||||
fetchAttendances(range, place);
|
||||
}}
|
||||
onReset={() => {
|
||||
const emptyRange = { startDate: null, endDate: null };
|
||||
setFilterRange(emptyRange);
|
||||
setFilterPlace(null);
|
||||
setIsFilterVisible(false);
|
||||
fetchAttendances(emptyRange, null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Qr Scanner Modal */}
|
||||
<QrScanModal
|
||||
visible={showScanner}
|
||||
onClose={() => setShowScanner(false)}
|
||||
onScan={onScan}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import DocumentListCard from '@/components/DocumentListCard';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import { DocumentItem } from '@/types/types';
|
||||
import api from '@/utils/api';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ChevronLeft, AlertCircle } from 'lucide-react-native';
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import { uploadDocument } from '@/utils/documentUtils';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function ConstructionSiteDocumentsScreen() {
|
||||
const { id, name } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const alert = useAlert();
|
||||
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get(`/construction-site/get-attachments?id=${id}`);
|
||||
if (response.data?.success) {
|
||||
setDocuments(response.data.result || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei documenti:', error);
|
||||
alert.showAlert('error', 'Errore', 'Impossibile recuperare la lista dei documenti.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchDocuments();
|
||||
}
|
||||
}, [id, fetchDocuments]);
|
||||
|
||||
const onRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
fetchDocuments();
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: '*/*',
|
||||
copyToCacheDirectory: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets || result.assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = result.assets[0];
|
||||
setIsUploading(true);
|
||||
|
||||
await uploadDocument(file, {
|
||||
endpoint: '/construction-site/upload-attachment',
|
||||
fileKey: 'files',
|
||||
extraData: {
|
||||
model_classname: 'ConstructionSite',
|
||||
model_id: id as string,
|
||||
method: 'put',
|
||||
name: file.name,
|
||||
type: file.mimeType || 'application/octet-stream'
|
||||
}
|
||||
});
|
||||
|
||||
alert.showAlert('success', 'Successo', 'Documento caricato con successo.');
|
||||
onRefresh(); // reload list
|
||||
} catch (error: any) {
|
||||
console.error('Errore durante l\'upload:', error);
|
||||
alert.showAlert('error', 'Errore', error.message || 'Si è verificato un errore durante l\'upload del documento.');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !isRefreshing) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
<SafeAreaView edges={['top']} className="pt-5">
|
||||
{/* Header */}
|
||||
<View className="pb-6 px-6 z-10 flex-row items-center justify-between">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="p-2 -ml-2 rounded-full active:bg-white/20 w-12 items-center justify-center"
|
||||
>
|
||||
<ChevronLeft size={28} color="white" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="flex-1 px-2">
|
||||
<Text className="text-white text-xl font-bold text-center leading-tight uppercase" numberOfLines={1}>
|
||||
{name || 'Allegati Cantiere'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="w-10" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1 bg-slate-50 rounded-t-[2.5rem] overflow-hidden">
|
||||
{isUploading && (
|
||||
<View className="absolute z-10 top-0 left-0 right-0 bottom-0 bg-white/50 justify-center items-center">
|
||||
<ActivityIndicator size="large" color="#1071C2" />
|
||||
<Text className="text-primary-dark font-bold mt-2">Caricamento in corso...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
className="flex-1 px-6 pt-6"
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} colors={['#1071C2']} />
|
||||
}
|
||||
>
|
||||
<View className="flex-row justify-between items-center mb-6 px-1">
|
||||
<Text className="text-slate-800 text-xl font-bold">Documenti</Text>
|
||||
<TouchableOpacity onPress={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator size="small" color="#1071C2" />
|
||||
) : (
|
||||
<Text className="text-[#1071C2] text-lg font-semibold">Aggiungi</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{documents.length > 0 ? (
|
||||
<View className="gap-4">
|
||||
{documents.map((item, index) => (
|
||||
<DocumentListCard key={item.id || index} item={item} />
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="bg-white p-8 rounded-3xl border border-slate-200 items-center justify-center border-dashed mt-4">
|
||||
<View className="bg-slate-50 p-4 rounded-full mb-3">
|
||||
<AlertCircle size={32} color="#94a3b8" />
|
||||
</View>
|
||||
<Text className="text-slate-500 font-medium text-center">
|
||||
Nessun documento caricato per questo cantiere.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import api from '@/utils/api';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Building2, ChevronLeft, Paperclip, AlertCircle, FileText } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
const InfoRow = ({
|
||||
label,
|
||||
value,
|
||||
isLast = false,
|
||||
isCode = false
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | number | null;
|
||||
isLast?: boolean;
|
||||
isCode?: boolean
|
||||
}) => (
|
||||
<View className={`flex-row justify-between items-start ${!isLast ? 'border-b border-slate-100 pb-3' : ''}`}>
|
||||
<Text className="text-slate-500 font-medium">{label}</Text>
|
||||
<Text
|
||||
selectable={true}
|
||||
className={`text-primary-dark font-bold text-right flex-1 ${isCode ? 'tracking-widest' : ''}`}
|
||||
>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default function ConstructionSiteDetailsScreen() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [siteData, setSiteData] = useState<any>(null);
|
||||
|
||||
const fetchDetails = async () => {
|
||||
try {
|
||||
const response = await api.get(`/construction-site/get-info?id=${id}`);
|
||||
if (response.data?.success) {
|
||||
setSiteData(response.data.result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei dettagli del cantiere:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchDetails();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!siteData) {
|
||||
return (
|
||||
<View className="flex-1 bg-slate-50 justify-center items-center">
|
||||
<AlertCircle size={48} color="#94a3b8" />
|
||||
<Text className="text-slate-500 mt-4 font-medium">Impossibile caricare i dettagli.</Text>
|
||||
<TouchableOpacity onPress={() => router.back()} className="mt-4 px-6 py-2 bg-primary-dark rounded-full">
|
||||
<Text className="text-white font-bold">Indietro</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const { constructionSite, client, subactivities } = siteData;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
<SafeAreaView edges={['top']} className="pt-5">
|
||||
{/* Header */}
|
||||
<View className="pb-6 px-6 z-10 flex-row items-center justify-between">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/`)}
|
||||
className="p-2 -ml-2 rounded-full active:bg-white/20 w-12 items-center justify-center"
|
||||
>
|
||||
<ChevronLeft size={28} color="white" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="flex-1 px-2">
|
||||
<Text className="text-white text-xl font-bold text-center leading-tight" numberOfLines={1}>
|
||||
{constructionSite?.name || 'Dettaglio Cantiere'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/construction-site/${id}/documents?name=${encodeURIComponent(constructionSite?.name || '')}`)}
|
||||
className="p-2 -mr-2 rounded-full active:bg-white/20 w-12 items-center justify-center"
|
||||
>
|
||||
<Paperclip size={22} color="white" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1 bg-slate-50 rounded-t-[2.5rem] overflow-hidden">
|
||||
<ScrollView
|
||||
className="flex-1 px-6 pt-8"
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Info Card */}
|
||||
<View className="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 mb-6">
|
||||
<View className="flex-row items-center mb-6">
|
||||
<View className="bg-primary-50 p-3 rounded-xl mr-4">
|
||||
<Building2 size={24} color="#1071C2" />
|
||||
</View>
|
||||
<Text className="text-xl font-bold text-slate-800 flex-1">Informazioni</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-4">
|
||||
<InfoRow label="Cliente" value={client} />
|
||||
<InfoRow label="Indirizzo" value={constructionSite?.address} />
|
||||
<InfoRow label="CIG" value={constructionSite?.cig} isCode={true} />
|
||||
<InfoRow label="CUP" value={constructionSite?.cup} isCode={true} />
|
||||
<InfoRow
|
||||
label="% Ribasso"
|
||||
value={constructionSite?.reduction ? `${constructionSite.reduction} %` : undefined}
|
||||
isLast={true}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Subactivities */}
|
||||
{subactivities && subactivities.length > 0 && (
|
||||
<View className="mb-6">
|
||||
<Text className="text-slate-800 text-xl font-bold mb-4 px-2">Sottocommesse</Text>
|
||||
|
||||
<View className="gap-3">
|
||||
{subactivities.map((sub: any, index: number) => {
|
||||
const hasCodes = sub.cig || sub.cup;
|
||||
return (
|
||||
<View key={index} className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
|
||||
<View className={`flex-row items-center ${hasCodes ? 'mb-3 border-b border-slate-50 pb-3' : ''}`}>
|
||||
<View className="bg-slate-50 p-2 rounded-lg mr-3">
|
||||
<FileText size={20} color="#64748b" />
|
||||
</View>
|
||||
<Text selectable={true} className="text-slate-800 font-bold text-sm uppercase flex-1 leading-tight">
|
||||
{sub.code} - {sub.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{hasCodes && (
|
||||
<View className="gap-2 px-2">
|
||||
{sub.cig && (
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="text-slate-500 text-xs font-medium">CIG</Text>
|
||||
<Text selectable={true} className="text-primary-dark font-bold text-sm tracking-widest">{sub.cig}</Text>
|
||||
</View>
|
||||
)}
|
||||
{sub.cup && (
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="text-slate-500 text-xs font-medium">CUP</Text>
|
||||
<Text selectable={true} className="text-primary-dark font-bold text-sm tracking-widest">{sub.cup}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ConstructionSiteLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="[id]/index" />
|
||||
<Stack.Screen name="[id]/documents" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ChevronDown, ChevronUp, LayoutDashboard } from 'lucide-react-native';
|
||||
import api from '@/utils/api';
|
||||
import EchartWrapper from '@/components/EchartWrapper';
|
||||
import {
|
||||
CHART_ENDPOINTS,
|
||||
CHART_DATA_KEY,
|
||||
formatEuro,
|
||||
buildPieOption,
|
||||
buildAperteChiuseOption
|
||||
} from '@/utils/dashboardCharts';
|
||||
|
||||
const TOOLTIP_BASE = {
|
||||
renderMode: 'richText',
|
||||
confine: true,
|
||||
textStyle: { fontSize: 10 }
|
||||
};
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
export default function DashboardScreen() {
|
||||
|
||||
const [chartData, setChartData] = useState<any>({
|
||||
costiRicavi: null,
|
||||
fatturatoCliente: null,
|
||||
aperteChiuseCliente: null,
|
||||
aperteChiuseFornitore: null,
|
||||
partiteCliente: null,
|
||||
});
|
||||
|
||||
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});
|
||||
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({
|
||||
costiRicavi: false,
|
||||
aperteChiuseCliente: false,
|
||||
aperteChiuseFornitore: false,
|
||||
fatturatoCliente: false,
|
||||
apertoCliente: false,
|
||||
chiusoCliente: false,
|
||||
});
|
||||
|
||||
const chartRichiesti = useRef(new Set<string>());
|
||||
|
||||
const isLoading = (key: string) => !!loadingCharts[CHART_DATA_KEY[key] || key];
|
||||
|
||||
const fetchChart = (key: keyof typeof CHART_ENDPOINTS) => {
|
||||
const dataKey = CHART_DATA_KEY[key] || key;
|
||||
const endpoint = CHART_ENDPOINTS[key];
|
||||
if (!endpoint || chartRichiesti.current.has(dataKey)) { return Promise.resolve(); }
|
||||
|
||||
chartRichiesti.current.add(dataKey);
|
||||
setLoadingCharts(prev => ({ ...prev, [dataKey]: true }));
|
||||
return api.get(endpoint).then(res => {
|
||||
if (res.data && res.data.success) {
|
||||
setChartData((prev: any) => ({ ...prev, [dataKey]: res.data.result }));
|
||||
}
|
||||
}).catch(err => {
|
||||
chartRichiesti.current.delete(dataKey);
|
||||
console.log("Errore caricamento dashboard:", err);
|
||||
}).finally(() => {
|
||||
setLoadingCharts(prev => ({ ...prev, [dataKey]: false }));
|
||||
});
|
||||
};
|
||||
|
||||
// Sequential Prefetching
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const prefetch = async () => {
|
||||
const richieste: string[] = [];
|
||||
const visti = new Set<string>();
|
||||
Object.keys(CHART_ENDPOINTS).forEach(key => {
|
||||
const dataKey = CHART_DATA_KEY[key] || key;
|
||||
if (visti.has(dataKey)) { return; }
|
||||
visti.add(dataKey);
|
||||
richieste.push(key);
|
||||
});
|
||||
|
||||
for (const key of richieste) {
|
||||
if (cancelled) { return; }
|
||||
await fetchChart(key as keyof typeof CHART_ENDPOINTS);
|
||||
}
|
||||
};
|
||||
|
||||
prefetch();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const toggleCard = (key: string) => {
|
||||
const isCurrentlyExpanded = expandedCards[key];
|
||||
setExpandedCards(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const dataKey = CHART_DATA_KEY[key] || key;
|
||||
|
||||
if (!isCurrentlyExpanded && !chartData[dataKey] && !isLoading(key)) {
|
||||
fetchChart(key as keyof typeof CHART_ENDPOINTS);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Chart Options ---
|
||||
const costiRicaviOption = useMemo(() => !chartData.costiRicavi ? null : {
|
||||
tooltip: {
|
||||
...TOOLTIP_BASE,
|
||||
trigger: 'axis',
|
||||
valueFormatter: formatEuro
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
itemGap: 6,
|
||||
itemWidth: 14,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 12 },
|
||||
data: [
|
||||
`Costi Materiali(${chartData.costiRicavi.current_year})`,
|
||||
`Costi Manodopera(${chartData.costiRicavi.current_year})`,
|
||||
`Ricavi(${chartData.costiRicavi.current_year})`,
|
||||
`Utile(${chartData.costiRicavi.current_year})`,
|
||||
`Costi Materiali(${chartData.costiRicavi.prev_year})`,
|
||||
`Costi Manodopera(${chartData.costiRicavi.prev_year})`,
|
||||
`Ricavi(${chartData.costiRicavi.prev_year})`,
|
||||
`Utile(${chartData.costiRicavi.prev_year})`
|
||||
],
|
||||
selected: {
|
||||
[`Utile(${chartData.costiRicavi.prev_year})`]: false,
|
||||
[`Ricavi(${chartData.costiRicavi.prev_year})`]: false,
|
||||
[`Costi Materiali(${chartData.costiRicavi.prev_year})`]: false,
|
||||
[`Costi Manodopera(${chartData.costiRicavi.prev_year})`]: false
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
top: '8%',
|
||||
bottom: 95,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.costiRicavi.mesi
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{ name: `Costi Materiali(${chartData.costiRicavi.current_year})`, type: 'bar', data: chartData.costiRicavi.costi },
|
||||
{ name: `Costi Manodopera(${chartData.costiRicavi.current_year})`, type: 'bar', data: chartData.costiRicavi.costi_mdo },
|
||||
{ name: `Ricavi(${chartData.costiRicavi.current_year})`, type: 'bar', data: chartData.costiRicavi.ricavi },
|
||||
{ name: `Utile(${chartData.costiRicavi.current_year})`, type: 'bar', lineStyle: { width: 2.5, type: 'dashed' }, data: chartData.costiRicavi.utile },
|
||||
{ name: `Costi Materiali(${chartData.costiRicavi.prev_year})`, type: 'bar', itemStyle: { color: '#b3b3b3' }, data: chartData.costiRicavi.costi_prev },
|
||||
{ name: `Costi Manodopera(${chartData.costiRicavi.prev_year})`, type: 'bar', itemStyle: { color: '#b3b3b3' }, data: chartData.costiRicavi.costi_mdo_prev },
|
||||
{ name: `Ricavi(${chartData.costiRicavi.prev_year})`, type: 'bar', itemStyle: { color: '#b3b3b3' }, data: chartData.costiRicavi.ricavi_prev },
|
||||
{ name: `Utile(${chartData.costiRicavi.prev_year})`, type: 'bar', lineStyle: { width: 1.5, type: 'dashed', color: '#b3b3b3' }, data: chartData.costiRicavi.utile_prev }
|
||||
]
|
||||
}, [chartData.costiRicavi]);
|
||||
|
||||
|
||||
const aperteChiuseClienteOption = useMemo(() => chartData.aperteChiuseCliente ? buildAperteChiuseOption(chartData.aperteChiuseCliente) : null, [chartData.aperteChiuseCliente]);
|
||||
const aperteChiuseFornitoreOption = useMemo(() => chartData.aperteChiuseFornitore ? buildAperteChiuseOption(chartData.aperteChiuseFornitore) : null, [chartData.aperteChiuseFornitore]);
|
||||
const fatturatoClienteOption = useMemo(() => chartData.fatturatoCliente ? buildPieOption(chartData.fatturatoCliente) : null, [chartData.fatturatoCliente]);
|
||||
const apertoClienteOption = useMemo(() => chartData.partiteCliente ? buildPieOption(chartData.partiteCliente.aperto) : null, [chartData.partiteCliente]);
|
||||
const chiusoClienteOption = useMemo(() => chartData.partiteCliente ? buildPieOption(chartData.partiteCliente.chiuso) : null, [chartData.partiteCliente]);
|
||||
|
||||
const numItems = chartData.fatturatoCliente?.length || 0;
|
||||
const dynamicPieHeight = Math.max(260, 200 + (Math.ceil(numItems / 2) * 26));
|
||||
const pieHeightFor = (arr: any) => Math.max(260, 200 + (Math.ceil((arr?.length || 0) / 2) * 26));
|
||||
const dynamicApertoHeight = pieHeightFor(chartData.partiteCliente?.aperto);
|
||||
const dynamicChiusoHeight = pieHeightFor(chartData.partiteCliente?.chiuso);
|
||||
|
||||
// Render Card Helper
|
||||
const renderCard = (key: string, title: string, option: any, height: number) => {
|
||||
const isExpanded = expandedCards[key];
|
||||
const loading = isLoading(key);
|
||||
|
||||
return (
|
||||
<View className="bg-white rounded-2xl shadow-sm border border-gray-100 w-full mb-4 overflow-hidden" key={key}>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between p-4 active:bg-gray-50"
|
||||
onPress={() => toggleCard(key)}
|
||||
>
|
||||
<Text className="text-gray-800 font-bold text-lg">{title}</Text>
|
||||
{isExpanded ? <ChevronUp size={24} color="#082963" /> : <ChevronDown size={24} color="#082963" />}
|
||||
</TouchableOpacity>
|
||||
|
||||
{isExpanded && (
|
||||
<View className="p-4 pt-0 border-t border-gray-50">
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#1071C2" className="my-6" />
|
||||
) : option ? (
|
||||
<View style={{ width: screenWidth - 64, height: height, overflow: 'hidden' }} className="self-center">
|
||||
<EchartWrapper option={option} width={screenWidth - 64} height={height} />
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-gray-400 text-center my-4">Nessun dato disponibile</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
|
||||
<SafeAreaView edges={['top']} className="pt-5">
|
||||
<View className="pb-6 px-6 z-10 flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="text-white text-md font-semibold uppercase tracking-wider mb-1">Grafici e Statistiche</Text>
|
||||
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Dashboard</Text>
|
||||
</View>
|
||||
<View className="bg-white/10 p-4 rounded-full">
|
||||
<LayoutDashboard size={32} color="white" pointerEvents="none" />
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<View className="flex-1 bg-gray-50 rounded-t-[2.5rem] overflow-hidden">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 20 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderCard('costiRicavi', 'Costi / Ricavi', costiRicaviOption, 390)}
|
||||
{renderCard('aperteChiuseCliente', 'Aperto / Chiuso Clienti', aperteChiuseClienteOption, 330)}
|
||||
{renderCard('aperteChiuseFornitore', 'Aperto / Chiuso Fornitori', aperteChiuseFornitoreOption, 330)}
|
||||
{renderCard('fatturatoCliente', 'Fatturato per Cliente', fatturatoClienteOption, dynamicPieHeight)}
|
||||
{renderCard('apertoCliente', 'Aperto per Cliente', apertoClienteOption, dynamicApertoHeight)}
|
||||
{renderCard('chiusoCliente', 'Chiuso per Cliente', chiusoClienteOption, dynamicChiusoHeight)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import ConstructionSiteCard from '@/components/ConstructionSiteCard';
|
||||
import FilterModal from '@/components/FilterModal';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import api from '@/utils/api';
|
||||
import { AuthContext } from '@/utils/authContext';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Filter, QrCode, User, ShoppingBag, ChartNoAxesCombined } from 'lucide-react-native';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Client, ConstructionSite } from '@/types/types';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter();
|
||||
const { user } = useContext(AuthContext);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Construction Sites & Filters state
|
||||
const [constructionSites, setConstructionSites] = useState<ConstructionSite[]>([]);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [filterClient, setFilterClient] = useState<any>(null);
|
||||
const [isFilterVisible, setIsFilterVisible] = useState(false);
|
||||
|
||||
const activeFiltersCount = filterClient ? 1 : 0;
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const response = await api.get('/construction-site/get-clients');
|
||||
if (response.data?.success) {
|
||||
setClients(response.data.clients || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei committenti:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConstructionSites = async (clientId = filterClient) => {
|
||||
try {
|
||||
if (!refreshing) setIsLoading(true);
|
||||
|
||||
const params = { id_client: clientId };
|
||||
const response = await api.post('/construction-site/list', { params });
|
||||
|
||||
if (response.data?.success) {
|
||||
setConstructionSites(response.data.result || []);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei cantieri:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients();
|
||||
fetchConstructionSites();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchConstructionSites();
|
||||
};
|
||||
|
||||
if (isLoading && !refreshing) {
|
||||
return (
|
||||
<LoadingScreen />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
<SafeAreaView edges={['top']} className='pt-5'>
|
||||
{/* Custom Banner */}
|
||||
<View className="pb-6 px-6 shadow-sm z-10">
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-row items-center gap-4 flex-1 mr-4">
|
||||
<View className="flex-1">
|
||||
<Text className="text-neutral-50 text-sm font-semibold uppercase tracking-wider mb-2">
|
||||
Fonsi Costruzioni S.R.L.
|
||||
</Text>
|
||||
<Text className="text-white text-4xl font-bold leading-tight">
|
||||
Ciao <Text className="text-yellow-400">{user?.firstName}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row gap-4 flex-shrink-0 items-center">
|
||||
{/* Profile Avatar */}
|
||||
<TouchableOpacity className="p-3 bg-white/10 rounded-full active:bg-white/20" onPress={() => router.push('/profile')}>
|
||||
<User size={28} color="white" pointerEvents="none"/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<View className="flex-1 bg-slate-50 rounded-t-[2.5rem] overflow-hidden">
|
||||
<ScrollView
|
||||
className="flex-1 px-5 pt-6"
|
||||
contentContainerStyle={{ paddingBottom: 100, gap: 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />
|
||||
}
|
||||
>
|
||||
{/* Quick Actions */}
|
||||
<View>
|
||||
<Text className="text-slate-800 text-xl font-bold mb-4 px-1">
|
||||
Azioni Rapide
|
||||
</Text>
|
||||
|
||||
<View className="flex-row gap-5">
|
||||
{user?.role === 'administrator' ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/dashboard')}
|
||||
className="flex-1 bg-white p-6 rounded-3xl shadow-sm items-center justify-center gap-4 border border-slate-100 active:scale-[0.98]"
|
||||
>
|
||||
<View className="w-20 h-20 rounded-full bg-primary-50 items-center justify-center mb-1">
|
||||
<ChartNoAxesCombined size={40} color="#1071C2" pointerEvents="none"/>
|
||||
</View>
|
||||
<Text className="text-lg font-bold text-slate-700 text-center">Dashboard</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/attendance?autoScan=true')}
|
||||
className="flex-1 bg-white p-6 rounded-3xl shadow-sm items-center justify-center gap-4 border border-slate-100 active:scale-[0.98]"
|
||||
>
|
||||
<View className="w-20 h-20 rounded-full bg-primary-50 items-center justify-center mb-1">
|
||||
<QrCode size={40} color="#1071C2" pointerEvents="none"/>
|
||||
</View>
|
||||
<Text className="text-lg font-bold text-slate-700 text-center">Nuova Presenza</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/activity/add')}
|
||||
className="flex-1 bg-white p-6 rounded-3xl shadow-sm items-center justify-center gap-4 border border-slate-100 active:scale-[0.98]"
|
||||
>
|
||||
<View className="w-20 h-20 rounded-full bg-primary-50 items-center justify-center mb-1">
|
||||
<ShoppingBag size={40} color="#1071C2" pointerEvents="none"/>
|
||||
</View>
|
||||
<Text className="text-lg font-bold text-slate-700 text-center">Nuova{'\n'}Attività</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Cantieri */}
|
||||
<View>
|
||||
<View className="flex-row justify-between items-center px-1 mb-4">
|
||||
<Text className="text-slate-800 text-xl font-bold">Lista Cantieri</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
{constructionSites.map((item, index) => (
|
||||
<ConstructionSiteCard
|
||||
key={index}
|
||||
item={item}
|
||||
onPress={() => router.push(`/construction-site/${item.id}`)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isLoading && constructionSites.length === 0 && (
|
||||
<View className="bg-white p-6 rounded-3xl border border-slate-200 items-center justify-center border-dashed">
|
||||
<Text className="text-slate-400 font-medium text-center">Nessun cantiere trovato.</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoading && constructionSites.length === 0 && (
|
||||
<View className="bg-white p-5 rounded-3xl border border-slate-100 h-24 justify-center items-center">
|
||||
<Text className="text-slate-400">Caricamento...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* FAB Filter */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsFilterVisible(true)}
|
||||
className="absolute bottom-8 right-6 w-16 h-16 bg-[#1071C2] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||
>
|
||||
<Filter size={28} color="white" pointerEvents="none" />
|
||||
{activeFiltersCount > 0 && (
|
||||
<View className="absolute top-0 right-0 bg-red-500 w-6 h-6 rounded-full items-center justify-center border-2 border-white">
|
||||
<Text className="text-white text-xs font-bold">{activeFiltersCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Filter Modal */}
|
||||
<FilterModal
|
||||
visible={isFilterVisible}
|
||||
clients={clients}
|
||||
currentClient={filterClient}
|
||||
showDate={false}
|
||||
showPlace={false}
|
||||
showClient={true}
|
||||
onClose={() => setIsFilterVisible(false)}
|
||||
onApply={(_, __, client) => {
|
||||
setFilterClient(client);
|
||||
setIsFilterVisible(false);
|
||||
fetchConstructionSites(client);
|
||||
}}
|
||||
onReset={() => {
|
||||
setFilterClient(null);
|
||||
setIsFilterVisible(false);
|
||||
fetchConstructionSites(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import CalendarWidget from '@/components/CalendarWidget';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import RequestPermitModal from '@/components/RequestPermitModal';
|
||||
import { TimeOffRequest, TimeOffRequestType } from '@/types/types';
|
||||
import api from '@/utils/api';
|
||||
import { formatDate, formatTime } from '@/utils/dateTime';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Calendar as CalendarIcon, CalendarRange, CalendarX, Clock, CloudRainWind, Cross, Plus, Thermometer, Trash2, Users, FileText } from 'lucide-react-native';
|
||||
import React, { JSX, useEffect, useMemo, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
|
||||
|
||||
// Icon Mapping
|
||||
const typeIcons: Record<string, (color: string) => JSX.Element> = {
|
||||
Ferie: (color) => <CalendarIcon size={24} color={color} pointerEvents="none" />,
|
||||
Permesso: (color) => <Clock size={24} color={color} pointerEvents="none" />,
|
||||
Malattia: (color) => <Thermometer size={24} color={color} pointerEvents="none" />,
|
||||
Assenza: (color) => <CalendarX size={24} color={color} pointerEvents="none" />,
|
||||
Maltempo: (color) => <CloudRainWind size={24} color={color} pointerEvents="none" />,
|
||||
Infortunio: (color) => <Cross size={24} color={color} pointerEvents="none" />,
|
||||
CongedoFamiliare: (color) => <Users size={24} color={color} pointerEvents="none" />,
|
||||
};
|
||||
|
||||
export default function PermitsScreen() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const alert = useAlert();
|
||||
const [permits, setPermits] = useState<TimeOffRequest[]>([]);
|
||||
const [types, setTypes] = useState<TimeOffRequestType[]>([]);
|
||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchPermits = async () => {
|
||||
try {
|
||||
const typesRes = await api.get('/time-off-request/get-types');
|
||||
const fetchedTypes: TimeOffRequestType[] = (typesRes.data.types || []).map((t: any) => ({
|
||||
id: t.id,
|
||||
name: t.label,
|
||||
time_required: t.time_required,
|
||||
color: t.label === 'Infortunio' ? '#EAB308' : (t.color || '#8F9BB3')
|
||||
}));
|
||||
|
||||
const response = await api.post('/time-off-request/list', { params: { range: '' } });
|
||||
|
||||
const mappedPermits: TimeOffRequest[] = (response.data.result || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
start_date: r.start_date,
|
||||
end_date: r.end_date,
|
||||
start_time: r.start_time,
|
||||
end_time: r.end_time,
|
||||
message: r.message,
|
||||
status: r.status,
|
||||
timeOffRequestType: fetchedTypes.find(t => t.name === r.type) || fetchedTypes[0],
|
||||
}));
|
||||
|
||||
setPermits(mappedPermits);
|
||||
setTypes(fetchedTypes);
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei permessi:', error);
|
||||
alert.showAlert('error', 'Errore', 'Impossibile recuperare i permessi. Riprova più tardi.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPermits = useMemo(() => {
|
||||
if (!permits.length) return [];
|
||||
|
||||
// Calculate start and end of the current month
|
||||
const year = currentMonthDate.getFullYear();
|
||||
const month = currentMonthDate.getMonth();
|
||||
|
||||
const startOfMonth = new Date(year, month, 1);
|
||||
// Day 0 of the next month = last day of the current month
|
||||
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59);
|
||||
|
||||
return permits.filter(item => {
|
||||
const itemStart = new Date(item.start_date?.toString() ?? '');
|
||||
// If there's no end_date, assume it's a single day (so end = start)
|
||||
const itemEnd = item.end_date ? new Date(item.end_date?.toString() ?? '') : new Date(item.start_date?.toString() ?? '');
|
||||
|
||||
// The permit is visible if it starts before the end of the month
|
||||
// And ends after the start of the month.
|
||||
return itemStart <= endOfMonth && itemEnd >= startOfMonth;
|
||||
});
|
||||
}, [permits, currentMonthDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermits();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchPermits();
|
||||
};
|
||||
|
||||
// Funzione per eliminare una richiesta
|
||||
const deletePermitRequest = async (id: number, itemRef?: React.ElementRef<typeof Swipeable> | null) => {
|
||||
try {
|
||||
itemRef?.close();
|
||||
const res = await api.post('/time-off-request/delete', { id });
|
||||
if (res.data?.success) {
|
||||
// Optimistic update
|
||||
setPermits(prevPermits => prevPermits.filter(p => p.id !== id));
|
||||
alert.showAlert('success', 'Richiesta eliminata', 'La richiesta è stata eliminata con successo.');
|
||||
} else {
|
||||
alert.showAlert('error', 'Errore', res.data?.message || 'Impossibile eliminare la richiesta.');
|
||||
}
|
||||
// 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) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
<RequestPermitModal
|
||||
visible={showModal}
|
||||
types={types}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={(data) => { console.log('Richiesta:', data); fetchPermits(); }}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<SafeAreaView edges={['top']} className='pt-5'>
|
||||
<View className="pb-6 px-6 z-10 flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="text-white text-md font-semibold uppercase tracking-wider mb-1">Elenco delle tue richieste</Text>
|
||||
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Ferie e Permessi</Text>
|
||||
</View>
|
||||
<View className="bg-white/10 p-4 rounded-full">
|
||||
<CalendarRange size={32} color="white" pointerEvents="none" />
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<View className="flex-1 bg-gray-50 rounded-t-[2.5rem] overflow-hidden">
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 100, gap: 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />
|
||||
}
|
||||
>
|
||||
|
||||
{/* Calendar Widget */}
|
||||
<CalendarWidget initialDate={currentMonthDate} events={permits} types={types} onMonthChange={(date) => setCurrentMonthDate(date)} />
|
||||
|
||||
{/* Recent Requests List */}
|
||||
<View>
|
||||
{filteredPermits.length === 0 ? (
|
||||
<Text className="text-center text-gray-500 mt-8">Nessuna richiesta di permesso questo mese</Text>
|
||||
) : (
|
||||
<View className="gap-4">
|
||||
<Text className="text-xl font-bold text-gray-800 px-1">Le tue richieste</Text>
|
||||
{filteredPermits.map((item) => {
|
||||
const swipeableRef = React.createRef<React.ElementRef<typeof Swipeable>>();
|
||||
const canDelete = item.status === null; // Solo "In Attesa"
|
||||
const cardContent = (
|
||||
<View className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-row justify-between items-center">
|
||||
<View className="flex-row items-center gap-4">
|
||||
<View className={`p-4 rounded-2xl`} style={{ backgroundColor: item.timeOffRequestType.color ? `${item.timeOffRequestType.color}25` : '#E5E7EB' }}>
|
||||
{(typeIcons[item.timeOffRequestType.name] || ((color: string) => <FileText size={24} color={color} pointerEvents="none" />))(item.timeOffRequestType.color)}
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="font-bold text-gray-800 text-lg">{item.timeOffRequestType.name}</Text>
|
||||
<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>
|
||||
|
||||
{item.message ? (
|
||||
<Text className="text-sm text-gray-600 mt-0.5 leading-tight">{item.message}</Text>
|
||||
) : null}
|
||||
<Text className="text-base text-gray-500 mt-0.5">
|
||||
{formatDate(item.start_date?.toLocaleString())} {item.end_date ? `- ${formatDate(item.end_date.toLocaleString())}` : ''}
|
||||
</Text>
|
||||
{item.timeOffRequestType.name === 'Permesso' && (
|
||||
<Text className="text-sm text-orange-600 font-bold mt-0.5">
|
||||
{formatTime(item.start_time)} - {formatTime(item.end_time)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
);
|
||||
|
||||
// Wrappa solo richieste "In Attesa" con Swipeable
|
||||
if (canDelete) {
|
||||
return (
|
||||
<Swipeable
|
||||
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>
|
||||
</ScrollView>
|
||||
|
||||
{/* FAB */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowModal(true)}
|
||||
className="absolute bottom-8 right-6 w-16 h-16 bg-white border border-[#1071C2] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||
>
|
||||
<Plus size={32} color="#1071C2" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function ProfileLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{headerShown: false}}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="documents" options={{ animation: 'slide_from_right' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import api from '@/utils/api';
|
||||
import DocumentListCard from '@/components/DocumentListCard';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ChevronDown, ChevronLeft, X } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, RefreshControl, FlatList, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function DocumentsScreen() {
|
||||
const router = useRouter();
|
||||
const alert = useAlert();
|
||||
const [documents, setDocuments] = useState<any[]>([]);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
// Fetch document categories
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await api.get('/documents/get-types');
|
||||
if (response.data?.success) {
|
||||
setCategories([{ label: 'Tutte le tipologie', value: null }, ...response.data.categories]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero delle categorie:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch user documents based on selected category
|
||||
const fetchUserDocuments = async (filterValue: any = null) => {
|
||||
try {
|
||||
if (!refreshing) setIsLoading(true);
|
||||
const params = { filter: filterValue };
|
||||
const response = await api.get(`/documents/list`, { params });
|
||||
if (response.data?.success) {
|
||||
setDocuments(response.data.result || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero dei documenti utente:', error);
|
||||
alert.showAlert('error', 'Errore', 'Impossibile recuperare i documenti. Riprova più tardi.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true);
|
||||
await fetchCategories();
|
||||
await fetchUserDocuments(selectedCategory);
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchUserDocuments(selectedCategory);
|
||||
};
|
||||
|
||||
const handleCategorySelect = (value: any) => {
|
||||
setSelectedCategory(value);
|
||||
fetchUserDocuments(value);
|
||||
setShowPicker(false);
|
||||
};
|
||||
|
||||
if (isLoading && !refreshing) {
|
||||
return (
|
||||
<LoadingScreen />
|
||||
);
|
||||
}
|
||||
|
||||
// Get label for the selected category or default text
|
||||
const selectedLabel = selectedCategory
|
||||
? categories.find(c => c.value === selectedCategory)?.label
|
||||
: 'Filtra per tipologia...';
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<StatusBar style="dark" />
|
||||
{/* Header */}
|
||||
<View className="bg-white px-4 pb-6 shadow-sm border-b border-gray-100">
|
||||
<SafeAreaView edges={['top']} className='pt-5'>
|
||||
<View className='flex-row items-center gap-4 mb-4'>
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2 rounded-full active:bg-gray-100">
|
||||
<ChevronLeft size={28} color="#4b5563" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
<View className="flex-1">
|
||||
<Text className="text-3xl font-bold text-gray-800">Documenti</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Select / Dropdown Trigger and Reset */}
|
||||
<View className="flex-row items-center mx-1 gap-3">
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPicker(true)}
|
||||
className="flex-1 flex-row items-center justify-between bg-white px-5 py-3 rounded-2xl border border-gray-200 shadow-sm"
|
||||
>
|
||||
<Text className="text-gray-700 font-medium text-base flex-1 mr-2" numberOfLines={1}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{selectedCategory !== null && (
|
||||
<TouchableOpacity
|
||||
onPress={() => handleCategorySelect(null)}
|
||||
className="bg-gray-50 p-3.5 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
|
||||
>
|
||||
<X size={22} color="#4b5563" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
<View className="p-5 flex-1 pt-4">
|
||||
{/* Documents List */}
|
||||
<FlatList
|
||||
data={documents}
|
||||
keyExtractor={(item, index) => index.toString()}
|
||||
contentContainerStyle={{ gap: 16}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />
|
||||
}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={15}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={({ item: doc }) => (
|
||||
<DocumentListCard item={doc} />
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
!isLoading ? (
|
||||
<View className="bg-white p-8 rounded-3xl border border-gray-100 items-center justify-center border-dashed mt-4">
|
||||
<Text className="text-gray-400 font-medium text-center">
|
||||
{selectedCategory !== null
|
||||
? 'Nessun documento trovato in questa categoria'
|
||||
: 'Nessun documento presente'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Modal Picker (Dropdown Custom) */}
|
||||
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowPicker(false)}
|
||||
className="flex-1 bg-black/50 justify-end"
|
||||
>
|
||||
<View className="bg-white rounded-t-3xl p-5 max-h-[70%]" onStartShouldSetResponder={() => true}>
|
||||
<View className="flex-row justify-between items-center mb-4 border-b border-gray-100 pb-4">
|
||||
<Text className="text-xl font-bold text-gray-800">Filtra per tipologia</Text>
|
||||
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full">
|
||||
<X size={20} color="#4b5563" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 30 }}
|
||||
data={categories}
|
||||
keyExtractor={(item, index) => index.toString()}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={15}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={({ item: cat }) => (
|
||||
<TouchableOpacity
|
||||
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedCategory === cat.value ? 'bg-blue-50' : ''}`}
|
||||
onPress={() => handleCategorySelect(cat.value)}
|
||||
>
|
||||
<Text className={`text-lg ${selectedCategory === cat.value ? 'font-bold text-primary-dark' : 'text-gray-700'}`}>
|
||||
{cat.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { AuthContext } from '@/utils/authContext';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ChevronLeft, FileText, LogOut, Mail, User } from 'lucide-react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useContext } from 'react';
|
||||
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const authContext = useContext(AuthContext);
|
||||
const { user } = authContext;
|
||||
const router = useRouter();
|
||||
|
||||
// Generate user initials
|
||||
const initials = `${user?.firstName?.[0] ?? ''}${user?.lastName?.[0] ?? ''}`.toUpperCase();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark">
|
||||
<StatusBar style="light" />
|
||||
<SafeAreaView edges={['top']} className='pt-5'>
|
||||
{/* Header Section */}
|
||||
<View className="pb-6 px-4">
|
||||
<View className="flex-row justify-start items-center gap-4">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft size={28} color="white" pointerEvents="none"/>
|
||||
</TouchableOpacity>
|
||||
<View className="flex-row items-center gap-4">
|
||||
<View className="w-16 h-16 rounded-full bg-white/20 items-center justify-center">
|
||||
<Text className="text-white font-bold text-2xl">{initials}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-gray-300 text-lg font-medium uppercase tracking-wider mb-1">Profilo</Text>
|
||||
<Text className="text-white text-2xl font-bold">{user?.firstName} {user?.lastName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1 bg-gray-50 rounded-t-[2.5rem] px-5 pt-8"
|
||||
contentContainerStyle={{ paddingBottom: 60, gap: 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Info Card - Enlarged Texts */}
|
||||
<View className="bg-white p-7 rounded-3xl shadow-sm border border-gray-100">
|
||||
{/* Section title */}
|
||||
<Text className="text-2xl font-bold text-gray-800">Informazioni</Text>
|
||||
|
||||
<View className="mt-6 gap-5">
|
||||
<View className="flex-row items-center gap-5 flex-1">
|
||||
<View className="w-14 h-14 bg-blue-50 rounded-2xl items-center justify-center flex-shrink-0">
|
||||
<Mail size={24} color="#1071C2" pointerEvents="none" />
|
||||
</View>
|
||||
<View className="flex-1 pr-4">
|
||||
<Text className="text-lg text-gray-700 font-bold">Email</Text>
|
||||
<Text className="text-gray-500 text-base" numberOfLines={1} ellipsizeMode="tail">{user?.email}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-5">
|
||||
<View className="w-14 h-14 bg-blue-50 rounded-2xl items-center justify-center">
|
||||
<User size={24} color="#1071C2" pointerEvents="none" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-lg text-gray-700 font-bold">Ruolo</Text>
|
||||
<Text className="text-gray-500 text-base capitalize">{user?.roleLabel || 'Utente'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View>
|
||||
<Text className="text-gray-800 text-2xl font-bold mb-5 px-1">Azioni</Text>
|
||||
|
||||
<TouchableOpacity onPress={() => router.push('/profile/documents')} className="bg-white p-4 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100 mb-4">
|
||||
<View className="flex-row items-center gap-5">
|
||||
<View className="bg-blue-50 p-3.5 rounded-2xl">
|
||||
<FileText size={26} color="#1071C2" pointerEvents="none" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-lg text-gray-800 font-bold">I miei documenti</Text>
|
||||
<Text className="text-base text-gray-400 mt-0.5">Visualizza i tuoi documenti</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-primary text-base font-bold">Apri</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={authContext.logOut} className="bg-white p-4 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100">
|
||||
<View className="flex-row items-center gap-5">
|
||||
<View className="bg-red-50 p-3.5 rounded-2xl">
|
||||
<LogOut size={26} color="#ef4444" pointerEvents="none" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-lg text-gray-800 font-bold">Esci</Text>
|
||||
<Text className="text-base text-gray-400 mt-0.5">Chiudi la sessione corrente</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-red-500 text-base font-bold">Esci</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import '../global.css';
|
||||
import { AuthProvider } from '@/utils/authContext';
|
||||
import { Stack } from 'expo-router';
|
||||
import { AlertProvider } from '@/components/AlertComponent';
|
||||
import { NetworkProvider } from '@/utils/networkProvider';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { ConfigProvider } from '@/utils/configProvider';
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<GestureHandlerRootView>
|
||||
<KeyboardProvider>
|
||||
<NetworkProvider>
|
||||
<ConfigProvider>
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<Stack screenOptions={{ headerShown: false, animation: 'flip' }}>
|
||||
<Stack.Screen name="(protected)" />
|
||||
<Stack.Screen name="login" />
|
||||
</Stack>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
</NetworkProvider>
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import api from '@/utils/api';
|
||||
import { AuthContext } from '@/utils/authContext';
|
||||
import { Eye, EyeOff, Lock, LogIn, User } from 'lucide-react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Image, Platform, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const alert = useAlert();
|
||||
const authContext = useContext(AuthContext);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Login Handler function
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
alert.showAlert('error', 'Attenzione', 'Inserisci username e password');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Execute login request
|
||||
const response = await api.post("/user/login", {
|
||||
username: username.trim(),
|
||||
password: password.trim()
|
||||
});
|
||||
|
||||
if (response.data && response.data.success === false) {
|
||||
alert.showAlert('error', 'Login Fallito', 'Credenziali non valide.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = response.data.auth_key;
|
||||
const user = {
|
||||
firstName: response.data.nome,
|
||||
lastName: response.data.cognome,
|
||||
email: response.data.email,
|
||||
role: response.data.role,
|
||||
roleLabel: response.data.role_label
|
||||
};
|
||||
|
||||
console.log("Login riuscito. Token:", token);
|
||||
console.log("Dati utente:", user);
|
||||
|
||||
// Pass token and user data to the context which will handle saving and redirect
|
||||
authContext.logIn(token, user);
|
||||
} catch (error: any) {
|
||||
let message = "Si è verificato un errore durante l'accesso.";
|
||||
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
message = "Credenziali non valide."
|
||||
} else {
|
||||
console.error("Login Error:", error);
|
||||
message = `Errore Server: ${error.response.data.message || error.response.status}`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
// Server not reachable
|
||||
console.error("Login Error:", error);
|
||||
message = "Impossibile contattare il server. Controlla la connessione.";
|
||||
} else {
|
||||
console.error("Login Error:", error);
|
||||
}
|
||||
|
||||
alert.showAlert('error', "Login Fallito", message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primary-dark h-screen overflow-hidden">
|
||||
<StatusBar style="light" />
|
||||
{/* Header with Logo/Title */}
|
||||
<View className="h-[30%] flex-column justify-center items-center">
|
||||
<View className="bg-white rounded-full w-32 h-32 justify-center items-center overflow-hidden shadow-lg">
|
||||
<Image
|
||||
source={require('@/assets/images/react-logo.png')}
|
||||
className='h-20 w-20'
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Form Container */}
|
||||
<View className="flex-1 bg-white rounded-t-[2.5rem] px-8 pt-8 shadow-xl w-full">
|
||||
<KeyboardAwareScrollView
|
||||
bottomOffset={Platform.OS === 'ios' ? 50 : 80}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 40, flexGrow: 1, justifyContent: 'space-between' }}
|
||||
className="flex-1"
|
||||
>
|
||||
<View className="flex-1 flex-col justify-between">
|
||||
<View>
|
||||
<Text className="text-primary-dark text-5xl font-bold text-center mb-3">Accedi</Text>
|
||||
<Text className="text-base font-semibold text-center text-text-secondary mb-10">
|
||||
Inserisci le tue credenziali per accedere
|
||||
</Text>
|
||||
|
||||
<View className="gap-6 flex flex-col" style={{ gap: '1.5rem' }}>
|
||||
{/* Input Username */}
|
||||
<View>
|
||||
<View className="flex-row items-center bg-slate-50 border border-slate-200 rounded-2xl h-16 px-4 flex">
|
||||
<User size={24} color="#94a3b8" pointerEvents="none" />
|
||||
<TextInput
|
||||
className="flex-1 ml-4 text-text text-lg font-medium h-full w-full"
|
||||
placeholder="Username"
|
||||
placeholderTextColor="#94a3b8"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Input Password */}
|
||||
<View>
|
||||
<View className="flex-row items-center bg-slate-50 border border-slate-200 rounded-2xl h-16 px-4 flex">
|
||||
<Lock size={24} color="#94a3b8" pointerEvents="none" />
|
||||
<TextInput
|
||||
className="flex-1 ml-4 text-text text-lg font-medium h-full w-full"
|
||||
placeholder="Password"
|
||||
placeholderTextColor="#94a3b8"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
|
||||
{showPassword ? (
|
||||
<EyeOff size={24} color="#64748b" pointerEvents="none" />
|
||||
) : (
|
||||
<Eye size={24} color="#64748b" pointerEvents="none" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Login Button */}
|
||||
<View className="mt-8">
|
||||
<TouchableOpacity
|
||||
onPress={handleLogin}
|
||||
activeOpacity={0.8}
|
||||
className={`bg-primary h-16 rounded-2xl flex-row justify-center items-center shadow-md flex ${isLoading ? 'opacity-70' : ''}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text className="text-white text-xl font-bold mr-2">
|
||||
{isLoading ? 'ACCESSO IN CORSO...' : 'LOGIN'}
|
||||
</Text>
|
||||
{!isLoading && <LogIn size={24} color="white" pointerEvents="none" />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAwareScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user