From e49d6f0e5b2a7196a7e43956a79310397b945002 Mon Sep 17 00:00:00 2001 From: leonardo Date: Fri, 31 Jul 2026 16:53:16 +0200 Subject: [PATCH] Initial commit --- .env.example | 5 + .gitignore | 48 + README.md | 50 + app.json | 68 + app/(protected)/_layout.tsx | 95 + app/(protected)/attendance/index.tsx | 240 + app/(protected)/index.tsx | 192 + app/(protected)/invoice/[id].tsx | 153 + app/(protected)/invoice/_layout.tsx | 10 + app/(protected)/invoice/index.tsx | 159 + app/(protected)/journal/[id].tsx | 282 + app/(protected)/journal/_layout.tsx | 11 + app/(protected)/journal/add.tsx | 292 + app/(protected)/journal/index.tsx | 196 + app/(protected)/machine/index.tsx | 249 + app/(protected)/permits/index.tsx | 288 + app/(protected)/profile/_layout.tsx | 10 + app/(protected)/profile/documents.tsx | 205 + app/(protected)/profile/index.tsx | 108 + app/_layout.tsx | 32 + app/login.tsx | 168 + assets/images/adaptive-icon.png | Bin 0 -> 484493 bytes assets/images/favicon.png | Bin 0 -> 3025 bytes assets/images/icon.png | Bin 0 -> 217191 bytes assets/images/partial-react-logo.png | Bin 0 -> 56778 bytes assets/images/react-logo.png | Bin 0 -> 8192 bytes assets/images/react-logo@2x.png | Bin 0 -> 22681 bytes assets/images/react-logo@3x.png | Bin 0 -> 48011 bytes assets/images/splash-icon.png | Bin 0 -> 327966 bytes assets/images/splash.png | Bin 0 -> 54178 bytes babel.config.js | 9 + components/AlertComponent.tsx | 181 + components/AppDatePicker.tsx | 26 + components/AttendanceCard.tsx | 53 + components/CalendarWidget.tsx | 114 + components/CameraAddTile.tsx | 21 + components/FilterModal.tsx | 149 + components/InvoiceCard.tsx | 42 + components/LoadingScreen.tsx | 10 + components/MachineCard.tsx | 57 + components/MachineFilter.tsx | 165 + components/OfflineScreen.tsx | 43 + components/PlaceFilter.tsx | 154 + components/QrScanModal.tsx | 102 + components/RemovablePhotoTile.tsx | 32 + components/RequestPermitModal.tsx | 283 + components/SetDescriptionModal.tsx | 114 + components/SupplierFilter.tsx | 180 + components/TimePickerModal.tsx | 70 + components/UpdateScreen.tsx | 43 + eas.json | 21 + eslint.config.js | 10 + global.css | 3 + metro.config.js | 6 + nativewind-env.d.ts | 1 + package-lock.json | 14658 ++++++++++++++++++++++++ package.json | 74 + tailwind.config.js | 30 + tsconfig.json | 18 + types/types.ts | 92 + utils/api.ts | 65 + utils/authContext.tsx | 124 + utils/configProvider.tsx | 87 + utils/dateTime.ts | 91 + utils/documentUtils.tsx | 105 + utils/networkProvider.tsx | 42 + utils/version.ts | 27 + 67 files changed, 20163 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.json create mode 100644 app/(protected)/_layout.tsx create mode 100644 app/(protected)/attendance/index.tsx create mode 100644 app/(protected)/index.tsx create mode 100644 app/(protected)/invoice/[id].tsx create mode 100644 app/(protected)/invoice/_layout.tsx create mode 100644 app/(protected)/invoice/index.tsx create mode 100644 app/(protected)/journal/[id].tsx create mode 100644 app/(protected)/journal/_layout.tsx create mode 100644 app/(protected)/journal/add.tsx create mode 100644 app/(protected)/journal/index.tsx create mode 100644 app/(protected)/machine/index.tsx create mode 100644 app/(protected)/permits/index.tsx create mode 100644 app/(protected)/profile/_layout.tsx create mode 100644 app/(protected)/profile/documents.tsx create mode 100644 app/(protected)/profile/index.tsx create mode 100644 app/_layout.tsx create mode 100644 app/login.tsx create mode 100644 assets/images/adaptive-icon.png create mode 100644 assets/images/favicon.png create mode 100644 assets/images/icon.png create mode 100644 assets/images/partial-react-logo.png create mode 100644 assets/images/react-logo.png create mode 100644 assets/images/react-logo@2x.png create mode 100644 assets/images/react-logo@3x.png create mode 100644 assets/images/splash-icon.png create mode 100644 assets/images/splash.png create mode 100644 babel.config.js create mode 100644 components/AlertComponent.tsx create mode 100644 components/AppDatePicker.tsx create mode 100644 components/AttendanceCard.tsx create mode 100644 components/CalendarWidget.tsx create mode 100644 components/CameraAddTile.tsx create mode 100644 components/FilterModal.tsx create mode 100644 components/InvoiceCard.tsx create mode 100644 components/LoadingScreen.tsx create mode 100644 components/MachineCard.tsx create mode 100644 components/MachineFilter.tsx create mode 100644 components/OfflineScreen.tsx create mode 100644 components/PlaceFilter.tsx create mode 100644 components/QrScanModal.tsx create mode 100644 components/RemovablePhotoTile.tsx create mode 100644 components/RequestPermitModal.tsx create mode 100644 components/SetDescriptionModal.tsx create mode 100644 components/SupplierFilter.tsx create mode 100644 components/TimePickerModal.tsx create mode 100644 components/UpdateScreen.tsx create mode 100644 eas.json create mode 100644 eslint.config.js create mode 100644 global.css create mode 100644 metro.config.js create mode 100644 nativewind-env.d.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 types/types.ts create mode 100644 utils/api.ts create mode 100644 utils/authContext.tsx create mode 100644 utils/configProvider.tsx create mode 100644 utils/dateTime.ts create mode 100644 utils/documentUtils.tsx create mode 100644 utils/networkProvider.tsx create mode 100644 utils/version.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90d5de8 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8b52d1 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..48dd63f --- /dev/null +++ b/README.md @@ -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. diff --git a/app.json b/app.json new file mode 100644 index 0000000..412bff7 --- /dev/null +++ b/app.json @@ -0,0 +1,68 @@ +{ + "expo": { + "name": "IP Costruzioni", + "slug": "ipcostruzioni_app", + "version": "1.7", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "ipcostruzioniapp", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.pcrt.ipcostruzioni-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.ipcostruzioni_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" + } + } + } +} diff --git a/app/(protected)/_layout.tsx b/app/(protected)/_layout.tsx new file mode 100644 index 0000000..e9f9f6b --- /dev/null +++ b/app/(protected)/_layout.tsx @@ -0,0 +1,95 @@ +import { Redirect, Tabs } from 'expo-router'; +import { Home, Clock, CalendarIcon, FileText, Image as ImageIcon, Car } 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 ; + } + + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + href: authState.user?.isAdmin ? undefined : null, + }} + /> + , + href: authState.user?.isAdmin ? undefined : null, + }} + /> + + + ); +} diff --git a/app/(protected)/attendance/index.tsx b/app/(protected)/attendance/index.tsx new file mode 100644 index 0000000..7341e62 --- /dev/null +++ b/app/(protected)/attendance/index.tsx @@ -0,0 +1,240 @@ +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'; + +export default function AttendanceScreen() { + const alert = useAlert(); + const [showScanner, setShowScanner] = useState(false); + const [lastScan, setLastScan] = useState<{ type: string; time: string; site: string } | null>(null); + const [attendances, setAttendances] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Filters state + const [isFilterVisible, setIsFilterVisible] = useState(false); + const [places, setPlaces] = useState([]); + const [filterRange, setFilterRange] = useState<{ startDate: string | null; endDate: string | null }>({ startDate: null, endDate: null }); + const [filterPlace, setFilterPlace] = useState(null); + + const activeFiltersCount = (filterRange.startDate ? 1 : 0) + (filterPlace ? 1 : 0); + + const fetchPlaces = async () => { + try { + const response = await api.get('/place/get-places'); + if (response.data?.success) { + setPlaces(response.data.places || []); + } + } 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, place: 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); + }, []); + + 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 ; + } + + return ( + + + {/* Header */} + + + + Elenco delle tue presenze + Presenze + + + + + + + + + + } + > + + + {/* Feedback Card */} + {lastScan ? ( + + + + + + + {lastScan.type} Registrata + + + + {lastScan.site} alle {lastScan.time} + + + + + ) : null} + + {/* Scanner Section */} + + + Scansione QR Code + + + + Scansiona Codice + + + + Posiziona il codice QR davanti alla fotocamera per registrare l'ingresso o l'uscita dal cantiere + + + + + {/* History using AttendanceCard component */} + + Ultime Presenze + {attendances.length === 0 ? ( + + Nessuna presenza registrata + + ) : ( + + {attendances.map((item, index) => ( + + ))} + + )} + + + + + + {/* FAB Filter */} + 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" + > + + {activeFiltersCount > 0 && ( + + {activeFiltersCount} + + )} + + + {/* Filter Modal */} + 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 */} + setShowScanner(false)} + onScan={onScan} + /> + + + ); +} diff --git a/app/(protected)/index.tsx b/app/(protected)/index.tsx new file mode 100644 index 0000000..53f684c --- /dev/null +++ b/app/(protected)/index.tsx @@ -0,0 +1,192 @@ +import AttendanceCard from '@/components/AttendanceCard'; +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, QrCode, User } 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'; + +export default function HomeScreen() { + const router = useRouter(); + const { user } = useContext(AuthContext); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const [updates, setUpdates] = useState([]); + const [attendances, setAttendances] = useState([]); + + const fetchDashboardData = async () => { + try { + if (!refreshing) setIsLoading(true); + + const params = { range: null, place: null }; + + // Parallel requests for journal and attendance data + const [journalRes, attendanceRes] = await Promise.all([ + api.post('/journal/list', { params }), + api.post('/attendance/list', { params }) + ]); + + // Select first element of journal (slice 0,1) + if (journalRes.data?.success) { + setUpdates(journalRes.data.result.slice(0, 1)); + } + // Select first 2 elements of attendances (slice 0,2) + if (attendanceRes.data?.success) { + setAttendances(attendanceRes.data.result.slice(0, 2)); + } + + } catch (error) { + console.error('Errore nel recupero dei dati della dashboard:', error); + } finally { + setIsLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchDashboardData(); + }, []); + + const onRefresh = () => { + setRefreshing(true); + fetchDashboardData(); + }; + + if (isLoading && !refreshing) { + return ( + + ); + } + + return ( + + + + {/* Custom Banner */} + + + + + + IP Costruzioni SRL + + + Ciao {user?.firstName} + + + + + {/* Profile Avatar */} + router.push('/profile')}> + + + + + + + + {/* Scrollable Content */} + + } + > + + {/* Quick Actions */} + + Azioni Rapide + + router.push('/attendance')} + className="flex-1 bg-white p-6 rounded-3xl shadow-sm items-center justify-center gap-4 border border-gray-100 active:scale-[0.98]" + > + + + + Nuova Presenza + + + router.push('/journal/add')} + className="flex-1 bg-white p-6 rounded-3xl shadow-sm items-center justify-center gap-4 border border-gray-100 active:scale-[0.98]" + > + + + + Nuovo{'\n'} Giornale + + + + + {/* Journal */} + + + Giornale di Cantiere + + + {updates.map((item, index) => ( + + + + + + + {item.place_name} + + + {item.place_address} + + + {item.date} + + + + ))} + + {!isLoading && updates.length === 0 && ( + + Nessun aggiornamento recente + + )} + {isLoading && updates.length === 0 && ( + + Caricamento... + + )} + + + + {/* Attendance */} + + + Presenze + + + {attendances.map((item, index) => ( + + ))} + + {!isLoading && attendances.length === 0 && ( + + Nessuna presenza recente + + )} + {isLoading && attendances.length === 0 && ( + + Caricamento... + + )} + + + + + + ); +} diff --git a/app/(protected)/invoice/[id].tsx b/app/(protected)/invoice/[id].tsx new file mode 100644 index 0000000..6625e07 --- /dev/null +++ b/app/(protected)/invoice/[id].tsx @@ -0,0 +1,153 @@ +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(null); + const [partite, setPartite] = useState([]); + 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 ; + } + + return ( + + + {/* Header */} + + + router.back()} className="p-2 bg-gray-50 rounded-full active:bg-gray-100"> + + + Dettaglio Fattura + + + + + + + + {/* General Info Card */} + + Informazioni + + + Numero Documento + {invoiceInfo.documentNumber} + + + + Fornitore + {invoiceInfo.supplier} + + + + Cantiere + {invoiceInfo.placeName} + + + + + Data Documento + {invoiceInfo.date} + + + Importo + {invoiceInfo.totalAmount} + + + + + {/* Expiries */} + {partite && partite.length > 0 && ( + + Scadenze + + + {partite.map((item, idx) => ( + + + + + {item.expiry} + + + {item.isPaid ? : } + + {item.isPaid ? 'Pagata' : 'Da Pagare'} + + + + + + + + + + {item.tpa_description} + + + {item.amount} + + + + ))} + + + )} + + + + + + ); +} diff --git a/app/(protected)/invoice/_layout.tsx b/app/(protected)/invoice/_layout.tsx new file mode 100644 index 0000000..659f3e9 --- /dev/null +++ b/app/(protected)/invoice/_layout.tsx @@ -0,0 +1,10 @@ +import {Stack} from 'expo-router'; + +export default function InvoiceLayout() { + return ( + + + + + ); +} diff --git a/app/(protected)/invoice/index.tsx b/app/(protected)/invoice/index.tsx new file mode 100644 index 0000000..b79d5bc --- /dev/null +++ b/app/(protected)/invoice/index.tsx @@ -0,0 +1,159 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { View, Text, ScrollView, 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([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Filters state + const [showFilterModal, setShowFilterModal] = useState(false); + const [places, setPlaces] = useState([]); + const [filterRange, setFilterRange] = useState<{ startDate: string | null; endDate: string | null }>({ startDate: null, endDate: null }); + const [filterPlace, setFilterPlace] = useState(null); + const [filterSupplier, setFilterSupplier] = useState(null); + + const activeFiltersCount = (filterRange.startDate ? 1 : 0) + (filterPlace ? 1 : 0) + (filterSupplier ? 1 : 0); + + const fetchPlaces = async () => { + try { + const response = await api.get('/place/get-places'); + if (response.data?.success) { + setPlaces(response.data.places || []); + } + } 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; + const placeCode = currentPlace ? places.find(p => p.id === currentPlace)?.code : 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 ; + } + + return ( + + + {/* Header */} + + + + Elenco delle fatture + Fatture + + + + + + + + + {/* List */} + } + > + {invoices.length === 0 ? ( + + Nessuna fattura trovata + + ) : ( + invoices.map((invoice) => ( + + )))} + + + {/* FAB Filter */} + 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" + > + + {activeFiltersCount > 0 && ( + + {activeFiltersCount} + + )} + + + setShowFilterModal(false)} + onApply={handleApplyFilters} + onReset={handleResetFilters} + /> + + + ); +} diff --git a/app/(protected)/journal/[id].tsx b/app/(protected)/journal/[id].tsx new file mode 100644 index 0000000..79ad449 --- /dev/null +++ b/app/(protected)/journal/[id].tsx @@ -0,0 +1,282 @@ +import { useAlert } from '@/components/AlertComponent'; +import LoadingScreen from '@/components/LoadingScreen'; +import SetDescriptionModal from '@/components/SetDescriptionModal'; +import api from '@/utils/api'; +import { Image } from 'expo-image'; +import { downloadAndShareDocument } from '@/utils/documentUtils'; +import ImageView from "react-native-image-viewing"; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { ChevronLeft, ImageIcon, Share2, Trash2, 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'; + +export default function JournalDetailScreen() { + const router = useRouter(); + const alert = useAlert(); + const params = useLocalSearchParams(); + const insets = useSafeAreaInsets(); + + const [placeName, setPlaceName] = useState(''); + const [photos, setPhotos] = useState([]); + + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const [isVisible, setIsVisible] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + + const [descModalVisible, setDescModalVisible] = useState(false); + const [editIndex, setEditIndex] = useState(null); + + const handleDescription = async (desc: string) => { + if (editIndex === null) return; + const photo = photos[editIndex]; + if (!photo) return; + + setDescModalVisible(false); + try { + const response = await api.post('/journal/save-description', { + id: photo.id, + description: desc, + }); + if (response.data?.success) { + fetchDetails(true); + alert.showAlert('success', 'Salvato', 'Descrizione aggiornata con successo.'); + } else { + alert.showAlert('error', 'Errore', response.data?.message || 'Impossibile salvare la descrizione.'); + } + } catch (error) { + console.error(error); + alert.showAlert('error', 'Errore', 'Si è verificato un errore durante il salvataggio.'); + } + }; + + const [isSharing, setIsSharing] = useState(false); + + const handleShare = async (uri: string) => { + if (isSharing) return; // Prevent multiple share actions + setIsSharing(true); + try { + const fileName = uri.split('/').pop() || 'immagine.jpg'; + await downloadAndShareDocument('image/jpeg', fileName, uri); + } catch (error) { + console.error('Error sharing image:', error); + alert.showAlert('error', 'Errore', 'Si è verificato un errore durante la condivisione.'); + } finally { + setIsSharing(false); + } + }; + + const handleDelete = (id: string) => { + alert.showConfirm( + 'Sei sicuro?', + 'Vuoi davvero eliminare questa foto?', + [ + { text: 'Annulla', style: 'cancel', onPress: () => {} }, + { + text: 'Elimina', + style: 'destructive', + onPress: async () => { + try { + const response = await api.post('/journal/delete-attachment', { id }); + if (response.data?.success) { + setIsVisible(false); + fetchDetails(true); + alert.showAlert('success', 'Eliminata', 'La foto è stata rimossa con successo.'); + } else { + alert.showAlert('error', 'Errore', response.data?.message || 'Impossibile eliminare la foto.'); + } + } catch (error) { + console.error(error); + alert.showAlert('error', 'Errore', 'Impossibile eliminare la foto.'); + } + } + } + ] + ); + }; + + const fetchDetails = useCallback(async (isRefreshing = false) => { + try { + if (!isRefreshing) setIsLoading(true); + + const response = await api.get(`/journal/get-journal-items?id=${params.id}`); + + if (response.data?.success) { + setPlaceName(response.data.result?.place_name || ''); + setPhotos(response.data.result?.photos || []); + } else { + alert.showAlert('error', 'Errore', 'Impossibile caricare i dettagli.'); + } + } catch (error) { + console.error('Errore nel recupero del dettaglio giornale:', error); + alert.showAlert('error', 'Errore', 'Si è verificato un errore di rete.'); + } finally { + setIsLoading(false); + setRefreshing(false); + } + }, [params.id]); + + useEffect(() => { + if (params.id) { + fetchDetails(); + } + }, [params.id, fetchDetails]); + + const onRefresh = () => { + setRefreshing(true); + fetchDetails(true); + }; + + if (isLoading && !refreshing) { + return ; + } + + // 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 })); + + // Custom Header for the ImageView + const CustomHeader = ({ imageIndex }: { imageIndex: number }) => { + const currentPhoto = photos[imageIndex]; + if (!currentPhoto) return null; + + return ( + + + + setIsVisible(false)} className="p-2 -ml-2 active:opacity-70"> + + + {currentPhoto.date || 'Dettaglio'} + + + { + setEditIndex(imageIndex); + setDescModalVisible(true); + }} className="p-2 active:opacity-70"> + + + handleShare(currentPhoto.uri)} disabled={isSharing} className={`p-2 active:opacity-70 ${isSharing ? 'opacity-50' : ''}`}> + + + handleDelete(currentPhoto.id)} className="p-2 active:opacity-70"> + + + + + + ); + }; + + // Custom Footer for the ImageView + const CustomFooter = ({ imageIndex }: { imageIndex: number }) => { + const currentPhoto = photos[imageIndex]; + if (!currentPhoto || !currentPhoto.description) return null; + + return ( + + + + {currentPhoto.description} + + + + ); + }; + + return ( + + {/* Header */} + + + + router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100"> + + + + + {placeName || 'Dettaglio Giornale'} + + + + + + } + > + {photos.length === 0 ? ( + + + Nessuna foto presente per questo giornale. + + ) : ( + + {photos.map((item, index) => ( + { + setCurrentIndex(index); + setIsVisible(true); + }} + style={{ width: itemSize }} + className="mb-2" + > + + + + {item.description && ( + + {item.description} + + )} + + ))} + + )} + + + {/* Lightbox / Fullscreen viewer */} + setIsVisible(false)} + HeaderComponent={CustomHeader} + FooterComponent={CustomFooter} + backgroundColor={"#EDF1F7"} + presentationStyle={'fullScreen'} + /> + + setDescModalVisible(false)} + onSave={handleDescription} + /> + + ); +} diff --git a/app/(protected)/journal/_layout.tsx b/app/(protected)/journal/_layout.tsx new file mode 100644 index 0000000..073fc45 --- /dev/null +++ b/app/(protected)/journal/_layout.tsx @@ -0,0 +1,11 @@ +import {Stack} from 'expo-router'; + +export default function JournalLayout() { + return ( + + + + + + ); +} diff --git a/app/(protected)/journal/add.tsx b/app/(protected)/journal/add.tsx new file mode 100644 index 0000000..201d35a --- /dev/null +++ b/app/(protected)/journal/add.tsx @@ -0,0 +1,292 @@ +import { ChevronLeft } from 'lucide-react-native'; +import React, { useState, useEffect } from 'react'; +import { View, Text, TouchableOpacity, TextInput, KeyboardAvoidingView, ScrollView, Platform, Dimensions, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import PlaceFilter from '@/components/PlaceFilter'; +import { Place } from '@/types/types'; +import api from '@/utils/api'; +import { useAlert } from '@/components/AlertComponent'; +import RemovablePhotoTile from '@/components/RemovablePhotoTile'; +import CameraAddTile from '@/components/CameraAddTile'; +import * as ImagePicker from 'expo-image-picker'; + +export default function AddJournalScreen() { + const router = useRouter(); + const alert = useAlert(); + const [places, setPlaces] = useState([]); + const [selectedPlaceId, setSelectedPlaceId] = useState(null); + const [description, setDescription] = useState(''); + const [photos, setPhotos] = useState([]); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [uploadProgress, setUploadProgress] = useState<{ current: number, total: number } | null>(null); + + // Dynamic calculation of grid items (4 items per row) + const windowWidth = Dimensions.get('window').width; + const padding = 24; + const gap = 12; + const itemsPerRow = 4; + const itemSize = (windowWidth - (padding * 2) - (gap * (itemsPerRow - 1))) / itemsPerRow; + + // Function to pick images from the gallery + const pickFromGallery = async () => { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + + if (permissionResult.granted === false) { + alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla galleria per caricare foto.'); + return; + } + + const limit = 50 - photos.length; + if (limit <= 0) return; + + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], // Only images + allowsMultipleSelection: true, + selectionLimit: limit, + quality: 0.8, // Apply light compression and permanently fix the correct orientation + }); + + if (!result.canceled && result.assets) { + setPhotos(prev => [...prev, ...result.assets]); + } + } catch (error) { + console.error('Errore nella galleria:', error); + alert.showAlert('error', 'Errore', 'Impossibile accedere alla galleria.'); + } + }; + + // Function to take a photo using the camera + const takePhoto = async () => { + const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); + + if (permissionResult.granted === false) { + alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla fotocamera per scattare foto.'); + return; + } + + if (photos.length >= 50) return; + + try { + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + quality: 0.8, // Apply light compression and permanently fix the correct orientation + }); + + if (!result.canceled && result.assets && result.assets.length > 0) { + setPhotos(prev => [...prev, result.assets[0]]); + } + } catch (error) { + console.error('Errore nella fotocamera:', error); + alert.showAlert('error', 'Errore', 'Impossibile accedere alla fotocamera.'); + } + }; + + const removePhoto = (index: number) => { + setPhotos(prev => prev.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + if (!selectedPlaceId) return; + + setIsSubmitting(true); + setUploadProgress(null); + + try { + // Create record in the database first + const params = { + place: selectedPlaceId, + description: description, + n_files: photos.length + }; + + const response = await api.post('/journal/add', params); + + if (response.data?.success) { + const journalId = response.data.id; + + // Sequential asynchronous upload + if (photos.length > 0) { + setUploadProgress({ current: 0, total: photos.length }); + + for (let i = 0; i < photos.length; i++) { + const file = photos[i]; + let formData = new FormData(); + + const fileName = file.fileName || file.uri.split('/').pop() || `photo_${i}.jpg`; + const fileType = file.mimeType || 'image/jpeg'; + 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', 'JournalUpdate'); + formData.append('model_id', journalId); + formData.append('method', 'put'); + formData.append('name', fileName); + formData.append('type', fileType); + + await api.post('/journal/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + setUploadProgress({ current: i + 1, total: photos.length }); + } + } + + alert.showAlert('success', 'Ottimo Lavoro', 'Aggiornamento caricato con successo!'); + router.replace('/(protected)/journal'); + } else { + alert.showAlert('error', 'Errore', response.data?.message || 'Impossibile creare l\'avanzamento.'); + } + } catch (error: any) { + console.error('Errore durante il salvataggio:', error); + alert.showAlert('error', 'Errore di connessione', 'Verifica la tua connessione e riprova.'); + } finally { + setIsSubmitting(false); + setUploadProgress(null); + } + }; + + useEffect(() => { + fetchPlaces(); + }, []); + + const fetchPlaces = async () => { + try { + // This endpoint returns the UUID of the construction site necessary for saving + const response = await api.get('/journal/get-places'); + if (response.data?.success) { + const mappedPlaces = response.data.places.map((p: any) => ({ + id: p.value, // value = UUID + label: p.label, + code: '' + })); + setPlaces(mappedPlaces); + } + } catch (error) { + console.error('Errore nel recupero dei cantieri:', error); + } + }; + + return ( + + + {/* Header */} + + + + router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100"> + + + + + Nuovo Aggiornamento + + + + + + + + {/* Place Filter Component */} + + + + + {/* Description Area */} + + Descrizione + + + + + + {/* Attachments Area */} + + + Allegati ({photos.length}/50) + {photos.length < 50 && ( + + Aggiungi + + )} + + + + {photos.map((photo, index) => ( + removePhoto(index)} + /> + ))} + {photos.length < 50 && ( + + )} + + + + + + {/* Save Button */} + + + Salva + + + + + {/* Loading Overlay */} + {isSubmitting && ( + + + + + {uploadProgress ? 'Caricamento foto...' : 'Salvataggio...'} + + {uploadProgress && ( + + {uploadProgress.current} / {uploadProgress.total} + + )} + + + )} + + ); +} diff --git a/app/(protected)/journal/index.tsx b/app/(protected)/journal/index.tsx new file mode 100644 index 0000000..3ff2c50 --- /dev/null +++ b/app/(protected)/journal/index.tsx @@ -0,0 +1,196 @@ +import { useAlert } from '@/components/AlertComponent'; +import LoadingScreen from '@/components/LoadingScreen'; +import api from '@/utils/api'; +import { ImageIcon, Plus, Filter, Newspaper } 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 } 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([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Filters state + const [showFilterModal, setShowFilterModal] = useState(false); + const [places, setPlaces] = useState([]); + const [filterRange, setFilterRange] = useState<{ startDate: string | null; endDate: string | null }>({ startDate: null, endDate: null }); + const [filterPlace, setFilterPlace] = useState(null); + + const activeFiltersCount = (filterRange.startDate ? 1 : 0) + (filterPlace ? 1 : 0); + + const fetchPlaces = async () => { + try { + const response = await api.get('/place/get-places'); + if (response.data?.success) { + setPlaces(response.data.places || []); + } + } 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, place: currentPlace }; + const response = await api.post('/journal/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 ; + } + + return ( + + + {/* Header */} + + + + Elenco invii al giornale + Giornale di Cantiere + + + + + + + + + {/* List */} + } + > + {updates.length === 0 ? ( + + Nessun invio registrato al giornale di cantiere + + ) : ( + + {updates.map((item, index) => ( + + + + {item.place_name} + + + {item.place_address} + + + {item.description} + + + + + + + + + + + {item.n_files} File Inviati + + {item.date} + + + {item.n_files > 0 && ( + router.push(`/journal/${item.id}`)} + > + Vedi + + )} + + + ))} + + )} + + + {/* FAB Add Journal */} + router.push('/journal/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" + > + + + + {/* FAB Filter */} + 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" + > + + {activeFiltersCount > 0 && ( + + {activeFiltersCount} + + )} + + + setShowFilterModal(false)} + onApply={handleApplyFilters} + onReset={handleResetFilters} + /> + + + ); +} diff --git a/app/(protected)/machine/index.tsx b/app/(protected)/machine/index.tsx new file mode 100644 index 0000000..85e418b --- /dev/null +++ b/app/(protected)/machine/index.tsx @@ -0,0 +1,249 @@ +import { useAlert } from '@/components/AlertComponent'; +import MachineCard from '@/components/MachineCard'; +import FilterModal from '@/components/FilterModal'; +import LoadingScreen from '@/components/LoadingScreen'; +import QrScanModal from '@/components/QrScanModal'; +import { StatusBar } from 'expo-status-bar'; +import api from '@/utils/api'; +import { formatTime } from '@/utils/dateTime'; +import { CarFront, CheckCircle2, Filter, 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 { MachineAttendanceItem } from '@/types/types'; + +export default function MachineScreen() { + const alert = useAlert(); + const [showScanner, setShowScanner] = useState(false); + const [lastScan, setLastScan] = useState<{ type: string; time: string; site: string } | null>(null); + const [attendances, setAttendances] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Filters state + const [isFilterVisible, setIsFilterVisible] = useState(false); + const [filterRange, setFilterRange] = useState<{ startDate: string | null; endDate: string | null }>({ startDate: null, endDate: null }); + const [filterMachine, setFilterMachine] = useState(null); + + const activeFiltersCount = (filterRange.startDate ? 1 : 0) + (filterMachine ? 1 : 0); + + const fetchAttendances = async (currentRange = filterRange, currentMachine = filterMachine) => { + try { + if (!refreshing) setIsLoading(true); + + const rangeParam = currentRange.startDate ? currentRange : null; + const params = { range: rangeParam, machine: currentMachine }; + const response = await api.post('/machine-attendance/list', { params }); + + if (response.data?.success) { + setAttendances(response.data.data || []); + } + } catch (error) { + console.error('Errore nel recupero degli utilizzi macchine:', error); + alert.showAlert('error', 'Errore', 'Impossibile recuperare i dati. Riprova più tardi.'); + } finally { + setIsLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchAttendances(); + setLastScan(null); + }, []); + + 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('/machine-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 operazione.'); + } + } catch (error) { + console.error('Errore nell\'invio dei dati di scansione:', error); + alert.showAlert('error', 'Errore', 'Impossibile registrare operazione. Riprova più tardi.'); + return; + } + }; + + const handleExitPress = (item: MachineAttendanceItem) => { + alert.showConfirm( + 'Conferma Uscita', + `Vuoi davvero registrare l'uscita da ${item.name}?`, + [ + { text: 'Annulla', onPress: () => {}, style: 'cancel' }, + { text: 'Conferma', style: 'default', onPress: async () => { + try { + const response = await api.post('/machine-attendance/out', { uuid: item.machine_uuid }); + if (response.data?.success) { + fetchAttendances(); + } else { + alert.showAlert('error', 'Errore', response.data?.message || 'Impossibile registrare l\'uscita.'); + } + } catch (error) { + alert.showAlert('error', 'Errore', 'Impossibile registrare l\'uscita. Riprova più tardi.'); + } + }} + ] + ); + }; + + if (isLoading && !refreshing) { + return ; + } + + return ( + + + {/* Header */} + + + + Elenco degli utilizzi dei mezzi + Macchine + + + + + + + + + + } + > + + + {/* Feedback Card */} + {lastScan ? ( + + + + + + + {lastScan.type} Registrata + + + + {lastScan.site} alle {lastScan.time} + + + + + ) : null} + + {/* Scanner Section */} + + + Scansione QR Code + + + + Scansiona Codice + + + + Posiziona il codice QR davanti alla fotocamera per registrare l'ingresso o l'uscita dalla macchina + + + + + {/* History using MachineCard component */} + + Ultimi Utilizzi + {attendances.length === 0 ? ( + + Nessun utilizzo registrato + + ) : ( + + {attendances.map((item, index) => ( + + ))} + + )} + + + + + + {/* FAB Filter */} + 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" + > + + {activeFiltersCount > 0 && ( + + {activeFiltersCount} + + )} + + + {/* Filter Modal */} + setIsFilterVisible(false)} + onApply={(range, place, supplier, machine) => { + setFilterRange(range); + setFilterMachine(machine); + setIsFilterVisible(false); + fetchAttendances(range, machine); + }} + onReset={() => { + const emptyRange = { startDate: null, endDate: null }; + setFilterRange(emptyRange); + setFilterMachine(null); + setIsFilterVisible(false); + fetchAttendances(emptyRange, null); + }} + /> + + {/* Scanner Modal */} + setShowScanner(false)} + onScan={onScan} + /> + + + ); +} diff --git a/app/(protected)/permits/index.tsx b/app/(protected)/permits/index.tsx new file mode 100644 index 0000000..8898b65 --- /dev/null +++ b/app/(protected)/permits/index.tsx @@ -0,0 +1,288 @@ +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, Plus, Thermometer, Trash2 } 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'; + +export const hardcodedTypes: TimeOffRequestType[] = [ + { id: 1, name: 'Ferie', color: '#1071C2', time_required: 0 }, + { id: 2, name: 'Permesso', color: '#FCBE1F', time_required: 1 }, + { id: 3, name: 'Malattia', color: '#DC4437', time_required: 0 }, + { id: 4, name: 'Assenza', color: '#8F9BB3', time_required: 0 }, +]; + +// Icon Mapping +const typeIcons: Record JSX.Element> = { + Ferie: (color) => , + Permesso: (color) => , + Malattia: (color) => , + Assenza: (color) => , +}; + +export default function PermitsScreen() { + const [showModal, setShowModal] = useState(false); + const alert = useAlert(); + const [permits, setPermits] = useState([]); + const [types, setTypes] = useState([]); + const [currentMonthDate, setCurrentMonthDate] = useState(new Date()); + const [isLoading, setIsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const fetchPermits = async () => { + try { + + const response = await api.post('/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: hardcodedTypes.find(t => t.name === r.type) || hardcodedTypes[0], + })); + + setPermits(mappedPermits); + setTypes(hardcodedTypes); + } 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 | null) => { + try { + itemRef?.close(); + const res = await api.get(`/request/delete-request?id=${id}`); + if (res.data.code === 200) { + // 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', '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 | 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 | null> + ) => { + return ( + confirmDelete(item, swipeableRef.current)} + className="bg-red-500 justify-center items-center px-6 rounded-3xl ml-3" + activeOpacity={0.7} + style={{ margin: 2 }} + > + + + Elimina + + + ); + }; + + if (isLoading && !refreshing) { + return ; + } + + return ( + + + setShowModal(false)} + onSubmit={(data) => { console.log('Richiesta:', data); fetchPermits(); }} + /> + + {/* Header */} + + + + Elenco delle tue richieste + Ferie e Permessi + + + + + + + + + + } + > + + {/* Calendar Widget */} + setCurrentMonthDate(date)} /> + + {/* Recent Requests List */} + + {filteredPermits.length === 0 ? ( + Nessuna richiesta di permesso questo mese + ) : ( + + Le tue richieste + {filteredPermits.map((item) => { + const swipeableRef = React.createRef>(); + const canDelete = item.status === null; // Solo "In Attesa" + const cardContent = ( + + + + {typeIcons[item.timeOffRequestType.name]?.(item.timeOffRequestType.color)} + + + + {item.timeOffRequestType.name} + + + {item.status === 1 ? 'Approvata' : item.status === 0 ? 'Rifiutata' : 'In Attesa'} + + + + + {item.message ? ( + {item.message} + ) : null} + + {formatDate(item.start_date?.toLocaleString())} {item.end_date ? `- ${formatDate(item.end_date.toLocaleString())}` : ''} + + {item.timeOffRequestType.name === 'Permesso' && ( + + {formatTime(item.start_time)} - {formatTime(item.end_time)} + + )} + + + + + ); + + // Wrappa solo richieste "In Attesa" con Swipeable + if (canDelete) { + return ( + + renderRightActions(progress, dragX, item, swipeableRef) + } + rightThreshold={40} + friction={2} + overshootFriction={8} + containerStyle={{ padding: 2 }} + > + {cardContent} + + ); + } + + // Richieste approvate senza swipe + return {cardContent}; + })} + + )} + + + + {/* FAB */} + 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" + > + + + + + ); +} diff --git a/app/(protected)/profile/_layout.tsx b/app/(protected)/profile/_layout.tsx new file mode 100644 index 0000000..b16564f --- /dev/null +++ b/app/(protected)/profile/_layout.tsx @@ -0,0 +1,10 @@ +import { Stack } from "expo-router"; + +export default function ProfileLayout() { + return ( + + + + + ); +} \ No newline at end of file diff --git a/app/(protected)/profile/documents.tsx b/app/(protected)/profile/documents.tsx new file mode 100644 index 0000000..3b90e8b --- /dev/null +++ b/app/(protected)/profile/documents.tsx @@ -0,0 +1,205 @@ +import { useAlert } from '@/components/AlertComponent'; +import LoadingScreen from '@/components/LoadingScreen'; +import api from '@/utils/api'; +import { downloadAndShareDocument } from '@/utils/documentUtils'; +import { useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { ChevronDown, ChevronLeft, Download, FileText, 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([]); + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(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('/registry/get-categories'); + 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(`/registry/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 ( + + ); + } + + // Get label for the selected category or default text + const selectedLabel = selectedCategory + ? categories.find(c => c.value === selectedCategory)?.label + : 'Filtra per tipologia...'; + + return ( + + + {/* Header */} + + + + router.back()} className="p-2 rounded-full active:bg-gray-100"> + + + + Documenti + + + + {/* Select / Dropdown Trigger and Reset */} + + 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" + > + + {selectedLabel} + + + + + {selectedCategory !== null && ( + handleCategorySelect(null)} + className="bg-gray-50 p-3.5 rounded-2xl border border-gray-200 shadow-sm justify-center items-center" + > + + + )} + + + + + + {/* Documents List */} + index.toString()} + contentContainerStyle={{ gap: 16, paddingBottom: 100 }} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + initialNumToRender={10} + maxToRenderPerBatch={15} + windowSize={5} + removeClippedSubviews={true} + renderItem={({ item: doc }) => ( + + + + + + + {doc.filename} + + {doc.date} + + + + downloadAndShareDocument(doc.mimetype, doc.filename, doc.url)} + className="p-4 bg-gray-50 rounded-2xl active:bg-gray-100 flex-shrink-0 border border-gray-100"> + + + + )} + ListEmptyComponent={() => ( + !isLoading ? ( + + Nessun documento trovato in questa categoria + + ) : null + )} + /> + + + {/* Modal Picker (Dropdown Custom) */} + setShowPicker(false)}> + setShowPicker(false)} + className="flex-1 bg-black/50 justify-end" + > + true}> + + Filtra per tipologia + setShowPicker(false)} className="p-2 bg-gray-100 rounded-full"> + + + + index.toString()} + initialNumToRender={10} + maxToRenderPerBatch={15} + windowSize={5} + removeClippedSubviews={true} + renderItem={({ item: cat }) => ( + handleCategorySelect(cat.value)} + > + + {cat.label} + + + )} + /> + + + + + ); +} \ No newline at end of file diff --git a/app/(protected)/profile/index.tsx b/app/(protected)/profile/index.tsx new file mode 100644 index 0000000..9bc66f0 --- /dev/null +++ b/app/(protected)/profile/index.tsx @@ -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 ( + + + + {/* Header Section */} + + + router.back()} + > + + + + + {initials} + + + Profilo + {user?.firstName} {user?.lastName} + + + + + + + + {/* Info Card - Enlarged Texts */} + + {/* Section title */} + Informazioni + + + + + + + + Email + {user?.email} + + + + + + + + + Ruolo + {user?.isAdmin ? 'Amministratore' : 'Utente'} + + + + + + {/* Actions */} + + Azioni + + 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"> + + + + + + I miei documenti + Visualizza i tuoi documenti + + + Apri + + + + + + + + + Esci + Chiudi la sessione corrente + + + Esci + + + + + ); +} diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..75c57c5 --- /dev/null +++ b/app/_layout.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/login.tsx b/app/login.tsx new file mode 100644 index 0000000..e5ab132 --- /dev/null +++ b/app/login.tsx @@ -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 ( + + + {/* Header with Logo/Title */} + + + + + + + {/* Form Container */} + + + + + Accedi + + Inserisci le tue credenziali per accedere + + + + {/* Input Username */} + + + + + + + + {/* Input Password */} + + + + + setShowPassword(!showPassword)}> + {showPassword ? ( + + ) : ( + + )} + + + + + + + {/* Login Button */} + + + + {isLoading ? 'ACCESSO IN CORSO...' : 'LOGIN'} + + {!isLoading && } + + + + + + + ); +} \ No newline at end of file diff --git a/assets/images/adaptive-icon.png b/assets/images/adaptive-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..584dcb2cd303c11e99b3a2734c727d1a5163449e GIT binary patch literal 484493 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