Initial commit
@@ -0,0 +1,5 @@
|
|||||||
|
EXPO_PUBLIC_API_URL=[YOUR_API_URL] # backend API URL (used for development, it overrides the one provided by the gateway)
|
||||||
|
|
||||||
|
EXPO_PUBLIC_GW_API_URL=[YOUR_GW_API_URL] # Gateway API URL
|
||||||
|
EXPO_PUBLIC_GW_UUID=[YOUR_GW_UUID] # Gateway UUID
|
||||||
|
EXPO_PUBLIC_GW_API_TOKEN=[YOUR_GW_API_TOKEN] # Gateway API Token
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Welcome to your Expo app 👋
|
||||||
|
|
||||||
|
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
In the output, you'll find options to open the app in a
|
||||||
|
|
||||||
|
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||||
|
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||||
|
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||||
|
|
||||||
|
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||||
|
|
||||||
|
## Get a fresh project
|
||||||
|
|
||||||
|
When you're ready, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run reset-project
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
To learn more about developing your project with Expo, look at the following resources:
|
||||||
|
|
||||||
|
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||||
|
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||||
|
|
||||||
|
## Join the community
|
||||||
|
|
||||||
|
Join our community of developers creating universal apps.
|
||||||
|
|
||||||
|
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||||
|
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Progeco",
|
||||||
|
"slug": "progeco_app",
|
||||||
|
"version": "1.6",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "progecoapp",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.pcrt.progeco-app"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO"
|
||||||
|
],
|
||||||
|
"package": "com.pcrt.progeco_app"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png",
|
||||||
|
"bundler": "metro"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"dark": {
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera",
|
||||||
|
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone",
|
||||||
|
"recordAudioAndroid": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-font"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "51cde1ca-e1b5-46c6-b9b4-0f17bf95693c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Redirect, Tabs } from 'expo-router';
|
||||||
|
import { Home, Clock, ShoppingBag, CircleCheckBig, 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="activity"
|
||||||
|
options={{
|
||||||
|
title: 'Attività',
|
||||||
|
tabBarIcon: ({ color, size }) => <ShoppingBag 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="quality"
|
||||||
|
options={{
|
||||||
|
title: 'Qualità',
|
||||||
|
tabBarIcon: ({ color, size }) => <CircleCheckBig 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="invoice"
|
||||||
|
options={{
|
||||||
|
title: 'Fatture',
|
||||||
|
href: null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
href: null,
|
||||||
|
title: 'Profilo',
|
||||||
|
tabBarStyle: { display: 'none' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="dashboard/index"
|
||||||
|
options={{
|
||||||
|
href: null,
|
||||||
|
title: 'Dashboard',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="construction-site"
|
||||||
|
options={{
|
||||||
|
href: null,
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: { display: 'none' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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, Users, HardHat, MapPin, Calendar as CalendarIcon, Briefcase, TextAlignStart, CheckCircle2, Wrench, Pencil } from 'lucide-react-native';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Dimensions, RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } 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 insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
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 [subcontractorLabor, setSubcontractorLabor] = useState<any[]>([]);
|
||||||
|
const [otherOperatorLabor, setOtherOperatorLabor] = 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 || []);
|
||||||
|
setSubcontractorLabor(data.subcontractor_labor || []);
|
||||||
|
setOtherOperatorLabor(data.other_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('Subappaltatori', <Briefcase size={24} color="#082963" />, subcontractorLabor)}
|
||||||
|
{renderLaborSection('Operai Distaccati', <Users size={24} color="#082963" />, otherOperatorLabor)}
|
||||||
|
{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,430 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, TextInput, KeyboardAvoidingView, ScrollView, Platform, 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 [subcontractorLabor, setSubcontractorLabor] = useState<any[]>([]);
|
||||||
|
const [otherOperatorLabor, setOtherOperatorLabor] = 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 || []);
|
||||||
|
setSubcontractorLabor(data.subcontractor_labor || []);
|
||||||
|
setOtherOperatorLabor(data.other_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,
|
||||||
|
subcontractor_labor: subcontractorLabor,
|
||||||
|
other_operators_labor: otherOperatorLabor,
|
||||||
|
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="Subappaltatori"
|
||||||
|
fetchUrl="/activity/get-subcontractors"
|
||||||
|
laborList={subcontractorLabor}
|
||||||
|
onAddLabor={(labor) => setSubcontractorLabor([...subcontractorLabor, labor])}
|
||||||
|
onRemoveLabor={(idx) => setSubcontractorLabor(subcontractorLabor.filter((_, i) => i !== idx))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActivityLaborCard
|
||||||
|
title="Operai Distaccati"
|
||||||
|
fetchUrl="/activity/get-other-operators"
|
||||||
|
laborList={otherOperatorLabor}
|
||||||
|
onAddLabor={(labor) => setOtherOperatorLabor([...otherOperatorLabor, labor])}
|
||||||
|
onRemoveLabor={(idx) => setOtherOperatorLabor(otherOperatorLabor.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,191 @@
|
|||||||
|
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 } 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';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
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,248 @@
|
|||||||
|
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) {
|
||||||
|
console.error('Errore nell\'invio dei dati di scansione:', error);
|
||||||
|
alert.showAlert('error', 'Errore', '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.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" 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,337 @@
|
|||||||
|
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 { useRouter } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { ChevronDown, ChevronUp, LayoutDashboard } from 'lucide-react-native';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { useAlert } from '@/components/AlertComponent';
|
||||||
|
import EchartWrapper from '@/components/EchartWrapper';
|
||||||
|
import {
|
||||||
|
CHART_ENDPOINTS,
|
||||||
|
CHART_DATA_KEY,
|
||||||
|
cumulate,
|
||||||
|
formatEuro,
|
||||||
|
buildPieOption,
|
||||||
|
buildSoaBarOption,
|
||||||
|
commesseLine,
|
||||||
|
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 router = useRouter();
|
||||||
|
const alert = useAlert();
|
||||||
|
|
||||||
|
const [chartData, setChartData] = useState<any>({
|
||||||
|
costiRicavi: null,
|
||||||
|
fatturatoCliente: null,
|
||||||
|
aperteChiuseCliente: null,
|
||||||
|
aperteChiuseFornitore: null,
|
||||||
|
fatturatoSoa: null,
|
||||||
|
partiteCliente: null,
|
||||||
|
marginalitaMediaSoa: null,
|
||||||
|
aggregatiSoa: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});
|
||||||
|
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({
|
||||||
|
costiRicavi: false,
|
||||||
|
costiRicaviCum: false,
|
||||||
|
aperteChiuseCliente: false,
|
||||||
|
aperteChiuseFornitore: false,
|
||||||
|
fatturatoSoa: false,
|
||||||
|
fatturatoCliente: false,
|
||||||
|
apertoCliente: false,
|
||||||
|
chiusoCliente: false,
|
||||||
|
marginalitaMediaSoa: false,
|
||||||
|
marginalitaCategoriaSoa: false,
|
||||||
|
pesoFatturatoSoa: 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 costiRicaviCumOption = useMemo(() => {
|
||||||
|
if (!chartData.costiRicavi) return null;
|
||||||
|
const d = chartData.costiRicavi;
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
...TOOLTIP_BASE,
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: formatEuro
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
itemGap: 6,
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 10,
|
||||||
|
textStyle: { fontSize: 12 },
|
||||||
|
data: [
|
||||||
|
`Costi Materiali(${d.current_year})`,
|
||||||
|
`Costi Manodopera(${d.current_year})`,
|
||||||
|
`Ricavi(${d.current_year})`,
|
||||||
|
`Utile(${d.current_year})`,
|
||||||
|
`Costi Materiali(${d.prev_year})`,
|
||||||
|
`Costi Manodopera(${d.prev_year})`,
|
||||||
|
`Ricavi(${d.prev_year})`,
|
||||||
|
`Utile(${d.prev_year})`
|
||||||
|
],
|
||||||
|
selected: {
|
||||||
|
[`Utile(${d.prev_year})`]: false,
|
||||||
|
[`Ricavi(${d.prev_year})`]: false,
|
||||||
|
[`Costi Materiali(${d.prev_year})`]: false,
|
||||||
|
[`Costi Manodopera(${d.prev_year})`]: false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
top: '8%',
|
||||||
|
bottom: 95,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: d.mesi
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value'
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{ name: `Costi Materiali(${d.current_year})`, type: 'bar', data: cumulate(d.costi) },
|
||||||
|
{ name: `Costi Manodopera(${d.current_year})`, type: 'bar', data: cumulate(d.costi_mdo) },
|
||||||
|
{ name: `Ricavi(${d.current_year})`, type: 'bar', data: cumulate(d.ricavi) },
|
||||||
|
{ name: `Utile(${d.current_year})`, type: 'bar', lineStyle: { width: 2.5, type: 'dashed' }, data: cumulate(d.utile) },
|
||||||
|
{ name: `Costi Materiali(${d.prev_year})`, type: 'bar', itemStyle: { color: '#b3b3b3' }, data: cumulate(d.costi_prev) },
|
||||||
|
{ name: `Costi Manodopera(${d.prev_year})`, type: 'bar', itemStyle: { color: '#b3b3b3' }, data: cumulate(d.costi_mdo_prev) },
|
||||||
|
{ name: `Ricavi(${d.prev_year})`, type: 'bar', itemStyle: { color: '#b3b3b3' }, data: cumulate(d.ricavi_prev) },
|
||||||
|
{ name: `Utile(${d.prev_year})`, type: 'bar', lineStyle: { width: 1.5, type: 'dashed', color: '#b3b3b3' }, data: cumulate(d.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 fatturatoSoaOption = useMemo(() => chartData.fatturatoSoa ? buildPieOption(chartData.fatturatoSoa) : null, [chartData.fatturatoSoa]);
|
||||||
|
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 numItemsSoa = chartData.fatturatoSoa?.length || 0;
|
||||||
|
const dynamicSoaHeight = Math.max(260, 200 + (numItemsSoa * 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);
|
||||||
|
|
||||||
|
const marginalitaMediaSoaOption = useMemo(() => chartData.marginalitaMediaSoa
|
||||||
|
? buildSoaBarOption(chartData.marginalitaMediaSoa, { color: '#5470c6', valueLabel: 'Marginalità media', extraLines: commesseLine })
|
||||||
|
: null, [chartData.marginalitaMediaSoa]);
|
||||||
|
|
||||||
|
const marginalitaCategoriaOption = useMemo(() => chartData.aggregatiSoa
|
||||||
|
? buildSoaBarOption(chartData.aggregatiSoa.marginalitaCategoria, { color: '#3ba272', valueLabel: 'Marginalità categoria', extraLines: commesseLine })
|
||||||
|
: null, [chartData.aggregatiSoa]);
|
||||||
|
|
||||||
|
const pesoFatturatoSoaOption = useMemo(() => chartData.aggregatiSoa
|
||||||
|
? buildSoaBarOption(chartData.aggregatiSoa.pesoFatturato, {
|
||||||
|
color: '#fac858', valueLabel: 'Peso', extraLines: (r: any) => [
|
||||||
|
`Fatturato categoria: ${formatEuro(r.fatturato_tot)}`,
|
||||||
|
`Commesse: ${r.n_commesse}`
|
||||||
|
]
|
||||||
|
}) : null, [chartData.aggregatiSoa]);
|
||||||
|
|
||||||
|
const soaBarHeight = (arr: any) => Math.max(280, (arr?.length || 0) * 36 + 80);
|
||||||
|
const dynamicMarginalitaHeight = soaBarHeight(chartData.marginalitaMediaSoa);
|
||||||
|
const dynamicMarginalitaCatHeight = soaBarHeight(chartData.aggregatiSoa?.marginalitaCategoria);
|
||||||
|
const dynamicPesoSoaHeight = soaBarHeight(chartData.aggregatiSoa?.pesoFatturato);
|
||||||
|
|
||||||
|
// 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('costiRicaviCum', 'Costi / Ricavi (Cumulati)', costiRicaviCumOption, 390)}
|
||||||
|
{renderCard('aperteChiuseCliente', 'Aperto / Chiuso Clienti', aperteChiuseClienteOption, 330)}
|
||||||
|
{renderCard('aperteChiuseFornitore', 'Aperto / Chiuso Fornitori', aperteChiuseFornitoreOption, 330)}
|
||||||
|
{renderCard('fatturatoSoa', 'Fatturato per SOA', fatturatoSoaOption, dynamicSoaHeight)}
|
||||||
|
{renderCard('fatturatoCliente', 'Fatturato per Cliente', fatturatoClienteOption, dynamicPieHeight)}
|
||||||
|
{renderCard('apertoCliente', 'Aperto per Cliente', apertoClienteOption, dynamicApertoHeight)}
|
||||||
|
{renderCard('chiusoCliente', 'Chiuso per Cliente', chiusoClienteOption, dynamicChiusoHeight)}
|
||||||
|
{renderCard('marginalitaMediaSoa', 'Marginalità media per SOA', marginalitaMediaSoaOption, dynamicMarginalitaHeight)}
|
||||||
|
{renderCard('marginalitaCategoriaSoa', 'Marginalità % di categoria SOA', marginalitaCategoriaOption, dynamicMarginalitaCatHeight)}
|
||||||
|
{renderCard('pesoFatturatoSoa', 'Peso fatturato SOA', pesoFatturatoSoaOption, dynamicPesoSoaHeight)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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 { FileText, 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">
|
||||||
|
Progeco Costruzioni Generali 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 / Centro di Controllo */}
|
||||||
|
<View>
|
||||||
|
<Text className="text-slate-800 text-xl font-bold mb-4 px-1">
|
||||||
|
{user?.isAdmin ? 'Centro di Controllo' : 'Azioni Rapide'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{user?.isAdmin ? (
|
||||||
|
<View className="flex-row gap-5">
|
||||||
|
<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('/invoice')}
|
||||||
|
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">
|
||||||
|
<FileText size={40} color="#1071C2" pointerEvents="none"/>
|
||||||
|
</View>
|
||||||
|
<Text className="text-lg font-bold text-slate-700 text-center">Fatture</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="flex-row gap-5">
|
||||||
|
<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,148 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, ScrollView, TouchableOpacity, Linking } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { ChevronLeft, Download, CheckCircle2, XCircle, Calendar, CreditCard } from 'lucide-react-native';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { useAlert } from '@/components/AlertComponent';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import { InvoiceInfo, InvoiceAccounting } from '@/types/types';
|
||||||
|
|
||||||
|
export default function InvoiceDetailScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const alert = useAlert();
|
||||||
|
|
||||||
|
const [invoiceInfo, setInvoiceInfo] = useState<InvoiceInfo | null>(null);
|
||||||
|
const [partite, setPartite] = useState<InvoiceAccounting[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchInvoiceInfo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/invoice-supplier/get-info?id=${id}`);
|
||||||
|
if (response.data?.success) {
|
||||||
|
setInvoiceInfo(response.data.result.info);
|
||||||
|
|
||||||
|
// Map backend keys to our frontend types
|
||||||
|
const mappedPartite: InvoiceAccounting[] = (response.data.result.partite || []).map((p: any) => ({
|
||||||
|
expiry: p.scadenza,
|
||||||
|
tpa_description: p.tpa_descrizione,
|
||||||
|
amount: p.importo_dovuto,
|
||||||
|
isPaid: p.pagata
|
||||||
|
}));
|
||||||
|
setPartite(mappedPartite);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching invoice info:', error);
|
||||||
|
alert.showAlert('error', 'Errore', 'Impossibile recuperare il dettaglio della fattura.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchInvoiceInfo();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (invoiceInfo?.link) {
|
||||||
|
Linking.openURL(invoiceInfo.link);
|
||||||
|
} else {
|
||||||
|
alert.showAlert('error', 'Nessun Documento', 'Non è presente un documento allegato per questa fattura.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !invoiceInfo) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
{/* Header */}
|
||||||
|
<View className="bg-white px-6 pb-6 shadow-sm border-b border-gray-100">
|
||||||
|
<SafeAreaView edges={['top']} className="pt-5 flex-row items-center justify-between">
|
||||||
|
<TouchableOpacity onPress={() => router.back()} className="p-2 bg-gray-50 rounded-full active:bg-gray-100">
|
||||||
|
<ChevronLeft size={24} color="#082963" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-xl font-bold text-gray-800 text-center flex-1">Dettaglio Fattura</Text>
|
||||||
|
<TouchableOpacity onPress={handleDownload} className="p-2.5 bg-blue-50 rounded-full active:bg-blue-100 shadow-sm">
|
||||||
|
<Download size={22} color="#1071C2" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 100, gap: 16 }} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* General Info Card */}
|
||||||
|
<View className="bg-white p-6 rounded-3xl shadow-sm border border-gray-100">
|
||||||
|
<Text className="text-sm font-bold text-gray-400 uppercase tracking-wider mb-4">Informazioni</Text>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-xs font-bold text-gray-400 uppercase mb-1">Numero Documento</Text>
|
||||||
|
<Text className="text-base font-bold text-gray-800">{invoiceInfo.documentNumber}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-xs font-bold text-gray-400 uppercase mb-1">Fornitore</Text>
|
||||||
|
<Text className="text-xl font-bold text-[#082963]">{invoiceInfo.supplier}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-xs font-bold text-gray-400 uppercase mb-1">Cantiere</Text>
|
||||||
|
<Text className="text-base font-bold text-gray-800">{invoiceInfo.placeName}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between bg-gray-50 p-4 rounded-2xl mb-2">
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs font-bold text-gray-400 uppercase mb-1">Data Documento</Text>
|
||||||
|
<Text className="font-bold text-gray-800">{invoiceInfo.date}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end">
|
||||||
|
<Text className="text-xs font-bold text-gray-400 uppercase mb-1">Importo</Text>
|
||||||
|
<Text className="font-bold text-[#1071C2]">{invoiceInfo.totalAmount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Expiries */}
|
||||||
|
{partite && partite.length > 0 && (
|
||||||
|
<View className="bg-white p-6 rounded-3xl shadow-sm border border-gray-100">
|
||||||
|
<Text className="text-sm font-bold text-gray-400 uppercase tracking-wider mb-4">Scadenze</Text>
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
{partite.map((item, idx) => (
|
||||||
|
<View key={idx} className={`border border-gray-100 rounded-2xl p-4 ${item.isPaid ? 'bg-green-50/30' : 'bg-red-50/30'}`}>
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Calendar size={16} color="#082963" />
|
||||||
|
<Text className="font-bold text-[#082963]">{item.expiry}</Text>
|
||||||
|
</View>
|
||||||
|
<View className={`px-2 py-1 rounded-md flex-row items-center gap-1 ${item.isPaid ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||||
|
{item.isPaid ? <CheckCircle2 size={12} color="#109D59" /> : <XCircle size={12} color="#DC4437" />}
|
||||||
|
<Text className={`text-[10px] font-bold uppercase ${item.isPaid ? 'text-green-700' : 'text-red-700'}`}>
|
||||||
|
{item.isPaid ? 'Pagata' : 'Da Pagare'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between items-center pt-3 border-t border-gray-100 gap-2">
|
||||||
|
<View className="flex-row items-center gap-2 flex-1 mr-2">
|
||||||
|
<CreditCard size={14} color="#8F9BB3" />
|
||||||
|
<Text className="text-xs uppercase text-gray-500 font-medium flex-1">{item.tpa_description}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={`font-bold text-base whitespace-nowrap ${item.isPaid ? 'text-[#109D59]' : 'text-[#DC4437]'}`}>
|
||||||
|
{item.amount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {Stack} from 'expo-router';
|
||||||
|
|
||||||
|
export default function InvoiceLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{headerShown: false}}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="[id]" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { View, Text, FlatList, TouchableOpacity, RefreshControl } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { Filter, ReceiptText } from 'lucide-react-native';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { useAlert } from '@/components/AlertComponent';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import FilterModal from '@/components/FilterModal';
|
||||||
|
import InvoiceCard from '@/components/InvoiceCard';
|
||||||
|
import { InvoiceItem, Place } from '@/types/types';
|
||||||
|
|
||||||
|
export default function InvoiceScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const alert = useAlert();
|
||||||
|
const [invoices, setInvoices] = useState<InvoiceItem[]>([]);
|
||||||
|
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 [filterSupplier, setFilterSupplier] = useState<any>(null);
|
||||||
|
|
||||||
|
const activeFiltersCount = (filterRange.startDate ? 1 : 0) + (filterPlace ? 1 : 0) + (filterSupplier ? 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 fetchInvoices = async (currentRange = filterRange, currentPlace = filterPlace, currentSupplier = filterSupplier) => {
|
||||||
|
try {
|
||||||
|
if (!refreshing) setIsLoading(true);
|
||||||
|
const rangeParam = currentRange.startDate ? currentRange : null;
|
||||||
|
// The backend's construction_site_code matches the name (label) of the construction site
|
||||||
|
const placeCode = currentPlace ? places.find(p => p.id === currentPlace)?.label : null;
|
||||||
|
const params = { date: rangeParam, place: placeCode, supplier: currentSupplier };
|
||||||
|
const response = await api.post('/invoice-supplier/list', { params });
|
||||||
|
if (response.data?.success) {
|
||||||
|
// The backend already returns data matching InvoiceItem mostly
|
||||||
|
setInvoices(response.data.result || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching invoices:', error);
|
||||||
|
alert.showAlert('error', 'Errore', 'Impossibile recuperare la lista delle fatture.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlaces();
|
||||||
|
fetchInvoices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchInvoices();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stable reference: keeps InvoiceCard memoization effective across page re-renders
|
||||||
|
const handleInvoicePress = useCallback((id: number) => {
|
||||||
|
router.push(`/invoice/${id}`);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleApplyFilters = (range: any, place: any, supplier: any) => {
|
||||||
|
setFilterRange(range);
|
||||||
|
setFilterPlace(place);
|
||||||
|
setFilterSupplier(supplier);
|
||||||
|
setShowFilterModal(false);
|
||||||
|
fetchInvoices(range, place, supplier);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
const emptyRange = { startDate: null, endDate: null };
|
||||||
|
setFilterRange(emptyRange);
|
||||||
|
setFilterPlace(null);
|
||||||
|
setFilterSupplier(null);
|
||||||
|
setShowFilterModal(false);
|
||||||
|
fetchInvoices(emptyRange, null, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 fatture</Text>
|
||||||
|
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Fatture Fornitori</Text>
|
||||||
|
</View>
|
||||||
|
<View className="bg-white/10 p-4 rounded-full">
|
||||||
|
<ReceiptText size={32} color="white" pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
|
||||||
|
<View className="flex-1 bg-gray-50 rounded-t-[2.5rem] overflow-hidden">
|
||||||
|
{/* List */}
|
||||||
|
<FlatList
|
||||||
|
data={invoices}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 20, paddingBottom: 100, gap: 16 }}
|
||||||
|
showsVerticalScrollIndicator={true}
|
||||||
|
scrollIndicatorInsets={{ right: 1 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<InvoiceCard item={item} onPress={handleInvoicePress} />
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<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">Nessuna fattura trovata</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
currentSupplier={filterSupplier}
|
||||||
|
showSupplier={true}
|
||||||
|
onClose={() => setShowFilterModal(false)}
|
||||||
|
onApply={handleApplyFilters}
|
||||||
|
onReset={handleResetFilters}
|
||||||
|
/>
|
||||||
|
</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,188 @@
|
|||||||
|
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">Nessun documento trovato in questa categoria</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?.isAdmin ? 'Amministratore' : '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,10 @@
|
|||||||
|
import {Stack} from 'expo-router';
|
||||||
|
|
||||||
|
export default function QualityLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{headerShown: false}}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="add" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { useAlert } from '@/components/AlertComponent';
|
||||||
|
import { AppDatePicker } from '@/components/AppDatePicker';
|
||||||
|
import GenericDropdown from '@/components/GenericDropdown';
|
||||||
|
import FileAttachmentCard from '@/components/FileAttachmentCard';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { formatDate, formatPickerDate } from '@/utils/dateTime';
|
||||||
|
import { DateType } from 'react-native-ui-datepicker';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { Calendar, CheckSquare, ChevronLeft, Square } from 'lucide-react-native';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, KeyboardAvoidingView, Modal, Platform, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
|
||||||
|
export default function AddQualityControlScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const alert = useAlert();
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [date, setDate] = useState<DateType>(new Date());
|
||||||
|
const [subactivityId, setSubactivityId] = useState<number | null>(null);
|
||||||
|
const [workType, setWorkType] = useState<number | null>(null);
|
||||||
|
const [refDoc, setRefDoc] = useState<number | null>(null);
|
||||||
|
const [controlType, setControlType] = useState('');
|
||||||
|
const [instrument, setInstrument] = useState<number | null>(null);
|
||||||
|
const [result, setResult] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
const [attachments, setAttachments] = useState<DocumentPicker.DocumentPickerAsset[]>([]);
|
||||||
|
|
||||||
|
// Checkboxes
|
||||||
|
const [checkSegnaletica, setCheckSegnaletica] = useState(false);
|
||||||
|
const [checkSoggettiTerzi, setCheckSoggettiTerzi] = useState(false);
|
||||||
|
const [checkUtilizzoDPI, setCheckUtilizzoDPI] = useState(false);
|
||||||
|
const [checkControlloVisivo, setCheckControlloVisivo] = useState(false);
|
||||||
|
const [checkControlloDim, setCheckControlloDim] = useState(false);
|
||||||
|
const [checkConformita, setCheckConformita] = useState(false);
|
||||||
|
const [checkControlloFunz, setCheckControlloFunz] = useState(false);
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
const [subactivities, setSubactivities] = useState<{id: number | string, label: string}[]>([]);
|
||||||
|
const [workTypeList, setWorkTypeList] = useState<{id: number, label: string}[]>([]);
|
||||||
|
const [refDocList, setRefDocList] = useState<{id: number, label: string}[]>([]);
|
||||||
|
const [instrumentList, setInstrumentList] = useState<{id: number, label: string}[]>([]);
|
||||||
|
|
||||||
|
const results = [
|
||||||
|
{ id: 1, label: 'Positivo' },
|
||||||
|
{ id: 0, label: 'Negativo' },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
// Load Subactivities
|
||||||
|
const subRes = await api.get('/subactivity/get-subactivities');
|
||||||
|
if (subRes.data?.success) {
|
||||||
|
const mappedSubs = subRes.data.subactivities.map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
uuid: s.uuid,
|
||||||
|
label: s.label
|
||||||
|
}));
|
||||||
|
setSubactivities(mappedSubs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Related Tables
|
||||||
|
const relRes = await api.get('/quality-control/get-related-tables');
|
||||||
|
if (relRes.data?.success) {
|
||||||
|
setWorkTypeList(relRes.data.data.workType || []);
|
||||||
|
setRefDocList(relRes.data.data.refDocument || []);
|
||||||
|
setInstrumentList(relRes.data.data.instrument || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel caricamento dei dati iniziali:', error);
|
||||||
|
alert.showAlert('error', 'Errore', 'Impossibile caricare i dati per il form.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDocument = async () => {
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
multiple: true,
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (!result.canceled && result.assets) {
|
||||||
|
setAttachments(prev => [...prev, ...result.assets]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore durante la selezione del documento:', error);
|
||||||
|
alert.showAlert('error', 'Errore', 'Impossibile selezionare il documento.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (index: number) => {
|
||||||
|
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!subactivityId || !date || result === null) {
|
||||||
|
alert.showAlert('warning', 'Dati Mancanti', 'Compila tutti i campi obbligatori (Cantiere, Data, Esito).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const formattedDate = formatPickerDate(date);
|
||||||
|
const selectedSub = subactivities.find(s => s.id === subactivityId) as any;
|
||||||
|
const payload = {
|
||||||
|
subactivity_uuid: selectedSub?.uuid,
|
||||||
|
id_subactivity: subactivityId,
|
||||||
|
subactivity_id: subactivityId, // just in case
|
||||||
|
id_work_type: workType,
|
||||||
|
date: formattedDate,
|
||||||
|
id_ref_document: refDoc,
|
||||||
|
control_type: controlType,
|
||||||
|
id_instrument: instrument,
|
||||||
|
result: result,
|
||||||
|
check_segnaletica: checkSegnaletica ? 1 : 0,
|
||||||
|
check_soggetti_terzi: checkSoggettiTerzi ? 1 : 0,
|
||||||
|
check_utilizzo_dpi: checkUtilizzoDPI ? 1 : 0,
|
||||||
|
check_controllo_visivo: checkControlloVisivo ? 1 : 0,
|
||||||
|
check_controllo_dim: checkControlloDim ? 1 : 0,
|
||||||
|
check_conformita: checkConformita ? 1 : 0,
|
||||||
|
check_controllo_funz: checkControlloFunz ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await api.post('/quality-control/add', { post: JSON.stringify(payload) });
|
||||||
|
if (response.data?.success) {
|
||||||
|
const qcId = response.data.id;
|
||||||
|
|
||||||
|
// Upload attachments if present
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
for (let i = 0; i < attachments.length; i++) {
|
||||||
|
const file = attachments[i];
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
const fileName = file.name || `document_${i}`;
|
||||||
|
const fileType = file.mimeType || 'application/octet-stream';
|
||||||
|
const fileUri = Platform.OS === 'android' ? file.uri : file.uri.replace('file://', '');
|
||||||
|
|
||||||
|
formData.append("files", {
|
||||||
|
name: fileName,
|
||||||
|
type: fileType,
|
||||||
|
uri: fileUri
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
formData.append('model_classname', 'QualityControl');
|
||||||
|
formData.append('model_id', qcId);
|
||||||
|
formData.append('method', 'put');
|
||||||
|
formData.append('name', fileName);
|
||||||
|
formData.append('type', fileType);
|
||||||
|
|
||||||
|
await api.post('/quality-control/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.showAlert('success', 'Salvato', 'Controllo di Qualità salvato con successo.');
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
alert.showAlert('error', 'Errore', response.data?.message || 'Salvataggio non riuscito.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore durante il salvataggio:', error);
|
||||||
|
alert.showAlert('error', 'Errore', 'Impossibile completare il salvataggio.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CheckboxRow = ({ label, value, onChange }: { label: string, value: boolean, onChange: (v: boolean) => void }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onChange(!value)}
|
||||||
|
className="flex-row items-center bg-white border border-gray-100 rounded-xl px-4 py-4 mb-3 active:bg-gray-50 shadow-sm"
|
||||||
|
>
|
||||||
|
{value ? <CheckSquare size={24} color="#1071C2" /> : <Square size={24} color="#9ca3af" />}
|
||||||
|
<Text className="ml-3 text-base text-gray-800 flex-1">{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 px-2'>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100">
|
||||||
|
<ChevronLeft size={28} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text className="text-xl font-bold text-gray-800 leading-tight uppercase flex-1 pr-4">
|
||||||
|
Nuovo Controllo
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 24 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Date */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-3">Data <Text className="text-red-500">*</Text></Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowDatePicker(true)}
|
||||||
|
className="flex-row items-center bg-white border border-gray-200 rounded-2xl px-5 py-4 mb-6 active:bg-gray-50 shadow-sm"
|
||||||
|
>
|
||||||
|
<Text className="flex-1 text-base text-gray-800 font-medium">
|
||||||
|
{date ? formatDate(formatPickerDate(date)) : 'Seleziona data...'}
|
||||||
|
</Text>
|
||||||
|
<Calendar size={20} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Subactivity / Cantiere */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-3">Cantiere <Text className="text-red-500">*</Text></Text>
|
||||||
|
<View className="mb-6 shadow-sm">
|
||||||
|
<GenericDropdown
|
||||||
|
options={subactivities}
|
||||||
|
selectedId={subactivityId}
|
||||||
|
onSelect={(id) => setSubactivityId(id as number)}
|
||||||
|
placeholder="Seleziona cantiere..."
|
||||||
|
searchPlaceholder="Cerca cantiere..."
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Work Type */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-3">Tipologia Lavorazione</Text>
|
||||||
|
<View className="mb-6 shadow-sm">
|
||||||
|
<GenericDropdown
|
||||||
|
options={workTypeList}
|
||||||
|
selectedId={workType}
|
||||||
|
onSelect={setWorkType}
|
||||||
|
placeholder="Seleziona tipologia..."
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Reference Document */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-3">Documento di Riferimento</Text>
|
||||||
|
<View className="mb-6 shadow-sm">
|
||||||
|
<GenericDropdown
|
||||||
|
options={refDocList}
|
||||||
|
selectedId={refDoc}
|
||||||
|
onSelect={setRefDoc}
|
||||||
|
placeholder="Seleziona documento..."
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Control Type */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-3">Tipo di Controllo</Text>
|
||||||
|
<View className="bg-white rounded-2xl border border-gray-200 mb-6 shadow-sm">
|
||||||
|
<TextInput
|
||||||
|
className="px-5 py-4 text-base text-gray-800 font-medium"
|
||||||
|
placeholder="Inserisci tipo di controllo"
|
||||||
|
placeholderTextColor="#6a7282"
|
||||||
|
value={controlType}
|
||||||
|
onChangeText={setControlType}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Instrument */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-3">Strumento Utilizzato</Text>
|
||||||
|
<View className="mb-8 shadow-sm">
|
||||||
|
<GenericDropdown
|
||||||
|
options={instrumentList}
|
||||||
|
selectedId={instrument}
|
||||||
|
onSelect={setInstrument}
|
||||||
|
placeholder="Seleziona strumento..."
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Checkboxes */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-4 mt-2 border-t border-gray-200 pt-6">Checklist Controlli</Text>
|
||||||
|
<CheckboxRow label="Presenza e visibilità segnaletica" value={checkSegnaletica} onChange={setCheckSegnaletica} />
|
||||||
|
<CheckboxRow label="Presenza soggetti terzi" value={checkSoggettiTerzi} onChange={setCheckSoggettiTerzi} />
|
||||||
|
<CheckboxRow label="Corretto utilizzo DPI" value={checkUtilizzoDPI} onChange={setCheckUtilizzoDPI} />
|
||||||
|
<CheckboxRow label="Controllo visivo" value={checkControlloVisivo} onChange={setCheckControlloVisivo} />
|
||||||
|
<CheckboxRow label="Controllo Dimensionale/Elaborati" value={checkControlloDim} onChange={setCheckControlloDim} />
|
||||||
|
<CheckboxRow label="Controllo conformità mat. posato" value={checkConformita} onChange={setCheckConformita} />
|
||||||
|
<CheckboxRow label="Controllo funzionale" value={checkControlloFunz} onChange={setCheckControlloFunz} />
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-3 mt-6 border-t border-gray-200 pt-6">Esito <Text className="text-red-500">*</Text></Text>
|
||||||
|
<View className="mb-10 shadow-sm">
|
||||||
|
<GenericDropdown
|
||||||
|
options={results}
|
||||||
|
selectedId={result}
|
||||||
|
onSelect={setResult}
|
||||||
|
placeholder="Seleziona esito..."
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Attachments Section */}
|
||||||
|
<View className="mb-8">
|
||||||
|
<View className="flex-row items-center justify-between mb-4 border-t border-gray-200 pt-6">
|
||||||
|
<Text className="text-lg font-bold text-primary-dark">
|
||||||
|
Allegati <Text className="text-sm font-normal text-gray-500">({attachments.length})</Text>
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity activeOpacity={0.7} onPress={pickDocument}>
|
||||||
|
<Text className="text-[#1071C2] font-bold text-base uppercase">Aggiungi</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mt-2">
|
||||||
|
{attachments.map((file, index) => (
|
||||||
|
<FileAttachmentCard
|
||||||
|
key={index}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => removeAttachment(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{attachments.length === 0 && (
|
||||||
|
<Text className="text-gray-400 font-medium text-center py-4 bg-white border border-gray-200 border-dashed rounded-2xl">
|
||||||
|
Nessun file allegato
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isSubmitting || !subactivityId || !date || result === null}
|
||||||
|
className={`w-full py-4 rounded-2xl shadow-lg flex-row items-center justify-center ${(!subactivityId || !date || result === null || isSubmitting) ? 'bg-gray-300' : 'bg-[#1071C2] active:scale-[0.98]'}`}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-white text-lg font-bold uppercase">Salva</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* Date Picker Modal */}
|
||||||
|
<Modal visible={showDatePicker} transparent animationType="fade">
|
||||||
|
<View className="flex-1 justify-center items-center bg-black/50">
|
||||||
|
<View className="bg-white rounded-3xl p-6 w-[90%] shadow-2xl">
|
||||||
|
<Text className="text-lg font-bold text-gray-800 mb-4">Seleziona Data</Text>
|
||||||
|
<AppDatePicker
|
||||||
|
mode="single"
|
||||||
|
date={date}
|
||||||
|
onChange={(d) => setDate(d.date || new Date())}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowDatePicker(false)}
|
||||||
|
className="mt-6 w-full py-4 bg-[#1071C2] rounded-xl active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-center font-bold text-lg">Conferma</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useAlert } from '@/components/AlertComponent';
|
||||||
|
import FilterModal from '@/components/FilterModal';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import QualityControlCard from '@/components/QualityControlCard';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { ClipboardCheck, Filter, Plus } from 'lucide-react-native';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Place, QualityControlItem } from '@/types/types';
|
||||||
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
|
|
||||||
|
export default function QualityControlScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const alert = useAlert();
|
||||||
|
const [qualityControls, setQualityControls] = useState<QualityControlItem[]>([]);
|
||||||
|
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 fetchQualityControls = async (currentRange = filterRange, currentPlace = filterPlace, isSilent = false) => {
|
||||||
|
try {
|
||||||
|
if (!refreshing && !isSilent) setIsLoading(true);
|
||||||
|
|
||||||
|
const rangeParam = currentRange.startDate ? currentRange : null;
|
||||||
|
// The API expects 'constructionSite' which is likely the ID. The filterPlace stores the ID or the Place object?
|
||||||
|
// According to PlaceFilter and InvoiceScreen, currentPlace is the selectedPlaceId.
|
||||||
|
const params = { range: rangeParam, constructionSite: currentPlace };
|
||||||
|
const response = await api.post('/quality-control/list', { params });
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
setQualityControls(response.data.result || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel recupero dei controlli qualità:', error);
|
||||||
|
alert.showAlert('error', 'Errore', 'Impossibile recuperare i dati. Riprova più tardi.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlaces();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchQualityControls(filterRange, filterPlace, true);
|
||||||
|
}, [filterRange, filterPlace])
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchQualityControls();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 dei controlli di qualità</Text>
|
||||||
|
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Controlli di Qualità</Text>
|
||||||
|
</View>
|
||||||
|
<View className="bg-white/10 p-4 rounded-full">
|
||||||
|
<ClipboardCheck 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: 160 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex-1 p-5 items-center">
|
||||||
|
<View className="w-full mt-2">
|
||||||
|
{qualityControls.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">Nessun controllo registrato</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
{qualityControls.map((item, index) => (
|
||||||
|
<QualityControlCard key={item.id || index} item={item} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* FAB Add */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push('/quality/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={() => 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>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Filter Modal */}
|
||||||
|
<FilterModal
|
||||||
|
visible={isFilterVisible}
|
||||||
|
showPlace={true}
|
||||||
|
showDate={true}
|
||||||
|
places={places}
|
||||||
|
currentRange={filterRange}
|
||||||
|
currentPlace={filterPlace}
|
||||||
|
onApply={(range, place) => {
|
||||||
|
setFilterRange(range);
|
||||||
|
setFilterPlace(place);
|
||||||
|
setIsFilterVisible(false);
|
||||||
|
fetchQualityControls(range, place);
|
||||||
|
}}
|
||||||
|
onReset={() => {
|
||||||
|
setFilterRange({ startDate: null, endDate: null });
|
||||||
|
setFilterPlace(null);
|
||||||
|
setIsFilterVisible(false);
|
||||||
|
fetchQualityControls({ startDate: null, endDate: null }, null);
|
||||||
|
}}
|
||||||
|
onClose={() => setIsFilterVisible(false)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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 {
|
||||||
|
username.trim();
|
||||||
|
password.trim();
|
||||||
|
|
||||||
|
// Execute login request
|
||||||
|
const response = await api.post("/user/login", {
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
isAdmin: response.data.isAdmin
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
|
"nativewind/babel",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, TextInput } from 'react-native';
|
||||||
|
import { Plus, Trash2, CheckCircle2 } from 'lucide-react-native';
|
||||||
|
import GenericDropdown from './GenericDropdown';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { useAlert } from './AlertComponent';
|
||||||
|
|
||||||
|
interface LaborItem {
|
||||||
|
id_registry: number;
|
||||||
|
id_user: number | null;
|
||||||
|
name: string;
|
||||||
|
hours: string | number;
|
||||||
|
minutes: string | number;
|
||||||
|
sync?: boolean;
|
||||||
|
id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityLaborCardProps {
|
||||||
|
title: string;
|
||||||
|
fetchUrl: string;
|
||||||
|
laborList: LaborItem[];
|
||||||
|
onAddLabor: (labor: LaborItem) => void;
|
||||||
|
onRemoveLabor: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityLaborCard({
|
||||||
|
title,
|
||||||
|
fetchUrl,
|
||||||
|
laborList,
|
||||||
|
onAddLabor,
|
||||||
|
onRemoveLabor
|
||||||
|
}: ActivityLaborCardProps) {
|
||||||
|
const alert = useAlert();
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
const [showInput, setShowInput] = useState(false);
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [hours, setHours] = useState('');
|
||||||
|
const [minutes, setMinutes] = useState('');
|
||||||
|
|
||||||
|
const loadItems = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(fetchUrl);
|
||||||
|
if (res.data?.success && res.data.items) {
|
||||||
|
// Map the items to include an 'id' property required by GenericDropdown
|
||||||
|
const mappedItems = res.data.items.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
id: item.id_registry
|
||||||
|
}));
|
||||||
|
setItems(mappedItems);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching labor items:', error);
|
||||||
|
}
|
||||||
|
}, [fetchUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadItems();
|
||||||
|
}, [loadItems]);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
alert.showAlert('error', 'Attenzione', 'Selezionare un elemento dalla lista');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours === '' && minutes === '') {
|
||||||
|
alert.showAlert('error', 'Attenzione', 'Inserire le ore o i minuti di lavoro');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = items.find(data => data.id_registry === selectedId);
|
||||||
|
if (el) {
|
||||||
|
const newLabor: LaborItem = {
|
||||||
|
id_registry: selectedId,
|
||||||
|
id_user: el.id_user,
|
||||||
|
name: el.label,
|
||||||
|
hours: hours === '' ? '0' : hours,
|
||||||
|
minutes: minutes === '' ? '0' : minutes,
|
||||||
|
};
|
||||||
|
onAddLabor(newLabor);
|
||||||
|
|
||||||
|
// Reset and hide
|
||||||
|
setSelectedId(null);
|
||||||
|
setHours('');
|
||||||
|
setMinutes('');
|
||||||
|
setShowInput(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleInput = () => {
|
||||||
|
setSelectedId(null);
|
||||||
|
setHours('');
|
||||||
|
setMinutes('');
|
||||||
|
setShowInput(!showInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-4">
|
||||||
|
<View className="flex-row items-center justify-between border-b border-gray-50 pb-3 mb-3">
|
||||||
|
<Text className="text-[#082963] font-bold text-lg">{title}</Text>
|
||||||
|
<TouchableOpacity onPress={toggleInput} className="active:opacity-70">
|
||||||
|
<Text className="text-[#1071C2] font-bold text-sm">
|
||||||
|
{showInput ? 'Annulla' : 'Aggiungi'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showInput && (
|
||||||
|
<View className="bg-blue-50/50 p-4 rounded-2xl mb-4 border border-blue-100">
|
||||||
|
<View className="mb-3 z-50">
|
||||||
|
<GenericDropdown
|
||||||
|
options={items}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={(id) => setSelectedId(id as number)}
|
||||||
|
placeholder="Seleziona..."
|
||||||
|
searchPlaceholder="Cerca per nome"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<View className="flex-1 bg-white rounded-xl border border-gray-200 px-3 py-2">
|
||||||
|
<TextInput
|
||||||
|
value={hours}
|
||||||
|
onChangeText={setHours}
|
||||||
|
placeholder="Ore"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
keyboardType="numeric"
|
||||||
|
className="text-gray-800 font-medium"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 bg-white rounded-xl border border-gray-200 px-3 py-2">
|
||||||
|
<TextInput
|
||||||
|
value={minutes}
|
||||||
|
onChangeText={setMinutes}
|
||||||
|
placeholder="Minuti"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
keyboardType="numeric"
|
||||||
|
className="text-gray-800 font-medium"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleAdd}
|
||||||
|
className="bg-[#1071C2] h-[46px] w-[46px] items-center justify-center rounded-xl"
|
||||||
|
>
|
||||||
|
<Plus size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="gap-2">
|
||||||
|
{laborList.map((item, index) => (
|
||||||
|
<View key={index} 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={2}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<View className="bg-white px-3 py-1.5 rounded-xl border border-gray-200">
|
||||||
|
<Text className="text-[#1071C2] font-bold">
|
||||||
|
{item.hours}h {item.minutes}m
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.sync ? (
|
||||||
|
<View className="w-8 items-center justify-center">
|
||||||
|
<CheckCircle2 size={20} color="#0F9D58" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onRemoveLabor(index)}
|
||||||
|
className="w-8 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Trash2 size={20} color="#ef4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{laborList.length === 0 && !showInput && (
|
||||||
|
<Text className="text-gray-400 text-center italic mt-2">Nessun elemento inserito</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
import { Modal, View, Text, TouchableOpacity, TouchableWithoutFeedback } from 'react-native';
|
||||||
|
import { CheckCircle, XCircle, Info, AlertTriangle } from 'lucide-react-native';
|
||||||
|
|
||||||
|
type AlertType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
|
type ConfirmButtonStyle = 'default' | 'destructive' | 'cancel';
|
||||||
|
|
||||||
|
interface ConfirmButton {
|
||||||
|
text: string;
|
||||||
|
onPress: () => void;
|
||||||
|
style?: ConfirmButtonStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertContextData {
|
||||||
|
showAlert: (type: AlertType, title: string, message: string) => void;
|
||||||
|
showConfirm: (
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
buttons: [ConfirmButton, ConfirmButton]
|
||||||
|
) => void;
|
||||||
|
hideAlert: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertContext = createContext<AlertContextData>({} as AlertContextData);
|
||||||
|
|
||||||
|
// TODO: Move this config to a separate file
|
||||||
|
const ALERT_CONFIG = {
|
||||||
|
success: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: 'text-green-600',
|
||||||
|
bgColor: 'bg-green-100',
|
||||||
|
btnColor: 'bg-green-600',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: XCircle,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bgColor: 'bg-red-100',
|
||||||
|
btnColor: 'bg-red-600',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: Info,
|
||||||
|
color: 'text-sky-600',
|
||||||
|
bgColor: 'bg-sky-100',
|
||||||
|
btnColor: 'bg-sky-600',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: 'text-orange-600',
|
||||||
|
bgColor: 'bg-orange-100',
|
||||||
|
btnColor: 'bg-orange-600',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AlertProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [type, setType] = useState<AlertType>('info');
|
||||||
|
const [isConfirmMode, setIsConfirmMode] = useState(false);
|
||||||
|
const [confirmButtons, setConfirmButtons] = useState<[ConfirmButton, ConfirmButton]>([
|
||||||
|
{ text: 'Annulla', onPress: () => {}, style: 'cancel' },
|
||||||
|
{ text: 'Conferma', onPress: () => {}, style: 'default' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const showAlert = (newType: AlertType, newTitle: string, newMessage: string) => {
|
||||||
|
setType(newType);
|
||||||
|
setTitle(newTitle);
|
||||||
|
setMessage(newMessage);
|
||||||
|
setIsConfirmMode(false);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
newTitle: string,
|
||||||
|
newMessage: string,
|
||||||
|
buttons: [ConfirmButton, ConfirmButton]
|
||||||
|
) => {
|
||||||
|
setTitle(newTitle);
|
||||||
|
setMessage(newMessage);
|
||||||
|
setConfirmButtons(buttons);
|
||||||
|
setIsConfirmMode(true);
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideAlert = () => {
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon: Icon, color, bgColor, btnColor } = ALERT_CONFIG[type];
|
||||||
|
|
||||||
|
// TODO: Need to refactor component styles
|
||||||
|
return (
|
||||||
|
<AlertContext.Provider value={{ showAlert, showConfirm, hideAlert }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
visible={visible}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={hideAlert}
|
||||||
|
>
|
||||||
|
{/* Dark Backdrop */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={hideAlert} // Closes if you click outside (optional)
|
||||||
|
className="flex-1 bg-black/60 justify-center items-center px-6"
|
||||||
|
>
|
||||||
|
{/* Alert Container */}
|
||||||
|
<TouchableWithoutFeedback>
|
||||||
|
<View className="bg-white w-full max-w-sm rounded-3xl p-6 items-center shadow-2xl">
|
||||||
|
|
||||||
|
{/* Icon Circle - Solo per alert normali */}
|
||||||
|
{!isConfirmMode && (
|
||||||
|
<View className={`${bgColor} p-4 rounded-full mb-4`}>
|
||||||
|
<Icon size={32} className={color} strokeWidth={2.5} pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Texts */}
|
||||||
|
<Text className="text-xl font-bold text-gray-900 text-center mb-2">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-lg text-gray-500 text-center leading-relaxed mb-8">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Buttons - Condizionale */}
|
||||||
|
{isConfirmMode ? (
|
||||||
|
// Conferma: 2 bottoni orizzontali
|
||||||
|
<View className="flex-row gap-3 w-full">
|
||||||
|
{confirmButtons.map((button, index) => {
|
||||||
|
const isDestructive = button.style === 'destructive';
|
||||||
|
const isCancel = button.style === 'cancel';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
onPress={() => {
|
||||||
|
hideAlert();
|
||||||
|
button.onPress();
|
||||||
|
}}
|
||||||
|
className={`flex-1 py-3.5 rounded-3xl ${
|
||||||
|
isDestructive
|
||||||
|
? 'bg-red-600'
|
||||||
|
: isCancel
|
||||||
|
? 'bg-gray-200'
|
||||||
|
: 'bg-[#1071C2]'
|
||||||
|
} active:opacity-90 shadow-sm`}
|
||||||
|
>
|
||||||
|
<Text className={`text-center font-bold text-lg ${
|
||||||
|
isCancel ? 'text-gray-700' : 'text-white'
|
||||||
|
}`}>
|
||||||
|
{button.text}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
// Alert normale: singolo bottone OK
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideAlert}
|
||||||
|
className={`w-full py-3.5 rounded-3xl ${btnColor} active:opacity-90 shadow-sm`}
|
||||||
|
>
|
||||||
|
<Text className="text-white text-center font-bold text-lg">
|
||||||
|
Ok, ho capito
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</AlertContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAlert = () => useContext(AlertContext);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DateTimePicker, { useDefaultStyles } from 'react-native-ui-datepicker';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react-native';
|
||||||
|
|
||||||
|
type AppDatePickerProps = React.ComponentProps<typeof DateTimePicker>;
|
||||||
|
|
||||||
|
export const AppDatePicker = (props: AppDatePickerProps) => {
|
||||||
|
const defaultStyles = useDefaultStyles('light');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DateTimePicker
|
||||||
|
{...props}
|
||||||
|
locale="it"
|
||||||
|
components={{
|
||||||
|
IconPrev: <ChevronLeft size={24} color="#1f2937" pointerEvents="none" />,
|
||||||
|
IconNext: <ChevronRight size={24} color="#1f2937" pointerEvents="none" />,
|
||||||
|
...props.components,
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
...defaultStyles,
|
||||||
|
selected: { backgroundColor: '#1071C2' },
|
||||||
|
...props.styles,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { AlertTriangle, Clock } from 'lucide-react-native';
|
||||||
|
import React from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { AttendanceRecord } from '@/types/types';
|
||||||
|
|
||||||
|
interface AttendanceCardProps {
|
||||||
|
item: AttendanceRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttendanceCard({ item }: AttendanceCardProps) {
|
||||||
|
return (
|
||||||
|
<View className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-col mb-4">
|
||||||
|
<View className="flex-row items-center border-b border-gray-50 pb-3 mb-3">
|
||||||
|
<View className="bg-blue-50 p-4 rounded-full mr-4 flex-shrink-0">
|
||||||
|
<Clock size={24} color="#1071C2" pointerEvents="none"/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 mr-2">
|
||||||
|
<Text className="text-lg font-bold text-primary-dark mb-1 leading-tight uppercase" numberOfLines={2}>
|
||||||
|
{item.constructionSite}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base font-medium text-primary-dark mb-1 leading-tight" numberOfLines={2}>
|
||||||
|
{item.subactivity}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm font-bold text-gray-400">
|
||||||
|
{item.date}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center flex-wrap gap-x-6 gap-y-2 px-1">
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<Text className={`font-bold ${!item.out ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
Entrata: {item.in}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.out && (
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<Text className="font-bold text-green-600">
|
||||||
|
Uscita: {item.out}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!item.out && (
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<AlertTriangle size={16} color="#dc2626" />
|
||||||
|
<Text className="font-bold text-red-600 ml-1">
|
||||||
|
In corso
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react-native';
|
||||||
|
import { TimeOffRequest, TimeOffRequestType } from '@/types/types';
|
||||||
|
|
||||||
|
interface CalendarWidgetProps {
|
||||||
|
events: TimeOffRequest[];
|
||||||
|
types: TimeOffRequestType[];
|
||||||
|
onMonthChange?: (date: Date) => void;
|
||||||
|
initialDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarWidget({ events, types, onMonthChange, initialDate }: CalendarWidgetProps) {
|
||||||
|
const [currentDate, setCurrentDate] = useState(initialDate || new Date());
|
||||||
|
|
||||||
|
// Calendar helpers
|
||||||
|
const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
|
||||||
|
const firstDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getDay(); // 0 = Sun
|
||||||
|
const adjustedFirstDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1; // 0 = Mon
|
||||||
|
|
||||||
|
const weekDays = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
|
||||||
|
|
||||||
|
const changeMonth = (increment: number) => {
|
||||||
|
const newDate = new Date(currentDate.setMonth(currentDate.getMonth() + increment));
|
||||||
|
setCurrentDate(new Date(newDate));
|
||||||
|
if (onMonthChange) {
|
||||||
|
onMonthChange(newDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventForDay = (day: number) => {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dayStr = String(day).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${dayStr}`;
|
||||||
|
|
||||||
|
return events.find(event => {
|
||||||
|
if (!event.start_date) return false;
|
||||||
|
const evtStart = String(event.start_date).split(' ')[0];
|
||||||
|
if (event.timeOffRequestType.name === 'Permesso') return evtStart === dateStr;
|
||||||
|
const evtEnd = event.end_date ? String(event.end_date).split(' ')[0] : evtStart;
|
||||||
|
return dateStr >= evtStart && dateStr <= evtEnd;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-white rounded-[2rem] p-6 shadow-sm border border-gray-100">
|
||||||
|
{/* Month Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-6">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => changeMonth(-1)}
|
||||||
|
className="p-2 bg-gray-50 rounded-full"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} color="#374151" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-xl font-bold text-gray-800 capitalize">
|
||||||
|
{currentDate.toLocaleString('it-IT', { month: 'long', year: 'numeric' })}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => changeMonth(1)}
|
||||||
|
className="p-2 bg-gray-50 rounded-full"
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} color="#374151" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Week Header */}
|
||||||
|
<View className="flex-row justify-between mb-4">
|
||||||
|
{weekDays.map(day => (
|
||||||
|
<Text key={day} className="w-10 text-center text-xs font-bold text-gray-400 uppercase">{day}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Days Grid */}
|
||||||
|
<View className="flex-row flex-wrap gap-y-4">
|
||||||
|
{/* Empty slots for alignment */}
|
||||||
|
{Array.from({ length: adjustedFirstDay }).map((_, i) => (
|
||||||
|
<View key={`empty-${i}`} style={{ width: '14.28%' }} />
|
||||||
|
))}
|
||||||
|
{/* Days */}
|
||||||
|
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||||
|
const day = i + 1;
|
||||||
|
const event = getEventForDay(day);
|
||||||
|
|
||||||
|
let bgClass = 'bg-transparent';
|
||||||
|
let textClass = 'text-gray-700';
|
||||||
|
let borderClass = 'border-transparent';
|
||||||
|
|
||||||
|
const bgColor = event?.timeOffRequestType?.color ? `${event.timeOffRequestType.color}25` : 'transparent';
|
||||||
|
const borderColor = event?.timeOffRequestType?.color || 'transparent';
|
||||||
|
const textColor = event ? event.timeOffRequestType?.color : '#374151';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={day} style={{ width: '14.28%' }} className="items-center">
|
||||||
|
<View className={`w-10 h-10 rounded-full items-center justify-center border`} style={{backgroundColor: bgColor, borderColor: borderColor }}>
|
||||||
|
<Text className={`text-sm ${event ? 'font-bold' : ''}`} style={{ color: textColor }}>{day}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<View className="flex-row flex-wrap justify-center gap-4 mt-8 pt-4 border-t border-gray-100">
|
||||||
|
{types.map((type) => (
|
||||||
|
<View key={type.id} className="flex-row items-center" >
|
||||||
|
<View className={`w-3 h-3 rounded-full mr-2`} style={{ backgroundColor: type.color }} />
|
||||||
|
<Text className="text-sm font-medium text-gray-500">{type.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TouchableOpacity } from 'react-native';
|
||||||
|
import { Camera } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface CameraAddTileProps {
|
||||||
|
onPress: () => void;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CameraAddTile({ onPress, size }: CameraAddTileProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
className="mb-2 bg-blue-50/50 rounded-2xl border-2 border-dashed border-[#1071C2]/40 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Camera size={28} color="#1071C2" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Client } from '@/types/types';
|
||||||
|
import { ChevronDown, Search, X } from 'lucide-react-native';
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Modal, FlatList, Text, TextInput, TouchableOpacity, View, Keyboard, KeyboardEvent, Platform } from 'react-native';
|
||||||
|
|
||||||
|
interface ClientFilterProps {
|
||||||
|
clients: Client[];
|
||||||
|
selectedClientId: any;
|
||||||
|
onClientSelect: (clientId: any) => void;
|
||||||
|
textColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientFilter({ clients, selectedClientId, onClientSelect, textColor }: ClientFilterProps) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
|
// Keyboard height listener
|
||||||
|
useEffect(() => {
|
||||||
|
const showSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||||
|
(e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
|
||||||
|
);
|
||||||
|
const hideSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
||||||
|
() => setKeyboardHeight(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
showSubscription.remove();
|
||||||
|
hideSubscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset search query every time modal is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPicker) {
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
const filteredClients = useMemo(() => {
|
||||||
|
if (!showPicker) return [];
|
||||||
|
if (!searchQuery.trim()) return clients;
|
||||||
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
return clients.filter(c => c.label.toLowerCase().includes(lowerQuery));
|
||||||
|
}, [clients, searchQuery, showPicker]);
|
||||||
|
|
||||||
|
const selectedClientLabel = selectedClientId
|
||||||
|
? clients.find(c => c.id === selectedClientId)?.label || 'Committente Selezionato'
|
||||||
|
: 'Tutti i committenti';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mb-8">
|
||||||
|
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Committente</Text>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPicker(true)}
|
||||||
|
className="flex-1 flex-row items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<Text className={`font-medium text-base flex-1 mr-2 ${selectedClientId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
|
||||||
|
{selectedClientLabel}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{selectedClientId !== null && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onClientSelect(null)}
|
||||||
|
className="bg-gray-50 p-4 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
|
||||||
|
>
|
||||||
|
<X size={22} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Nested Picker Modal */}
|
||||||
|
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowPicker(false)}
|
||||||
|
className="flex-1 bg-black/50 justify-end"
|
||||||
|
style={{ paddingBottom: keyboardHeight }}
|
||||||
|
>
|
||||||
|
<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">Seleziona Committente</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full active:bg-gray-200">
|
||||||
|
<X size={20} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View className="bg-gray-100 flex-row items-center px-4 py-3 rounded-2xl mb-4 border border-gray-200 focus:border-[#1071C2]">
|
||||||
|
<Search size={20} color="#9ca3af" />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-base text-gray-800"
|
||||||
|
placeholder="Cerca committente..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearchQuery('')} className="p-1">
|
||||||
|
<X size={18} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 30 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
data={filteredClients}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
searchQuery.trim() === '' ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedClientId === null ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onClientSelect(null); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedClientId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
Tutti i committenti
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View className="py-8 items-center">
|
||||||
|
<Text className="text-gray-500 font-medium text-center">Nessun committente trovato per "{searchQuery}"</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
initialNumToRender={15}
|
||||||
|
maxToRenderPerBatch={20}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedClientId === item.id ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onClientSelect(item.id); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedClientId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ConstructionSite } from '@/types/types';
|
||||||
|
import { Building2, MapPin } from 'lucide-react-native';
|
||||||
|
import React from 'react';
|
||||||
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface ConstructionSiteCardProps {
|
||||||
|
item: ConstructionSite;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConstructionSiteCard({ item, onPress }: ConstructionSiteCardProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
className="bg-white rounded-3xl p-5 mb-4 shadow-sm border border-slate-100 flex-row items-center active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<View className="bg-primary-50 p-4 rounded-2xl mr-4 shadow-sm">
|
||||||
|
<Building2 size={28} color="#1071C2" pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-lg font-bold text-text uppercase leading-tight">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-start pr-2">
|
||||||
|
<Text className="text-sm font-medium text-slate-500 leading-snug">
|
||||||
|
{item.address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item.client && (
|
||||||
|
<Text className="text-xs font-bold text-slate-400 uppercase tracking-wider leading-relaxed">
|
||||||
|
{item.client}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { DocumentItem } from '@/types/types';
|
||||||
|
import { downloadAndShareDocument } from '@/utils/documentUtils';
|
||||||
|
import { Download, FileText } from 'lucide-react-native';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface DocumentListCardProps {
|
||||||
|
item: DocumentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentListCard({ item }: DocumentListCardProps) {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
await downloadAndShareDocument(item.mimetype, item.filename, item.url);
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-white p-5 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100">
|
||||||
|
<View className="flex-row items-center gap-5 flex-1">
|
||||||
|
<View className="bg-blue-50 p-4 rounded-2xl flex-shrink-0">
|
||||||
|
<FileText size={32} color="#1071C2" pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 mr-2">
|
||||||
|
<Text className="font-bold text-gray-800 text-base leading-tight uppercase" numberOfLines={3}>
|
||||||
|
{item.filename}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row items-center mt-2">
|
||||||
|
<Text className="text-sm text-gray-400 font-bold">{item.date}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownload}
|
||||||
|
disabled={isDownloading}
|
||||||
|
className="p-4 bg-gray-50 rounded-2xl active:bg-gray-100 flex-shrink-0 border border-gray-100"
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size="small" color="#1071C2" />
|
||||||
|
) : (
|
||||||
|
<Download size={24} color="#1071C2" pointerEvents="none" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import SvgChart, { SVGRenderer } from '@wuba/react-native-echarts/svgChart';
|
||||||
|
import { BarChart, LineChart, PieChart } from 'echarts/charts';
|
||||||
|
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
SVGRenderer,
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
PieChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface EchartWrapperProps {
|
||||||
|
option: any;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EchartWrapper({ option, width, height }: EchartWrapperProps) {
|
||||||
|
const chartRef = useRef<any>(null);
|
||||||
|
const instanceRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Chart initialization and disposal
|
||||||
|
useEffect(() => {
|
||||||
|
let chart: any;
|
||||||
|
if (chartRef.current) {
|
||||||
|
chart = echarts.init(chartRef.current, 'light', {
|
||||||
|
renderer: 'svg',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
instanceRef.current = chart;
|
||||||
|
if (option) {
|
||||||
|
chart.setOption(option, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chart?.dispose();
|
||||||
|
instanceRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update options when option prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceRef.current && option) {
|
||||||
|
instanceRef.current.setOption(option, true);
|
||||||
|
}
|
||||||
|
}, [option]);
|
||||||
|
|
||||||
|
// Handle dynamic resize
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceRef.current && width && height) {
|
||||||
|
instanceRef.current.resize({ width, height });
|
||||||
|
}
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
return <SvgChart ref={chartRef} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { FileText, Trash2 } from 'lucide-react-native';
|
||||||
|
import { DocumentPickerAsset } from 'expo-document-picker';
|
||||||
|
|
||||||
|
interface FileAttachmentCardProps {
|
||||||
|
file: DocumentPickerAsset;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileAttachmentCard({ file, onRemove }: FileAttachmentCardProps) {
|
||||||
|
|
||||||
|
// Format size in readable format if available
|
||||||
|
const formatBytes = (bytes?: number) => {
|
||||||
|
if (!bytes) return 'Dimensione sconosciuta';
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center bg-white border border-gray-200 rounded-2xl p-4 mb-3 shadow-sm">
|
||||||
|
{/* File Icon */}
|
||||||
|
<View className="bg-blue-50 p-3 rounded-xl mr-4 border border-blue-100">
|
||||||
|
<FileText size={28} color="#1071C2" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* File Info */}
|
||||||
|
<View className="flex-1 mr-3 justify-center">
|
||||||
|
<Text
|
||||||
|
className="text-gray-800 font-bold text-base mb-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400 font-medium text-sm">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onRemove}
|
||||||
|
className="p-3 bg-red-50 border border-red-100 rounded-xl active:bg-red-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} color="#ef4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { AppDatePicker } from '@/components/AppDatePicker';
|
||||||
|
import ClientFilter from '@/components/ClientFilter';
|
||||||
|
import PlaceFilter from '@/components/PlaceFilter';
|
||||||
|
import SupplierFilter from '@/components/SupplierFilter';
|
||||||
|
import { Client, Place, Supplier } from '@/types/types';
|
||||||
|
import { formatPickerDate } from '@/utils/dateTime';
|
||||||
|
import { X } from 'lucide-react-native';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface FilterModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
places?: Place[];
|
||||||
|
clients?: Client[];
|
||||||
|
currentRange?: { startDate: string | null; endDate: string | null };
|
||||||
|
currentPlace?: Place | null;
|
||||||
|
currentClient?: Client | null;
|
||||||
|
currentSupplier?: Supplier | null;
|
||||||
|
showDate?: boolean;
|
||||||
|
showPlace?: boolean;
|
||||||
|
showClient?: boolean;
|
||||||
|
showSupplier?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (range: { startDate: string | null; endDate: string | null }, place: Place | null, client: Client | null, supplier: Supplier | null) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterModal({
|
||||||
|
visible,
|
||||||
|
places = [],
|
||||||
|
clients = [],
|
||||||
|
currentRange = { startDate: null, endDate: null },
|
||||||
|
currentPlace = null,
|
||||||
|
currentClient = null,
|
||||||
|
currentSupplier = null,
|
||||||
|
showDate = true,
|
||||||
|
showPlace = true,
|
||||||
|
showClient = false,
|
||||||
|
showSupplier = false,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
onReset,
|
||||||
|
}: FilterModalProps) {
|
||||||
|
const [localRange, setLocalRange] = useState<{ startDate: string | null; endDate: string | null }>(currentRange);
|
||||||
|
const [localPlace, setLocalPlace] = useState<any>(currentPlace);
|
||||||
|
const [localClient, setLocalClient] = useState<any>(currentClient);
|
||||||
|
const [localSupplier, setLocalSupplier] = useState<any>(currentSupplier);
|
||||||
|
|
||||||
|
// Sync local state when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setLocalRange(currentRange);
|
||||||
|
setLocalPlace(currentPlace);
|
||||||
|
setLocalClient(currentClient);
|
||||||
|
setLocalSupplier(currentSupplier);
|
||||||
|
}
|
||||||
|
}, [visible, currentRange, currentPlace, currentClient, currentSupplier]);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onApply(localRange, localPlace, localClient, localSupplier);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
onReset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType="slide"
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-black/60 justify-end sm:justify-center">
|
||||||
|
<View className="bg-white w-full rounded-t-[2.5rem] p-6 shadow-2xl max-h-[90%]">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-6">
|
||||||
|
<Text className="text-2xl font-bold text-gray-800">Filtra Risultati</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
|
||||||
|
<X size={24} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
<View className="space-y-6">
|
||||||
|
{/* Date Range Selection */}
|
||||||
|
{showDate && (
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="text-lg font-bold text-gray-700 mb-3">Periodo</Text>
|
||||||
|
<AppDatePicker
|
||||||
|
mode="range"
|
||||||
|
startDate={localRange.startDate}
|
||||||
|
endDate={localRange.endDate}
|
||||||
|
onChange={(params: any) => {
|
||||||
|
setLocalRange({
|
||||||
|
startDate: params.startDate ? formatPickerDate(params.startDate) : null,
|
||||||
|
endDate: params.endDate ? formatPickerDate(params.endDate) : null
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Place Selection */}
|
||||||
|
{showPlace && (
|
||||||
|
<PlaceFilter
|
||||||
|
places={places}
|
||||||
|
selectedPlaceId={localPlace}
|
||||||
|
onPlaceSelect={setLocalPlace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Client Selection */}
|
||||||
|
{showClient && (
|
||||||
|
<ClientFilter
|
||||||
|
clients={clients}
|
||||||
|
selectedClientId={localClient}
|
||||||
|
onClientSelect={setLocalClient}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supplier Selection */}
|
||||||
|
{showSupplier && (
|
||||||
|
<SupplierFilter
|
||||||
|
selectedSupplierId={localSupplier}
|
||||||
|
onSupplierSelect={setLocalSupplier}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleReset}
|
||||||
|
className="flex-1 py-4 bg-gray-200 rounded-2xl shadow-sm active:bg-gray-300"
|
||||||
|
>
|
||||||
|
<Text className="text-gray-700 text-center font-bold text-lg">Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleApply}
|
||||||
|
className="flex-1 py-4 bg-[#1071C2] rounded-2xl shadow-lg active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-center font-bold text-lg">Applica Filtri</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { ChevronDown, Search, X } from 'lucide-react-native';
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Modal, FlatList, Text, TextInput, TouchableOpacity, View, Keyboard, KeyboardEvent, Platform } from 'react-native';
|
||||||
|
|
||||||
|
interface DropdownOption {
|
||||||
|
id: any;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenericDropdownProps {
|
||||||
|
options: DropdownOption[];
|
||||||
|
selectedId: any;
|
||||||
|
onSelect: (id: any) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
showSearch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenericDropdown({
|
||||||
|
options,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
placeholder = 'Seleziona un\'opzione...',
|
||||||
|
searchPlaceholder = 'Cerca...',
|
||||||
|
showSearch = true
|
||||||
|
}: GenericDropdownProps) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
|
// Keyboard height listener
|
||||||
|
useEffect(() => {
|
||||||
|
const showSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||||
|
(e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
|
||||||
|
);
|
||||||
|
const hideSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
||||||
|
() => setKeyboardHeight(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
showSubscription.remove();
|
||||||
|
hideSubscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset search query every time modal is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPicker) {
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
if (!showPicker) return [];
|
||||||
|
if (!searchQuery.trim()) return options;
|
||||||
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
return options.filter(o => o.label.toLowerCase().includes(lowerQuery));
|
||||||
|
}, [options, searchQuery, showPicker]);
|
||||||
|
|
||||||
|
const selectedLabel = selectedId !== null && selectedId !== undefined
|
||||||
|
? options.find(o => o.id === selectedId)?.label || placeholder
|
||||||
|
: placeholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPicker(true)}
|
||||||
|
className="flex-1 flex-row items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<Text className={`font-medium text-base flex-1 mr-2 ${selectedId !== null && selectedId !== undefined ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
|
||||||
|
{selectedLabel}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{selectedId !== null && selectedId !== undefined && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onSelect(null)}
|
||||||
|
className="bg-gray-50 p-4 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
|
||||||
|
>
|
||||||
|
<X size={22} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowPicker(false)}
|
||||||
|
className="flex-1 bg-black/50 justify-end"
|
||||||
|
style={{ paddingBottom: keyboardHeight }}
|
||||||
|
>
|
||||||
|
<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">Seleziona</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full active:bg-gray-200">
|
||||||
|
<X size={20} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
{showSearch && (
|
||||||
|
<View className="bg-gray-100 flex-row items-center px-4 py-3 rounded-2xl mb-4 border border-gray-200 focus:border-[#1071C2]">
|
||||||
|
<Search size={20} color="#9ca3af" />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-base text-gray-800"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearchQuery('')} className="p-1">
|
||||||
|
<X size={18} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 30 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
data={filteredOptions}
|
||||||
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
searchQuery.trim() === '' ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedId === null || selectedId === undefined ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onSelect(null); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedId === null || selectedId === undefined ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
Nessuna selezione
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View className="py-8 items-center">
|
||||||
|
<Text className="text-gray-500 font-medium text-center">Nessun risultato trovato</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
initialNumToRender={15}
|
||||||
|
maxToRenderPerBatch={20}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedId === item.id ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onSelect(item.id); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { InvoiceItem } from '@/types/types';
|
||||||
|
import { ArrowRight, FileText, MapPin } from 'lucide-react-native';
|
||||||
|
import React from 'react';
|
||||||
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface InvoiceCardProps {
|
||||||
|
item: InvoiceItem;
|
||||||
|
onPress: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceCard({ item, onPress }: InvoiceCardProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onPress(item.id)}
|
||||||
|
className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-row justify-between items-center active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-4 flex-1">
|
||||||
|
<View className="p-4 rounded-2xl bg-blue-50">
|
||||||
|
<FileText size={24} color="#1071C2" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="mb-0.5">
|
||||||
|
<Text className="text-gray-500 text-xs font-bold uppercase mt-1">N° {item.documentNumber} del {item.date}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="font-bold text-gray-800 text-lg mb-1" numberOfLines={1}>{item.supplier}</Text>
|
||||||
|
<View className="flex-row items-center justify-between mt-1 gap-2">
|
||||||
|
<View className="flex-row items-center gap-1 flex-1">
|
||||||
|
<MapPin size={12} color="#8F9BB3" />
|
||||||
|
<Text className="text-[#8F9BB3] text-xs font-medium flex-1" numberOfLines={1}>{item.placeName}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="font-bold text-[#1071C2] text-base leading-tight">{item.totalAmount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="ml-3">
|
||||||
|
<ArrowRight size={20} color="#D1D5DB" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(InvoiceCard);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { View, Text, ActivityIndicator } from 'react-native';
|
||||||
|
|
||||||
|
export default function LoadingScreen() {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 justify-center items-center bg-gray-50">
|
||||||
|
<ActivityIndicator size="large" color="#1071C2" />
|
||||||
|
<Text className="text-gray-500 mt-2">Caricamento...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { WifiOff } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface OfflineScreenProps {
|
||||||
|
onRetry: () => void;
|
||||||
|
isRetrying?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OfflineScreen({ onRetry, isRetrying = false }: OfflineScreenProps) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
|
{/* Icon */}
|
||||||
|
<View className="bg-gray-100 p-6 rounded-full mb-6">
|
||||||
|
<WifiOff size={64} className="text-gray-400" pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-2xl font-bold text-gray-800 mb-2 text-center">
|
||||||
|
Sei Offline
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-base text-gray-500 text-center mb-10 leading-6">
|
||||||
|
Sembra che non ci sia connessione a internet.{'\n'}Controlla il Wi-Fi o i dati mobili e riprova.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Retry Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className={`flex-row items-center justify-center w-full py-4 rounded-[2rem] gap-4 ${
|
||||||
|
isRetrying ? 'bg-gray-300' : 'bg-[#1071C2] active:opacity-90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold text-lg">
|
||||||
|
{isRetrying ? 'Controllo...' : 'Riprova'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Place } from '@/types/types';
|
||||||
|
import { ChevronDown, Search, X } from 'lucide-react-native';
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Modal, FlatList, Text, TextInput, TouchableOpacity, View, Keyboard, KeyboardEvent, Platform } from 'react-native';
|
||||||
|
|
||||||
|
interface PlaceFilterProps {
|
||||||
|
places: Place[];
|
||||||
|
selectedPlaceId: any;
|
||||||
|
onPlaceSelect: (placeId: any) => void;
|
||||||
|
textColor?: string; // Optional prop for text color
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaceFilter({ places, selectedPlaceId, onPlaceSelect, textColor }: PlaceFilterProps) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
|
||||||
|
// Keyboard height listener
|
||||||
|
useEffect(() => {
|
||||||
|
const showSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||||
|
(e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
|
||||||
|
);
|
||||||
|
const hideSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
||||||
|
() => setKeyboardHeight(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
showSubscription.remove();
|
||||||
|
hideSubscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset search query every time modal is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPicker) {
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
const filteredPlaces = useMemo(() => {
|
||||||
|
if (!showPicker) return [];
|
||||||
|
if (!searchQuery.trim()) return places;
|
||||||
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
return places.filter(p => p.label.toLowerCase().includes(lowerQuery));
|
||||||
|
}, [places, searchQuery, showPicker]);
|
||||||
|
|
||||||
|
const selectedPlaceLabel = selectedPlaceId
|
||||||
|
? places.find(p => p.id === selectedPlaceId)?.label || 'Cantiere Selezionato'
|
||||||
|
: 'Tutti i cantieri';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mb-8">
|
||||||
|
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Cantiere</Text>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPicker(true)}
|
||||||
|
className="flex-1 flex-row items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<Text className={`font-medium text-base flex-1 mr-2 ${selectedPlaceId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
|
||||||
|
{selectedPlaceLabel}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{selectedPlaceId !== null && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onPlaceSelect(null)}
|
||||||
|
className="bg-gray-50 p-4 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
|
||||||
|
>
|
||||||
|
<X size={22} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Nested Place Picker Modal */}
|
||||||
|
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowPicker(false)}
|
||||||
|
className="flex-1 bg-black/50 justify-end"
|
||||||
|
style={{ paddingBottom: keyboardHeight }}
|
||||||
|
>
|
||||||
|
<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">Seleziona Cantiere</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full active:bg-gray-200">
|
||||||
|
<X size={20} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View className="bg-gray-100 flex-row items-center px-4 py-3 rounded-2xl mb-4 border border-gray-200 focus:border-[#1071C2]">
|
||||||
|
<Search size={20} color="#9ca3af" />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-base text-gray-800"
|
||||||
|
placeholder="Cerca cantiere..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearchQuery('')} className="p-1">
|
||||||
|
<X size={18} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 30 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
data={filteredPlaces}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
searchQuery.trim() === '' ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedPlaceId === null ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onPlaceSelect(null); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedPlaceId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
Tutti i cantieri
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View className="py-8 items-center">
|
||||||
|
<Text className="text-gray-500 font-medium text-center">Nessun cantiere trovato per "{searchQuery}"</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
initialNumToRender={15}
|
||||||
|
maxToRenderPerBatch={20}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedPlaceId === item.id ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onPlaceSelect(item.id); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedPlaceId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { View, Text, Modal, TouchableOpacity, Vibration, StyleSheet, useWindowDimensions } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { X, ScanLine } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface QrScanModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onScan: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QrScanModal({ visible, onClose, onScan }: QrScanModalProps) {
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
const scanInProgress = useRef(false);
|
||||||
|
const { width, height } = useWindowDimensions();
|
||||||
|
const squareSize = Math.min(width * 0.8, height * 0.8, 400);
|
||||||
|
|
||||||
|
// Permission handling and scanned state reset on modal open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
setScanned(false);
|
||||||
|
scanInProgress.current = false;
|
||||||
|
if (permission && !permission.granted && permission.canAskAgain) {
|
||||||
|
requestPermission();
|
||||||
|
}
|
||||||
|
}, [visible, permission]);
|
||||||
|
|
||||||
|
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
|
||||||
|
if (scanInProgress.current) return;
|
||||||
|
scanInProgress.current = true;
|
||||||
|
|
||||||
|
setScanned(true);
|
||||||
|
Vibration.vibrate();
|
||||||
|
console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
|
||||||
|
onScan(data);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return <View />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="fullScreen"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-black">
|
||||||
|
{/* Camera Full Screen */}
|
||||||
|
<CameraView
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
facing="back"
|
||||||
|
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||||
|
barcodeScannerSettings={{
|
||||||
|
barcodeTypes: ['qr'],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mask Overlay with transparent cutout */}
|
||||||
|
<View style={StyleSheet.absoluteFillObject} pointerEvents="none">
|
||||||
|
<View className="flex-1 bg-black/60" />
|
||||||
|
<View style={{ height: squareSize }} className="flex-row">
|
||||||
|
<View className="flex-1 bg-black/60" />
|
||||||
|
|
||||||
|
{/* Scanner Target Frame */}
|
||||||
|
<View
|
||||||
|
style={{ width: squareSize, height: squareSize }}
|
||||||
|
className="border-2 border-[#1071C2] justify-center items-center relative"
|
||||||
|
>
|
||||||
|
<View className="absolute top-0 left-0 w-6 h-6 border-l-4 border-t-4 border-[#1071C2]" />
|
||||||
|
<View className="absolute top-0 right-0 w-6 h-6 border-r-4 border-t-4 border-[#1071C2]" />
|
||||||
|
<View className="absolute bottom-0 left-0 w-6 h-6 border-l-4 border-b-4 border-[#1071C2]" />
|
||||||
|
<View className="absolute bottom-0 right-0 w-6 h-6 border-r-4 border-b-4 border-[#1071C2]" />
|
||||||
|
|
||||||
|
{!scanned && <ScanLine color="#1071C2" size={40} />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 bg-black/60" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 bg-black/60" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* UI Overlay */}
|
||||||
|
<SafeAreaView style={StyleSheet.absoluteFillObject} pointerEvents="box-none">
|
||||||
|
<View className="flex-1 justify-between pt-8">
|
||||||
|
{/* Header */}
|
||||||
|
<View className="items-center">
|
||||||
|
<Text className="text-white text-xl font-bold">
|
||||||
|
Scansiona QR Code
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-300 text-base mt-1">
|
||||||
|
Inquadra il codice nel riquadro
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View className="items-center pb-12">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
className="bg-white/20 p-4 rounded-full"
|
||||||
|
>
|
||||||
|
<X color="white" size={32} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text className="text-white mt-4 font-medium">
|
||||||
|
Chiudi
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import { CheckCircle2, AlertTriangle, Briefcase, Calendar, MapPin } from 'lucide-react-native';
|
||||||
|
import { QualityControlItem } from '@/types/types';
|
||||||
|
|
||||||
|
interface QualityControlCardProps {
|
||||||
|
item: QualityControlItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QualityControlCard({ item }: QualityControlCardProps) {
|
||||||
|
const isSuccess = item.result === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-white rounded-3xl p-5 mb-4 shadow-sm border border-gray-100 flex-row items-center gap-4">
|
||||||
|
|
||||||
|
{/* Icon Status */}
|
||||||
|
<View className={`w-14 h-14 rounded-full items-center justify-center shadow-sm ${isSuccess ? 'bg-green-100 shadow-green-200' : 'bg-red-100 shadow-red-200'}`}>
|
||||||
|
{isSuccess ? (
|
||||||
|
<CheckCircle2 size={32} color="#16a34a" pointerEvents="none" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle size={32} color="#dc2626" pointerEvents="none" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View className="flex-1 justify-center">
|
||||||
|
<Text
|
||||||
|
className="text-lg font-bold text-gray-800 uppercase leading-tight mb-1"
|
||||||
|
numberOfLines={2}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
>
|
||||||
|
{item.constructionSite}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-1 mb-1.5 mt-0.5">
|
||||||
|
<MapPin size={14} color="#6b7280" />
|
||||||
|
<Text
|
||||||
|
className="text-sm font-medium text-gray-500 leading-snug flex-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
>
|
||||||
|
{item.subactivity}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Calendar size={14} color="#9ca3af" />
|
||||||
|
<Text className="text-sm font-semibold text-gray-400">
|
||||||
|
{item.date}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, TouchableOpacity } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { X } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface RemovablePhotoTileProps {
|
||||||
|
uri: string;
|
||||||
|
onRemove: () => void;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RemovablePhotoTile({ uri, onRemove, size }: RemovablePhotoTileProps) {
|
||||||
|
return (
|
||||||
|
<View style={{ width: size, height: size }} className="mb-2">
|
||||||
|
<View className="bg-gray-100 rounded-2xl overflow-hidden border border-gray-200" style={{ flex: 1 }}>
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onRemove}
|
||||||
|
className="absolute top-[-6px] right-[-6px] bg-red-500 rounded-full p-1 border-2 border-white shadow-sm"
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<X size={14} color="white" strokeWidth={3} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useAlert } from '@/components/AlertComponent';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, Modal, TouchableOpacity, TextInput, ScrollView, Platform } from 'react-native';
|
||||||
|
import { TimeOffRequestType } from '@/types/types';
|
||||||
|
import { X } from 'lucide-react-native';
|
||||||
|
import { TimePickerModal } from './TimePickerModal';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { formatPickerDate } from '@/utils/dateTime';
|
||||||
|
import { AppDatePicker } from '@/components/AppDatePicker';
|
||||||
|
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
||||||
|
|
||||||
|
interface RequestPermitModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
types: TimeOffRequestType[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RequestPermitModal({ visible, types, onClose, onSubmit }: RequestPermitModalProps) {
|
||||||
|
const alert = useAlert();
|
||||||
|
const [type, setType] = useState<TimeOffRequestType>(types[0]); // Default to first type
|
||||||
|
const [date, setDate] = useState<string | null>();
|
||||||
|
const [range, setRange] = useState<{
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
}>({ startDate: null, endDate: null });
|
||||||
|
|
||||||
|
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||||
|
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||||
|
const [startTime, setStartTime] = useState('');
|
||||||
|
const [endTime, setEndTime] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
// Clean up function to reset all fields
|
||||||
|
const clearCalendar = () => {
|
||||||
|
setDate(null);
|
||||||
|
setRange({ startDate: null, endDate: null });
|
||||||
|
setStartTime(''); setEndTime('');
|
||||||
|
setMessage('');
|
||||||
|
setType(types[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to validate the request
|
||||||
|
function validateRequest(type: TimeOffRequestType, date: string | null | undefined, range: { startDate: string | null; endDate: string | null }, startTime: string, endTime: string): string | null {
|
||||||
|
if (!type) return "Seleziona una tipologia di assenza.";
|
||||||
|
|
||||||
|
if (type.time_required === 0) {
|
||||||
|
if (!range.startDate) return "Seleziona una data di inizio.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!date) return "Seleziona una data.";
|
||||||
|
if (!startTime || !endTime) return "Seleziona gli orari.";
|
||||||
|
if (startTime >= endTime) return "L'orario di fine deve essere successivo a quello di inizio.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to send the request to the API
|
||||||
|
const saveRequest = async (requestData: any) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/time-off-request/add', requestData);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
alert.showAlert('success', 'Successo', response.data.message || 'La tua richiesta è stata inviata con successo.');
|
||||||
|
onSubmit(requestData);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
alert.showAlert('error', 'Errore', response.data.message || 'Impossibile inviare la richiesta.');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Errore nell\'invio della richiesta:', error);
|
||||||
|
throw new Error('Impossibile inviare la richiesta.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to submit the request
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const error = validateRequest(type, date, range, startTime, endTime);
|
||||||
|
if (error) {
|
||||||
|
alert.showAlert("error", "Errore", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the interval based on the type of request
|
||||||
|
let interval = null;
|
||||||
|
if (type.time_required === 0) {
|
||||||
|
if (range.startDate) {
|
||||||
|
interval = range.startDate;
|
||||||
|
}
|
||||||
|
if (range.endDate && range.endDate !== range.startDate) {
|
||||||
|
if (interval) {
|
||||||
|
interval += ',';
|
||||||
|
}
|
||||||
|
interval += range.endDate;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
interval = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the request data object
|
||||||
|
const requestData = {
|
||||||
|
id_type: type.id,
|
||||||
|
interval: interval,
|
||||||
|
startTime: type.time_required === 1 ? startTime : null,
|
||||||
|
endTime: type.time_required === 1 ? endTime : null,
|
||||||
|
message: message ? message : null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveRequest(requestData);
|
||||||
|
} catch (e) {
|
||||||
|
alert.showAlert("error", "Errore", "Impossibile inviare la richiesta.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={true}
|
||||||
|
animationType="slide"
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-black/60 justify-end sm:justify-center">
|
||||||
|
<View className="bg-white w-full rounded-t-[2.5rem] p-6 shadow-2xl h-[85%] sm:h-auto">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-6">
|
||||||
|
<Text className="text-2xl font-bold text-gray-800">Nuova Richiesta</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
|
||||||
|
<X size={24} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<KeyboardAwareScrollView
|
||||||
|
bottomOffset={Platform.OS === 'ios' ? 50 : 80}
|
||||||
|
disableScrollOnKeyboardHide={false}
|
||||||
|
enabled={true}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 70,
|
||||||
|
flexGrow: 1
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<View className="space-y-6">
|
||||||
|
{/* Permit Type */}
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className="text-lg font-bold text-gray-700 mb-3">Tipologia Assenza</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 0, gap: 12 }}
|
||||||
|
>
|
||||||
|
{types.map((t) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t.id}
|
||||||
|
onPress={() => setType(t)}
|
||||||
|
style={type?.id === t.id ? { borderColor: t.color, backgroundColor: `${t.color}15` } : {}}
|
||||||
|
className={`py-4 px-5 rounded-xl border-2 items-center justify-center ${type?.id !== t.id ? 'border-gray-100 bg-white' : ''}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={type?.id === t.id ? { color: t.color } : {}}
|
||||||
|
className={`text-sm font-bold ${type?.id !== t.id ? 'text-gray-500' : ''}`}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date and Time Selection */}
|
||||||
|
{type?.time_required === 0 ? (
|
||||||
|
<AppDatePicker
|
||||||
|
mode="range"
|
||||||
|
startDate={range.startDate}
|
||||||
|
endDate={range.endDate}
|
||||||
|
onChange={(params) => {
|
||||||
|
setRange({
|
||||||
|
startDate: params.startDate ? formatPickerDate(params.startDate) : null,
|
||||||
|
endDate: params.endDate ? formatPickerDate(params.endDate) : null
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AppDatePicker
|
||||||
|
mode="single"
|
||||||
|
date={date}
|
||||||
|
onChange={({ date }) => setDate(date ? formatPickerDate(date) : null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='flex-column bg-gray-50 rounded-xl border border-gray-100 mb-6'>
|
||||||
|
{type?.time_required === 1 && (
|
||||||
|
<View>
|
||||||
|
<View className="flex-row gap-4 p-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-bold text-gray-700 mb-2 uppercase">Dalle Ore</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowStartPicker(true)}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="09:00"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
className="w-full p-3 bg-white rounded-lg border border-gray-200 font-bold text-gray-800 text-center"
|
||||||
|
value={startTime}
|
||||||
|
onChangeText={setStartTime}
|
||||||
|
editable={false}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-sm font-bold text-gray-700 mb-2 uppercase">Alle Ore</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowEndPicker(true)}>
|
||||||
|
<TextInput
|
||||||
|
placeholder="18:00"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
className="w-full p-3 bg-white rounded-lg border border-gray-200 font-bold text-gray-800 text-center"
|
||||||
|
value={endTime}
|
||||||
|
onChangeText={setEndTime}
|
||||||
|
editable={false}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TimePickerModal
|
||||||
|
visible={showStartPicker}
|
||||||
|
initialDate={new Date()}
|
||||||
|
title="Seleziona Ora Inizio"
|
||||||
|
onConfirm={(time) => setStartTime(time)}
|
||||||
|
onClose={() => setShowStartPicker(false)}
|
||||||
|
/>
|
||||||
|
<TimePickerModal
|
||||||
|
visible={showEndPicker}
|
||||||
|
initialDate={new Date()}
|
||||||
|
title="Seleziona Ora Fine"
|
||||||
|
onConfirm={(time) => setEndTime(time)}
|
||||||
|
onClose={() => setShowEndPicker(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reason field */}
|
||||||
|
<View className="p-4 pt-2">
|
||||||
|
<Text className="text-sm font-bold text-gray-700 mb-2 uppercase">Motivo</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="(opzionale)"
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
className="w-full px-3 py-3 bg-white font-bold text-gray-800 rounded-lg border border-gray-200"
|
||||||
|
textAlignVertical="top"
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
clearCalendar();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex-1 py-4 bg-gray-200 rounded-2xl shadow-sm active:bg-gray-300"
|
||||||
|
>
|
||||||
|
<Text className="text-gray-700 text-center font-bold text-lg">Annulla Richiesta</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSubmit}
|
||||||
|
className="flex-1 py-4 bg-[#1071C2] rounded-2xl shadow-lg active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-center font-bold text-lg">Invia Richiesta</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAwareScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
Keyboard,
|
||||||
|
ScrollView
|
||||||
|
} from 'react-native';
|
||||||
|
import { X } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface SetDescriptionModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
initialDescription: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (desc: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SetDescriptionModal({
|
||||||
|
visible,
|
||||||
|
initialDescription,
|
||||||
|
onClose,
|
||||||
|
onSave
|
||||||
|
}: SetDescriptionModalProps) {
|
||||||
|
const [desc, setDesc] = useState(initialDescription);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setDesc(initialDescription || '');
|
||||||
|
}
|
||||||
|
}, [visible, initialDescription]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
statusBarTranslucent
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<View className="flex-1 bg-black/60 justify-end">
|
||||||
|
|
||||||
|
{/* Backdrop */}
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 w-full"
|
||||||
|
onPress={() => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
activeOpacity={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="bg-white w-full rounded-t-[2.5rem] p-6 shadow-2xl max-h-[85%]">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-6">
|
||||||
|
<Text className="text-2xl font-bold text-gray-800">Descrizione Foto</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
|
||||||
|
<X size={24} color="#4b5563" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Text Area with Scroll */}
|
||||||
|
<ScrollView
|
||||||
|
className="mb-6"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
bounces={false}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
className="bg-gray-50 border border-gray-200 rounded-2xl p-4 text-base text-gray-800"
|
||||||
|
placeholder="Inserisci una descrizione... (max 250 caratteri)"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
multiline
|
||||||
|
maxLength={250}
|
||||||
|
value={desc}
|
||||||
|
onChangeText={setDesc}
|
||||||
|
style={{ minHeight: 120, maxHeight: 200, textAlignVertical: 'top' }}
|
||||||
|
/>
|
||||||
|
<Text className={`text-right mt-2 text-sm font-medium ${desc.length >= 250 ? 'text-red-500' : 'text-gray-500'}`}>
|
||||||
|
{desc.length}/250
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View className="flex-row gap-4 mb-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
className="flex-1 py-4 bg-gray-200 rounded-2xl shadow-sm active:bg-gray-300"
|
||||||
|
>
|
||||||
|
<Text className="text-gray-700 text-center font-bold text-lg">Annulla</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onSave(desc)}
|
||||||
|
className="flex-1 py-4 rounded-2xl shadow-lg active:scale-[0.98] bg-[#1071C2]"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-center font-bold text-lg">Salva</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Supplier } from '@/types/types';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import { ChevronDown, Search, X } from 'lucide-react-native';
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Modal, FlatList, Text, TextInput, TouchableOpacity, View, Keyboard, KeyboardEvent, Platform, InteractionManager, ActivityIndicator } from 'react-native';
|
||||||
|
|
||||||
|
interface SupplierFilterProps {
|
||||||
|
selectedSupplierId: any;
|
||||||
|
onSupplierSelect: (supplierId: any) => void;
|
||||||
|
textColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupplierFilter({ selectedSupplierId, onSupplierSelect, textColor }: SupplierFilterProps) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||||
|
const [isFetching, setIsFetching] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/registry/get-suppliers');
|
||||||
|
if (res.data?.success) {
|
||||||
|
setSuppliers(res.data.suppliers || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching suppliers:', err);
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay the heavy API call and state update until the modal animation finishes
|
||||||
|
InteractionManager.runAfterInteractions(() => {
|
||||||
|
fetchSuppliers();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard height listener
|
||||||
|
useEffect(() => {
|
||||||
|
const showSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
||||||
|
(e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
|
||||||
|
);
|
||||||
|
const hideSubscription = Keyboard.addListener(
|
||||||
|
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
|
||||||
|
() => setKeyboardHeight(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
showSubscription.remove();
|
||||||
|
hideSubscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset search query every time modal is opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPicker) {
|
||||||
|
setSearchQuery('');
|
||||||
|
}
|
||||||
|
}, [showPicker]);
|
||||||
|
|
||||||
|
const filteredSuppliers = useMemo(() => {
|
||||||
|
if (!showPicker) return [];
|
||||||
|
if (!searchQuery.trim()) return suppliers;
|
||||||
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
return suppliers.filter(s => s.label.toLowerCase().includes(lowerQuery));
|
||||||
|
}, [suppliers, searchQuery, showPicker]);
|
||||||
|
|
||||||
|
const selectedSupplierLabel = selectedSupplierId
|
||||||
|
? suppliers.find(s => s.code === selectedSupplierId)?.label || 'Fornitore Selezionato'
|
||||||
|
: 'Tutti i fornitori';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mb-8">
|
||||||
|
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Fornitore</Text>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPicker(true)}
|
||||||
|
className="flex-1 flex-row items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<Text className={`font-medium text-base flex-1 mr-2 ${selectedSupplierId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
|
||||||
|
{selectedSupplierLabel}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{selectedSupplierId !== null && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onSupplierSelect(null)}
|
||||||
|
className="bg-gray-50 p-4 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
|
||||||
|
>
|
||||||
|
<X size={22} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Nested Place Picker Modal */}
|
||||||
|
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowPicker(false)}
|
||||||
|
className="flex-1 bg-black/50 justify-end"
|
||||||
|
style={{ paddingBottom: keyboardHeight }}
|
||||||
|
>
|
||||||
|
<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">Seleziona Fornitore</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full active:bg-gray-200">
|
||||||
|
<X size={20} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View className="bg-gray-100 flex-row items-center px-4 py-3 rounded-2xl mb-4 border border-gray-200 focus:border-[#1071C2]">
|
||||||
|
<Search size={20} color="#9ca3af" />
|
||||||
|
<TextInput
|
||||||
|
className="flex-1 ml-3 text-base text-gray-800"
|
||||||
|
placeholder="Cerca fornitore..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{searchQuery.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => setSearchQuery('')} className="p-1">
|
||||||
|
<X size={18} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 30 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
data={filteredSuppliers}
|
||||||
|
keyExtractor={(item, index) => item.code?.toString() ?? `no-code-${index}`}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
searchQuery.trim() === '' ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedSupplierId === null ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onSupplierSelect(null); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedSupplierId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
Tutti i fornitori
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={() => (
|
||||||
|
<View className="py-8 items-center">
|
||||||
|
{isFetching ? (
|
||||||
|
<ActivityIndicator size="large" color="#1071C2" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-gray-500 font-medium text-center">Nessun fornitore trovato per "{searchQuery}"</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
initialNumToRender={15}
|
||||||
|
maxToRenderPerBatch={20}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedSupplierId === item.code ? 'bg-blue-50' : ''}`}
|
||||||
|
onPress={() => { onSupplierSelect(item.code); setShowPicker(false); }}
|
||||||
|
>
|
||||||
|
<Text className={`text-lg ${selectedSupplierId === item.code ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal, View, TouchableOpacity, Text } from 'react-native';
|
||||||
|
import DateTimePicker, { DateType, useDefaultStyles } from 'react-native-ui-datepicker';
|
||||||
|
import { X } from 'lucide-react-native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface TimePickerModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
initialDate?: DateType;
|
||||||
|
title?: string;
|
||||||
|
onConfirm: (time: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TimePickerModal = ({ visible, initialDate, title, onConfirm, onClose }: TimePickerModalProps) => {
|
||||||
|
const defaultStyles = useDefaultStyles('light');
|
||||||
|
const [selectedDate, setSelectedDate] = useState<DateType>(initialDate || new Date());
|
||||||
|
|
||||||
|
const formatTime = (date?: DateType | null) => {
|
||||||
|
if (!date) return "00:00";
|
||||||
|
date = dayjs(date);
|
||||||
|
const hour = date?.hour().toString().padStart(2, "0") ?? "00";
|
||||||
|
const minute = date?.minute().toString().padStart(2, "0") ?? "00";
|
||||||
|
return `${hour}:${minute}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const time = formatTime(selectedDate);
|
||||||
|
console.log("Selected time:", time);
|
||||||
|
onConfirm(time);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade">
|
||||||
|
<View className="flex-1 justify-center items-center bg-black/50">
|
||||||
|
<View className="bg-white rounded-xl p-4 w-[90%] max-h-[400px]">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-4">
|
||||||
|
<Text className="text-lg font-bold text-gray-800">{title}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
|
||||||
|
<X size={20} color="#4b5563" pointerEvents="none" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* TimePicker */}
|
||||||
|
<DateTimePicker
|
||||||
|
mode="single"
|
||||||
|
timePicker
|
||||||
|
date={selectedDate}
|
||||||
|
initialView="time"
|
||||||
|
hideHeader
|
||||||
|
containerHeight={200}
|
||||||
|
styles={defaultStyles}
|
||||||
|
onChange={(d) => setSelectedDate(d.date || new Date())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Confirm Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleConfirm}
|
||||||
|
className="mt-4 w-full py-3 bg-[#1071C2] rounded-xl shadow-lg active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-center font-bold text-lg">Applica</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { CloudDownload } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface UpdateScreenProps {
|
||||||
|
onUpdate: () => void;
|
||||||
|
isOpeningStore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdateScreen({ onUpdate, isOpeningStore = false }: UpdateScreenProps) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
|
{/* Icon */}
|
||||||
|
<View className="bg-blue-50 p-6 rounded-full mb-6">
|
||||||
|
<CloudDownload size={64} className="text-[#1071C2]" pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-2xl font-bold text-gray-800 mb-2 text-center">
|
||||||
|
Aggiornamento Richiesto
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-base text-gray-500 text-center mb-10 leading-6">
|
||||||
|
È disponibile una nuova versione dell'applicazione.{'\n'}Per continuare a utilizzarla è necessario effettuare l'aggiornamento.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Update Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onUpdate}
|
||||||
|
disabled={isOpeningStore}
|
||||||
|
className={`flex-row items-center justify-center w-full py-4 rounded-[2rem] gap-4 ${
|
||||||
|
isOpeningStore ? 'bg-gray-300' : 'bg-[#1071C2] active:opacity-90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold text-lg">
|
||||||
|
{isOpeningStore ? 'Apertura store...' : 'Aggiorna Ora'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.32.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ['dist/*'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { withNativeWind } = require('nativewind/metro');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname)
|
||||||
|
|
||||||
|
const ALIASES = {
|
||||||
|
tslib: path.resolve(__dirname, "node_modules/tslib/tslib.es6.js"),
|
||||||
|
};
|
||||||
|
|
||||||
|
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||||
|
return context.resolveRequest(
|
||||||
|
context,
|
||||||
|
ALIASES[moduleName] ?? moduleName,
|
||||||
|
platform
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: './global.css' })
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"name": "ipcostruzioni_app",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.7.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"tslib": "^2.6.1",
|
||||||
|
"zrender": "5.5.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
|
"@react-navigation/elements": "^2.6.3",
|
||||||
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@wuba/react-native-echarts": "^3.1.1",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"babel-preset-expo": "~54.0.10",
|
||||||
|
"echarts": "^5.5.0",
|
||||||
|
"expo": "~54.0.36",
|
||||||
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-constants": "~18.0.10",
|
||||||
|
"expo-dev-client": "~6.0.20",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
|
"expo-file-system": "~19.0.23",
|
||||||
|
"expo-font": "~14.0.12",
|
||||||
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-image": "~3.0.10",
|
||||||
|
"expo-image-picker": "~17.0.11",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-router": "~6.0.24",
|
||||||
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-sharing": "~14.0.8",
|
||||||
|
"expo-splash-screen": "~31.0.11",
|
||||||
|
"expo-status-bar": "~3.0.8",
|
||||||
|
"expo-symbols": "~1.0.7",
|
||||||
|
"expo-system-ui": "~6.0.8",
|
||||||
|
"expo-web-browser": "~15.0.9",
|
||||||
|
"lucide-react-native": "^0.563.0",
|
||||||
|
"nativewind": "^4.2.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-image-viewing": "^0.2.2",
|
||||||
|
"react-native-keyboard-controller": "1.18.5",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-ui-datepicker": "^3.1.2",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"tailwindcss": "^3.4.18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"expo": {
|
||||||
|
"doctor": {
|
||||||
|
"reactNativeDirectoryCheck": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native-nfc-manager"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
// NOTE: Update this to include the paths to all files that contain Nativewind classes.
|
||||||
|
content: ["./App.tsx", "./components/**/*.{js,jsx,ts,tsx}", "./app/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Colore primario basato sul logo PROGECO
|
||||||
|
primary: {
|
||||||
|
50: '#f0f7fd',
|
||||||
|
100: '#dcf0fa',
|
||||||
|
200: '#bae2f6',
|
||||||
|
300: '#8accf0',
|
||||||
|
400: '#53b0e7',
|
||||||
|
500: '#2d94d8',
|
||||||
|
600: '#1071C2', // <-- DEFAULT Brand (Logo)
|
||||||
|
700: '#175e96',
|
||||||
|
800: '#15507d',
|
||||||
|
900: '#082963', // <-- Brand Dark (Logo text / Headings)
|
||||||
|
950: '#0e2b46',
|
||||||
|
DEFAULT: '#1071C2',
|
||||||
|
dark: '#082963',
|
||||||
|
},
|
||||||
|
// Grigi freddi che si abbinano perfettamente al blu primario
|
||||||
|
slate: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
DEFAULT: '#F4F7F9', // Leggermente più luminoso
|
||||||
|
paper: '#FFFFFF', // Card e form
|
||||||
|
alt: '#E6EDF5', // Sfondo alternativo
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
DEFAULT: '#082963', // Riprende il primary.dark
|
||||||
|
secondary: '#64748b', // Sottotitoli (slate-500)
|
||||||
|
muted: '#94a3b8', // Testo disabilitato/hint (slate-400)
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
success: '#10B981', // Emerald green
|
||||||
|
danger: '#EF4444', // Red acceso
|
||||||
|
warning: '#F59E0B', // Amber
|
||||||
|
info: '#3B82F6', // Azzurro standard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { DateType } from "react-native-ui-datepicker";
|
||||||
|
|
||||||
|
export interface UserData {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceRecord {
|
||||||
|
id: number;
|
||||||
|
constructionSite: string;
|
||||||
|
subactivity: string;
|
||||||
|
date: string;
|
||||||
|
in: string;
|
||||||
|
out: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentItem {
|
||||||
|
id: number;
|
||||||
|
mimetype: string;
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeOffRequestType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
time_required: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeOffRequest {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
start_date: DateType;
|
||||||
|
end_date?: DateType | null;
|
||||||
|
start_time?: string | null;
|
||||||
|
end_time?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
status: number;
|
||||||
|
timeOffRequestType: TimeOffRequestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Place {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstructionSite {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
address: string;
|
||||||
|
client: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Supplier {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceItem {
|
||||||
|
id: number;
|
||||||
|
placeName: string;
|
||||||
|
supplier: string;
|
||||||
|
date: string;
|
||||||
|
documentNumber: string;
|
||||||
|
totalAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceInfo extends InvoiceItem {
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceAccounting {
|
||||||
|
expiry: string;
|
||||||
|
tpa_description: string;
|
||||||
|
amount: string;
|
||||||
|
isPaid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityControlItem {
|
||||||
|
id: number;
|
||||||
|
constructionSite: string;
|
||||||
|
subactivity: string;
|
||||||
|
date: string;
|
||||||
|
result: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: number;
|
||||||
|
constructionSite: string;
|
||||||
|
subactivity: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
n_files?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL;
|
||||||
|
export const KEY_TOKEN = 'auth_key';
|
||||||
|
|
||||||
|
// Create an Axios instance with default configuration
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 10000, // 10 seconds timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export function to update base URL
|
||||||
|
export const setApiBaseUrl = (url: string) => {
|
||||||
|
if (url) {
|
||||||
|
api.defaults.baseURL = url;
|
||||||
|
console.log(`[API] Base URL updated to: ${url}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interceptor: Adds the token to EVERY request if it exists
|
||||||
|
api.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
const token = await SecureStore.getItemAsync(KEY_TOKEN);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
console.log(`[API REQUEST] ${config.method?.toUpperCase()} ${config.url}`);
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Interceptor: Global error handling (e.g., expired token)
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const isLoginRequest = originalRequest?.url?.includes('/user/login');
|
||||||
|
if (!(error.response.status === 401 && isLoginRequest)) {
|
||||||
|
console.error('[API ERROR]', error.response.status, error.response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we receive 401 (Unauthorized), we might want to force logout
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
// TODO: Here you can add logic to redirect to login screen if needed
|
||||||
|
await SecureStore.deleteItemAsync(KEY_TOKEN);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('[API NETWORK ERROR]', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react';
|
||||||
|
import { SplashScreen, useRouter, useSegments } from 'expo-router';
|
||||||
|
import { UserData } from '@/types/types';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import api, { KEY_TOKEN } from './api';
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isReady: boolean;
|
||||||
|
user: UserData | null;
|
||||||
|
logIn: (token: string, userData: UserData) => void;
|
||||||
|
logOut: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthState>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isReady: false,
|
||||||
|
user: null,
|
||||||
|
logIn: () => { },
|
||||||
|
logOut: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: PropsWithChildren) {
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [user, setUser] = useState<UserData | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
|
||||||
|
const logIn = async (token: string, userData: UserData) => {
|
||||||
|
try {
|
||||||
|
await SecureStore.setItemAsync(KEY_TOKEN, token);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setUser(userData);
|
||||||
|
|
||||||
|
router.replace('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore durante il login:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logOut = async () => {
|
||||||
|
try {
|
||||||
|
await SecureStore.deleteItemAsync(KEY_TOKEN);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
|
||||||
|
router.replace('/login');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore durante il logout:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initApp = async () => {
|
||||||
|
try {
|
||||||
|
// Get saved Token from SecureStore
|
||||||
|
const savedToken = await SecureStore.getItemAsync(KEY_TOKEN);
|
||||||
|
|
||||||
|
if (savedToken) {
|
||||||
|
console.log("Token trovato:", savedToken);
|
||||||
|
|
||||||
|
// Call backend to verify token and fetch user data
|
||||||
|
// Note: api.ts already adds the Authorization header thanks to the interceptor (if configured to read from SecureStore)
|
||||||
|
// If your api.ts reads from AsyncStorage, make sure they are aligned, otherwise pass it manually here:
|
||||||
|
const response = await api.get("/user", {
|
||||||
|
headers: { Authorization: `Bearer ${savedToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
console.log("Sessione valida, dati utente caricati:", result);
|
||||||
|
|
||||||
|
const loadedUser: UserData = {
|
||||||
|
firstName: result.nome,
|
||||||
|
lastName: result.cognome,
|
||||||
|
email: result.email,
|
||||||
|
isAdmin: result.isAdmin
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(loadedUser);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} else {
|
||||||
|
console.log("Nessun token salvato.");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Errore inizializzazione (Token scaduto o Server down):', error.message);
|
||||||
|
|
||||||
|
// If the token is not valid, clear everything
|
||||||
|
await SecureStore.deleteItemAsync(KEY_TOKEN);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsReady(true);
|
||||||
|
await SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initApp();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Route protection (optional, but recommended here or in the Layout)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReady) return;
|
||||||
|
|
||||||
|
const inAuthGroup = segments[0] === '(protected)';
|
||||||
|
|
||||||
|
if (!isAuthenticated && inAuthGroup) {
|
||||||
|
router.replace('/login');
|
||||||
|
} else if (isAuthenticated && !inAuthGroup) {
|
||||||
|
router.replace('/');
|
||||||
|
}
|
||||||
|
}, [isReady, isAuthenticated, segments]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ isReady, isAuthenticated, user, logIn, logOut }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { createContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { Linking, Platform } from 'react-native';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import axios from 'axios';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import UpdateScreen from '@/components/UpdateScreen';
|
||||||
|
import { isUpdateAvailable } from '@/utils/version';
|
||||||
|
import { setApiBaseUrl } from './api';
|
||||||
|
|
||||||
|
interface ConfigContextProps {
|
||||||
|
children: ReactNode
|
||||||
|
};
|
||||||
|
|
||||||
|
// Context (useful if you want to trigger manual checks from inside the app in the future)
|
||||||
|
export const ConfigContext = createContext({});
|
||||||
|
|
||||||
|
const GW_API = process.env.EXPO_PUBLIC_GW_API_URL;
|
||||||
|
const GW_UUID = process.env.EXPO_PUBLIC_GW_UUID;
|
||||||
|
const GW_TOKEN = process.env.EXPO_PUBLIC_GW_API_TOKEN;
|
||||||
|
|
||||||
|
export const ConfigProvider = ({ children }: ConfigContextProps) => {
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
const [needsUpdate, setNeedsUpdate] = useState(false);
|
||||||
|
const [updateUrl, setUpdateUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAppVersion = async () => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const apiUrl = `${GW_API}${GW_UUID}`;
|
||||||
|
const response = await axios.get(apiUrl, {
|
||||||
|
headers: { "x-access-tokens": GW_TOKEN }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update API URL: prioritize environment variable (override) over gateway response
|
||||||
|
setApiBaseUrl(process.env.EXPO_PUBLIC_API_URL || response.data.url);
|
||||||
|
|
||||||
|
const currentVersion = Constants.expoConfig?.version;
|
||||||
|
console.log("Versione attuale dell'app:", currentVersion);
|
||||||
|
|
||||||
|
const latestVersion = response.data.version;
|
||||||
|
console.log("Versione più recente disponibile:", latestVersion);
|
||||||
|
|
||||||
|
// Check if an update is needed
|
||||||
|
if (isUpdateAvailable(currentVersion, latestVersion)) {
|
||||||
|
setNeedsUpdate(true);
|
||||||
|
setUpdateUrl(Platform.OS === 'ios' ? response.data.app_url_ios : response.data.app_url_android);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Errore durante il controllo della versione:", error);
|
||||||
|
setNeedsUpdate(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAppVersion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (updateUrl) {
|
||||||
|
Linking.openURL(updateUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isChecking) {
|
||||||
|
return (
|
||||||
|
<LoadingScreen />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state: blocks children rendering
|
||||||
|
if (needsUpdate) {
|
||||||
|
return (
|
||||||
|
<UpdateScreen onUpdate={handleUpdate} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version is up to date
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={{ isChecking, needsUpdate }}>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
export const CHART_ENDPOINTS = {
|
||||||
|
costiRicavi: '/dashboard/costi-ricavi',
|
||||||
|
costiRicaviCum: '/dashboard/costi-ricavi',
|
||||||
|
fatturatoCliente: '/dashboard/fatturato-cliente',
|
||||||
|
aperteChiuseCliente: '/dashboard/aperte-chiuse-cliente',
|
||||||
|
aperteChiuseFornitore: '/dashboard/aperte-chiuse-fornitore',
|
||||||
|
fatturatoSoa: '/dashboard/fatturato-soa',
|
||||||
|
apertoCliente: '/dashboard/partite-cliente',
|
||||||
|
chiusoCliente: '/dashboard/partite-cliente',
|
||||||
|
marginalitaMediaSoa: '/dashboard/marginalita-media-soa',
|
||||||
|
marginalitaCategoriaSoa: '/dashboard/aggregati-soa',
|
||||||
|
pesoFatturatoSoa: '/dashboard/aggregati-soa'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHART_DATA_KEY: Record<string, string> = {
|
||||||
|
costiRicaviCum: 'costiRicavi',
|
||||||
|
apertoCliente: 'partiteCliente',
|
||||||
|
chiusoCliente: 'partiteCliente',
|
||||||
|
marginalitaCategoriaSoa: 'aggregatiSoa',
|
||||||
|
pesoFatturatoSoa: 'aggregatiSoa'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cumulate = (arr: number[]): number[] => {
|
||||||
|
let sum = 0;
|
||||||
|
return (arr || []).map(v => (sum += (Number(v) || 0)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatEuro = (val: string | number): string => {
|
||||||
|
const num = parseFloat(val as string);
|
||||||
|
return (isNaN(num) ? 0 : num).toLocaleString('it-IT', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLTIP_BASE = {
|
||||||
|
renderMode: 'richText',
|
||||||
|
confine: true,
|
||||||
|
textStyle: { fontSize: 10 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapText = (text: string, maxChars = 30): string => {
|
||||||
|
const words = String(text).split(' ');
|
||||||
|
const lines = [];
|
||||||
|
let line = '';
|
||||||
|
words.forEach(word => {
|
||||||
|
if (line && (line.length + 1 + word.length) > maxChars) {
|
||||||
|
lines.push(line);
|
||||||
|
line = word;
|
||||||
|
} else {
|
||||||
|
line = line ? `${line} ${word}` : word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (line) { lines.push(line); }
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPieOption = (data: any) => ({
|
||||||
|
legend: {
|
||||||
|
type: 'plain',
|
||||||
|
top: 190,
|
||||||
|
left: 'center',
|
||||||
|
itemGap: 8,
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 10,
|
||||||
|
textStyle: { fontSize: 12 }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Fatturato',
|
||||||
|
type: 'pie',
|
||||||
|
radius: [45, 80],
|
||||||
|
center: ['50%', 95],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 5,
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: false,
|
||||||
|
position: 'center'
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const val = (typeof params.value === 'number') ? params.value : 0;
|
||||||
|
return `${params.name}\n${val.toLocaleString('it-IT', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labelLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
data: data
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildSoaBarOption = (rows: any[], { color, valueLabel, extraLines }: any) => {
|
||||||
|
const labels = rows.map(r => `${r.code} - ${r.name}`);
|
||||||
|
return {
|
||||||
|
grid: {
|
||||||
|
left: 8,
|
||||||
|
right: 60,
|
||||||
|
top: 10,
|
||||||
|
bottom: 10,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: { formatter: '{value}%', fontSize: 10 }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map(r => r.code),
|
||||||
|
inverse: true,
|
||||||
|
axisLabel: { interval: 0, fontSize: 10 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...TOOLTIP_BASE,
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const p = params[0];
|
||||||
|
const row = rows[p.dataIndex];
|
||||||
|
const extra = extraLines ? extraLines(row) : [];
|
||||||
|
return [
|
||||||
|
wrapText(labels[p.dataIndex]),
|
||||||
|
`${valueLabel}: ${Number(p.value).toFixed(2)}%`,
|
||||||
|
...extra
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: valueLabel,
|
||||||
|
type: 'bar',
|
||||||
|
data: rows.map(r => r.value),
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
formatter: (p: any) => `${Number(p.value).toFixed(2)}%`,
|
||||||
|
fontSize: 10
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: (p: any) => (p.value >= 0 ? color : '#ee6666'),
|
||||||
|
borderRadius: [0, 4, 4, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commesseLine = (r: any) => [`Commesse: ${r.n_commesse}`];
|
||||||
|
|
||||||
|
export const buildAperteChiuseOption = (d: any) => ({
|
||||||
|
tooltip: {
|
||||||
|
...TOOLTIP_BASE,
|
||||||
|
trigger: 'axis',
|
||||||
|
valueFormatter: formatEuro
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
itemGap: 10,
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 10,
|
||||||
|
textStyle: { fontSize: 12 },
|
||||||
|
data: [
|
||||||
|
`Aperto(${d.current_year})`,
|
||||||
|
`Chiuso(${d.current_year})`,
|
||||||
|
`Aperto(${d.prev_year})`,
|
||||||
|
`Chiuso(${d.prev_year})`
|
||||||
|
],
|
||||||
|
selected: {
|
||||||
|
[`Aperto(${d.prev_year})`]: false,
|
||||||
|
[`Chiuso(${d.prev_year})`]: false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
top: '10%',
|
||||||
|
bottom: 75,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: d.mesi
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value'
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{ name: `Aperto(${d.current_year})`, type: 'bar', stack: 'one', data: d.aperto },
|
||||||
|
{ name: `Chiuso(${d.current_year})`, type: 'bar', stack: 'one', data: d.chiuso },
|
||||||
|
{ name: `Aperto(${d.prev_year})`, type: 'bar', stack: 'one', itemStyle: { color: '#b3b3b3' }, data: d.aperto_prev },
|
||||||
|
{ name: `Chiuso(${d.prev_year})`, type: 'bar', stack: 'one', itemStyle: { color: '#d9d9d9' }, data: d.chiuso_prev }
|
||||||
|
]
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { DateType } from "react-native-ui-datepicker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms "YYYY-MM-DD" to "DD/MM/YYYY"
|
||||||
|
* @param dateStr string in ISO date format "YYYY-MM-DD"
|
||||||
|
* @returns formatted string "DD/MM/YYYY"
|
||||||
|
*/
|
||||||
|
export const formatDate = (dateStr: string | null | undefined): string => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms time from "HH:MM:SS" to "HH:MM"
|
||||||
|
* @param timeStr string in time format "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS"
|
||||||
|
* @returns formatted string "HH:MM"
|
||||||
|
*/
|
||||||
|
export const formatTime = (timeStr: string | null | undefined): string => {
|
||||||
|
if (!timeStr) return '';
|
||||||
|
// Handle both "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS" formats
|
||||||
|
const timePart = timeStr.includes(' ') ? timeStr.split(' ')[1] : timeStr;
|
||||||
|
const [hours, minutes] = timePart.split(':');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date for use with a date picker, normalizing it to midnight
|
||||||
|
* @param d Date in DateType format
|
||||||
|
* @returns string in "YYYY-MM-DD" format or null if input is null/undefined
|
||||||
|
*/
|
||||||
|
export const formatPickerDate = (d: DateType | null | undefined) => {
|
||||||
|
if (!d) return null;
|
||||||
|
|
||||||
|
const date = new Date(d as string | number | Date);
|
||||||
|
const normalized = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
|
||||||
|
const yyyy = normalized.getFullYear();
|
||||||
|
const mm = String(normalized.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(normalized.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a timestamp into a string "DD/MM/YYYY HH:mm:ss"
|
||||||
|
* @param timestamp string or Date object
|
||||||
|
* @returns formatted string or empty string if input is invalid
|
||||||
|
*/
|
||||||
|
export const formatTimestamp = (timestamp: string | Date | null | undefined): string => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
|
||||||
|
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0'); // months from 0 to 11
|
||||||
|
const yyyy = date.getFullYear();
|
||||||
|
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${dd}/${mm}/${yyyy} ${hh}:${min}:${ss}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an ISO timestamp to a Date object
|
||||||
|
* @param dateStr string in ISO date format
|
||||||
|
* @returns corresponding Date object
|
||||||
|
*/
|
||||||
|
export const parseTimestamp = (dateStr: string | undefined | null): Date => {
|
||||||
|
if (!dateStr) return new Date();
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return new Date();
|
||||||
|
return date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseSecondsToTime = (totalSeconds: number | null | undefined): string => {
|
||||||
|
if (totalSeconds == null || isNaN(totalSeconds)) return '';
|
||||||
|
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
const hh = String(hours);
|
||||||
|
const mm = String(minutes).padStart(2, '0');
|
||||||
|
const ss = String(seconds).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${hh}h`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import api from '@/utils/api';
|
||||||
|
import { Directory, File, Paths } from 'expo-file-system';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
interface UploadOptions {
|
||||||
|
endpoint: string;
|
||||||
|
fileKey?: string;
|
||||||
|
extraData?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles upload of a document through the server using FormData
|
||||||
|
* @param file File to upload (must have at least the 'uri' property)
|
||||||
|
* @param options Configuration for the upload
|
||||||
|
*/
|
||||||
|
export const uploadDocument = async (
|
||||||
|
file: any,
|
||||||
|
options: UploadOptions
|
||||||
|
): Promise<any> => {
|
||||||
|
if (!file || !file.uri) {
|
||||||
|
throw new Error("File non valido per l'upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
const fileKey = options.fileKey || 'file';
|
||||||
|
|
||||||
|
formData.append(fileKey, {
|
||||||
|
uri: Platform.OS === 'android' ? file.uri : file.uri.replace('file://', ''),
|
||||||
|
name: file.name,
|
||||||
|
type: file.mimeType || 'application/octet-stream'
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
if (options.extraData) {
|
||||||
|
Object.keys(options.extraData).forEach(key => {
|
||||||
|
formData.append(key, options.extraData![key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.post(options.endpoint, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Risposta server upload:", response.data);
|
||||||
|
|
||||||
|
if (response.data?.status === 'error' || response.data?.success === false) {
|
||||||
|
throw new Error(response.data.message || "Errore sconosciuto dal server");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Errore durante l'upload del documento:", error);
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const serverMessage = error.response.data?.message || error.message;
|
||||||
|
throw new Error(`Errore Server (${error.response.status}): ${serverMessage}`);
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error("Il server non risponde. Controlla la connessione.");
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and share a document (expo-sharing)
|
||||||
|
* @param attachmentId ID or relative URL of the document
|
||||||
|
* @param fileName Name to save the file as
|
||||||
|
* @param fileUrl Full URL of the file to download
|
||||||
|
*/
|
||||||
|
export const downloadAndShareDocument = async (
|
||||||
|
mimetype: string,
|
||||||
|
fileName: string,
|
||||||
|
fileUrl: string
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// TODO: Download based on expo-sharing - some mime types may not be supported
|
||||||
|
if (!fileUrl || !fileName) {
|
||||||
|
throw new Error("Parametri mancanti per il download del documento.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = new Directory(Paths.cache, 'documents');
|
||||||
|
destination.exists ? destination.delete() : null;
|
||||||
|
destination.create({ overwrite: true });
|
||||||
|
|
||||||
|
const tmpFile = await File.downloadFileAsync(fileUrl, destination);
|
||||||
|
console.log("File temporaneo scaricato in:", tmpFile.uri);
|
||||||
|
|
||||||
|
const outFile = new File(destination, fileName);
|
||||||
|
await tmpFile.move(outFile);
|
||||||
|
console.log("File spostato in:", outFile.uri);
|
||||||
|
console.log("File type:", mimetype);
|
||||||
|
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
await Sharing.shareAsync(outFile.uri, {
|
||||||
|
mimeType: mimetype,
|
||||||
|
dialogTitle: `Scarica ${fileName}`,
|
||||||
|
UTI: 'public.item'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("Condivisione non supportata su questo dispositivo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Download Error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { useState, useEffect, ReactNode } from 'react';
|
||||||
|
import NetInfo, { useNetInfo } from '@react-native-community/netinfo';
|
||||||
|
import OfflineScreen from '@/components/OfflineScreen';
|
||||||
|
|
||||||
|
interface NetworkProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NetworkProvider = ({ children }: NetworkProviderProps) => {
|
||||||
|
const netInfo = useNetInfo();
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (netInfo.isConnected === false) {
|
||||||
|
setIsOffline(true);
|
||||||
|
} else {
|
||||||
|
setIsOffline(false);
|
||||||
|
}
|
||||||
|
}, [netInfo.isConnected]);
|
||||||
|
|
||||||
|
// Manual Retry Handler
|
||||||
|
const handleManualRetry = async () => {
|
||||||
|
setIsRetrying(true);
|
||||||
|
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsOffline(state.isConnected === false);
|
||||||
|
setIsRetrying(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOffline) {
|
||||||
|
return (
|
||||||
|
<OfflineScreen
|
||||||
|
onRetry={handleManualRetry}
|
||||||
|
isRetrying={isRetrying}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Compare two version strings.
|
||||||
|
* Returns true if 'latest' is greater than 'current' (an update is available).
|
||||||
|
*/
|
||||||
|
export const isUpdateAvailable = (currentVersion: string | undefined, latestVersion: string) => {
|
||||||
|
if (!currentVersion || !latestVersion) return false;
|
||||||
|
|
||||||
|
// Split strings into an array of numbers: "1.2.10" -> [1, 2, 10]
|
||||||
|
const currentParts = currentVersion.split('.').map(Number);
|
||||||
|
const latestParts = latestVersion.split('.').map(Number);
|
||||||
|
const maxLength = Math.max(currentParts.length, latestParts.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
// If a part is missing, we consider it as 0 (e.g. "1.0" -> [1, 0, 0])
|
||||||
|
const current = currentParts[i] || 0;
|
||||||
|
const latest = latestParts[i] || 0;
|
||||||
|
|
||||||
|
if (current < latest) {
|
||||||
|
return true; // It needs an update
|
||||||
|
}
|
||||||
|
if (current > latest) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||