Initial commit

This commit is contained in:
2026-07-31 16:53:16 +02:00
commit e49d6f0e5b
67 changed files with 20163 additions and 0 deletions

5
.env.example Normal file
View File

@@ -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

48
.gitignore vendored Normal file
View File

@@ -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

50
README.md Normal file
View File

@@ -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.

68
app.json Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -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 <Redirect href="/login" />;
}
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: '#ffffff',
borderTopWidth: 1,
borderTopColor: '#f3f4f6',
height: 70 + insets.bottom,
paddingBottom: insets.bottom,
paddingTop: 10,
paddingHorizontal: 10,
},
tabBarActiveTintColor: '#1071C2',
tabBarInactiveTintColor: '#9ca3af',
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={24} />,
}}
/>
<Tabs.Screen
name="attendance/index"
options={{
title: 'Presenze',
tabBarIcon: ({ color, size }) => <Clock pointerEvents="none" color={color} size={24} />,
}}
/>
<Tabs.Screen
name="journal"
options={{
title: 'Giornale',
tabBarIcon: ({ color, size }) => <ImageIcon pointerEvents="none" color={color} size={24} />,
}}
/>
<Tabs.Screen
name="permits/index"
options={{
title: 'Ferie',
tabBarIcon: ({ color, size }) => <CalendarIcon pointerEvents="none" color={color} size={24} />,
}}
/>
<Tabs.Screen
name="invoice"
options={{
title: 'Fatture',
tabBarIcon: ({ color, size }) => <FileText pointerEvents="none" color={color} size={24} />,
href: authState.user?.isAdmin ? undefined : null,
}}
/>
<Tabs.Screen
name="machine/index"
options={{
title: 'Macchine',
tabBarIcon: ({ color, size }) => <Car pointerEvents="none" color={color} size={24} />,
href: authState.user?.isAdmin ? undefined : null,
}}
/>
<Tabs.Screen
name="profile"
options={{
href: null,
title: 'Profilo',
}}
/>
</Tabs>
);
}

View File

@@ -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<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('/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 <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>
);
}

192
app/(protected)/index.tsx Normal file
View File

@@ -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<any[]>([]);
const [attendances, setAttendances] = useState<any[]>([]);
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 (
<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-md font-semibold uppercase tracking-wider mb-2">
IP Costruzioni SRL
</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 */}
<ScrollView
className="flex-1 bg-gray-50 rounded-t-[2.5rem] px-5 pt-6"
contentContainerStyle={{ paddingBottom: 50, gap: 24 }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />
}
>
{/* Quick Actions */}
<View>
<Text className="text-gray-800 text-xl font-bold mb-4 px-1">Azioni Rapide</Text>
<View className="flex-row gap-5">
<TouchableOpacity
onPress={() => 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]"
>
<View className="w-20 h-20 rounded-full bg-blue-50 items-center justify-center mb-1">
<QrCode size={40} color="#1071C2" pointerEvents="none"/>
</View>
<Text className="text-lg font-bold text-gray-700 text-center">Nuova Presenza</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => 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]"
>
<View className="w-20 h-20 rounded-full bg-blue-50 items-center justify-center mb-1">
<FileText size={40} color="#1071C2" pointerEvents="none"/>
</View>
<Text className="text-lg font-bold text-gray-700 text-center">Nuovo{'\n'} Giornale</Text>
</TouchableOpacity>
</View>
</View>
{/* Journal */}
<View>
<View className="flex-row justify-between items-center px-1 mb-4">
<Text className="text-gray-800 text-xl font-bold">Giornale di Cantiere</Text>
</View>
<View className="gap-4">
{updates.map((item, index) => (
<View key={index} className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-row items-center">
<View className="bg-blue-50 p-4 rounded-full mr-4 flex-shrink-0">
<FileText size={24} color="#1071C2" pointerEvents="none"/>
</View>
<View className="flex-1 mr-2">
<Text className="text-base font-bold text-primary-dark mb-1 leading-tight uppercase" numberOfLines={2}>
{item.place_name}
</Text>
<Text className="text-xs font-medium text-primary-dark mb-1 leading-tight" numberOfLines={2}>
{item.place_address}
</Text>
<Text className="text-xs font-bold text-gray-400 mt-1">
{item.date}
</Text>
</View>
</View>
))}
{!isLoading && updates.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 aggiornamento recente</Text>
</View>
)}
{isLoading && updates.length === 0 && (
<View className="bg-white p-5 rounded-3xl border border-gray-100 h-24 justify-center items-center">
<Text className="text-gray-400">Caricamento...</Text>
</View>
)}
</View>
</View>
{/* Attendance */}
<View>
<View className="flex-row justify-between items-center px-1 mb-4">
<Text className="text-gray-800 text-xl font-bold">Presenze</Text>
</View>
<View className="gap-4">
{attendances.map((item, index) => (
<AttendanceCard key={index} item={item} />
))}
{!isLoading && 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 recente</Text>
</View>
)}
{isLoading && attendances.length === 0 && (
<View className="bg-white p-5 rounded-3xl border border-gray-100 h-24 justify-center items-center">
<Text className="text-gray-400">Caricamento...</Text>
</View>
)}
</View>
</View>
</ScrollView>
</View>
);
}

View File

@@ -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<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">
<View className="mt-0.5">
<CreditCard size={14} color="#8F9BB3" />
</View>
<Text className="text-xs uppercase text-gray-500 font-medium shrink flex-wrap">{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>
);
}

View File

@@ -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>
);
}

View File

@@ -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<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('/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 <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</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 */}
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 20, paddingBottom: 100, gap: 16 }}
showsVerticalScrollIndicator={true}
scrollIndicatorInsets={{ right: 1 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />}
>
{invoices.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">Nessuna fattura trovata</Text>
</View>
) : (
invoices.map((invoice) => (
<InvoiceCard key={invoice.id} item={invoice} onPress={handleInvoicePress} />
)))}
</ScrollView>
{/* 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>
);
}

View File

@@ -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<string>('');
const [photos, setPhotos] = useState<any[]>([]);
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<number | null>(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 <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 }));
// Custom Header for the ImageView
const CustomHeader = ({ imageIndex }: { imageIndex: number }) => {
const currentPhoto = photos[imageIndex];
if (!currentPhoto) return null;
return (
<View
className="bg-white/95 shadow-sm border-b border-gray-100"
style={{ paddingTop: insets.top }}
>
<View className="flex-row items-center justify-between px-4 py-3">
<View className="flex-row items-center gap-3 flex-1 mr-4">
<TouchableOpacity onPress={() => setIsVisible(false)} className="p-2 -ml-2 active:opacity-70">
<ChevronLeft size={28} color="#082963" />
</TouchableOpacity>
<Text className="text-[#082963] text-lg font-bold flex-1" numberOfLines={2}>{currentPhoto.date || 'Dettaglio'}</Text>
</View>
<View className="flex-row items-center gap-1">
<TouchableOpacity onPress={() => {
setEditIndex(imageIndex);
setDescModalVisible(true);
}} className="p-2 active:opacity-70">
<Pencil size={24} color="#082963" />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleShare(currentPhoto.uri)} disabled={isSharing} className={`p-2 active:opacity-70 ${isSharing ? 'opacity-50' : ''}`}>
<Share2 size={24} color="#082963" />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(currentPhoto.id)} className="p-2 active:opacity-70">
<Trash2 size={24} color="#082963" />
</TouchableOpacity>
</View>
</View>
</View>
);
};
// Custom Footer for the ImageView
const CustomFooter = ({ imageIndex }: { imageIndex: number }) => {
const currentPhoto = photos[imageIndex];
if (!currentPhoto || !currentPhoto.description) return null;
return (
<View
className="bg-black/60"
style={{ paddingBottom: insets.bottom }}
>
<View className="px-6 py-5">
<Text className="text-white text-center text-base font-medium leading-relaxed">
{currentPhoto.description}
</Text>
</View>
</View>
);
};
return (
<View className="flex-1 bg-gray-50">
{/* 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"
numberOfLines={2}
ellipsizeMode="tail"
>
{placeName || 'Dettaglio Giornale'}
</Text>
</View>
</SafeAreaView>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 100 }}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />}
>
{photos.length === 0 ? (
<View className="bg-white p-8 rounded-3xl border border-gray-100 items-center justify-center border-dashed mt-4">
<ImageIcon size={48} color="#d1d5db" />
<Text className="text-gray-400 font-medium text-center mt-4">Nessuna foto presente per questo giornale.</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>
{item.description && (
<Text className="text-xs text-gray-500 mt-2 text-center" numberOfLines={1}>
{item.description}
</Text>
)}
</TouchableOpacity>
))}
</View>
)}
</ScrollView>
{/* Lightbox / Fullscreen viewer */}
<ImageView
images={imageSource}
imageIndex={currentIndex}
visible={isVisible}
onRequestClose={() => setIsVisible(false)}
HeaderComponent={CustomHeader}
FooterComponent={CustomFooter}
backgroundColor={"#EDF1F7"}
presentationStyle={'fullScreen'}
/>
<SetDescriptionModal
visible={descModalVisible}
initialDescription={editIndex !== null ? (photos[editIndex]?.description || '') : ''}
onClose={() => setDescModalVisible(false)}
onSave={handleDescription}
/>
</View>
);
}

View File

@@ -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>
);
}

View File

@@ -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<Place[]>([]);
const [selectedPlaceId, setSelectedPlaceId] = useState<any>(null);
const [description, setDescription] = useState('');
const [photos, setPhotos] = useState<any[]>([]);
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 (
<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 Aggiornamento
</Text>
</View>
</SafeAreaView>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
className="flex-1"
>
<ScrollView
contentContainerStyle={{ padding: 24, paddingBottom: 100 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Place Filter Component */}
<View className="border-b border-gray-200 mb-4">
<PlaceFilter
places={places}
selectedPlaceId={selectedPlaceId}
onPlaceSelect={setSelectedPlaceId}
textColor="text-primary-dark"
/>
</View>
{/* Description Area */}
<View className="pb-8 border-b border-gray-200 mb-6">
<Text className="text-lg font-bold text-primary-dark mb-3">Descrizione</Text>
<View className="bg-white rounded-2xl shadow-sm border border-gray-200">
<TextInput
className="p-4 text-base text-gray-800"
placeholder="(opzionale)"
placeholderTextColor="#9ca3af"
multiline
value={description}
onChangeText={setDescription}
style={{ minHeight: 80, textAlignVertical: 'top' }}
/>
</View>
</View>
{/* Attachments Area */}
<View className="pb-8 border-b border-transparent mb-6">
<View className="flex-row items-center justify-between mb-4">
<Text className="text-lg font-bold text-gray-700">Allegati <Text className="text-sm font-normal text-gray-500">({photos.length}/50)</Text></Text>
{photos.length < 50 && (
<TouchableOpacity activeOpacity={0.7} onPress={pickFromGallery}>
<Text className="text-[#1071C2] font-bold text-base">Aggiungi</Text>
</TouchableOpacity>
)}
</View>
<View className="flex-row flex-wrap" style={{ gap: gap }}>
{photos.map((photo, index) => (
<RemovablePhotoTile
key={index}
uri={photo.uri}
size={itemSize}
onRemove={() => removePhoto(index)}
/>
))}
{photos.length < 50 && (
<CameraAddTile
size={itemSize}
onPress={takePhoto}
/>
)}
</View>
</View>
</ScrollView>
{/* Save Button */}
<View className="px-6 pt-2 pb-8">
<TouchableOpacity
className={`w-full py-4 rounded-[2rem] shadow-sm active:scale-[0.98] ${selectedPlaceId ? 'bg-[#1071C2]' : 'bg-gray-300'}`}
disabled={!selectedPlaceId || isSubmitting}
onPress={handleSave}
>
<Text className={`text-center uppercase font-bold text-lg ${selectedPlaceId ? 'text-white' : 'text-gray-500'}`}>Salva</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
{/* Loading Overlay */}
{isSubmitting && (
<View className="absolute inset-0 bg-black/60 items-center justify-center z-50">
<View className="bg-white p-8 rounded-3xl items-center shadow-2xl min-w-[200px]">
<ActivityIndicator size="large" color="#1071C2" className="mb-4" />
<Text className="text-gray-800 text-lg font-bold text-center mb-1">
{uploadProgress ? 'Caricamento foto...' : 'Salvataggio...'}
</Text>
{uploadProgress && (
<Text className="text-[#1071C2] font-black text-xl text-center mt-2">
{uploadProgress.current} / {uploadProgress.total}
</Text>
)}
</View>
</View>
)}
</View>
);
}

View File

@@ -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<any[]>([]);
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('/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 <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 invii al giornale</Text>
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Giornale di Cantiere</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 al giornale di cantiere</Text>
</View>
) : (
<View className="gap-4">
{updates.map((item, index) => (
<View key={index} className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100">
<View className="mb-3">
<Text className="font-bold text-[#082963] text-lg uppercase tracking-wide leading-tight">
{item.place_name}
</Text>
<Text className="text-gray-500 text-sm font-medium mt-1">
{item.place_address}
</Text>
<Text className="text-gray-400 text-base leading-relaxed mt-1">
{item.description}
</Text>
</View>
<View className="flex-row items-center justify-between border-t border-gray-50 pt-4">
<View className="flex-row items-center gap-4">
<View className="bg-blue-50 p-3 rounded-2xl">
<ImageIcon size={24} color="#082963" />
</View>
<View>
<Text className="font-bold text-primary text-lg">
{item.n_files} File Inviati
</Text>
<Text className="text-gray-400 font-bold text-sm mt-0.5">{item.date}</Text>
</View>
</View>
{item.n_files > 0 && (
<TouchableOpacity
className="bg-gray-100 px-4 py-2 rounded-xl active:bg-gray-200"
onPress={() => router.push(`/journal/${item.id}`)}
>
<Text className="text-gray-600 font-bold text-sm">Vedi</Text>
</TouchableOpacity>
)}
</View>
</View>
))}
</View>
)}
</ScrollView>
{/* FAB Add Journal */}
<TouchableOpacity
onPress={() => 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"
>
<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>
);
}

View File

@@ -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<MachineAttendanceItem[]>([]);
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<any>(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 <LoadingScreen />;
}
return (
<View className="flex-1 bg-gray-50">
<StatusBar style="light" />
{/* Header */}
<SafeAreaView edges={['top']} className='pt-5 bg-primary-dark'>
<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 degli utilizzi dei mezzi</Text>
<Text className="text-yellow-400 text-3xl font-bold leading-tight">Macchine</Text>
</View>
<View className="bg-white/10 p-4 rounded-full">
<CarFront 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">
{/* 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 dalla macchina
</Text>
</View>
</View>
{/* History using MachineCard component */}
<View className="w-full mt-4">
<Text className="text-gray-500 font-bold text-base mb-4 uppercase tracking-wider px-2">Ultimi Utilizzi</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">Nessun utilizzo registrato</Text>
</View>
) : (
<View>
{attendances.map((item, index) => (
<MachineCard key={index} item={item} onExitPress={handleExitPress} />
))}
</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}
showPlace={false}
showMachine={true}
currentRange={filterRange}
currentMachine={filterMachine}
onClose={() => 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 */}
<QrScanModal
visible={showScanner}
onClose={() => setShowScanner(false)}
onScan={onScan}
/>
</View>
</View>
);
}

View File

@@ -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<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" />,
};
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 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<typeof Swipeable> | 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<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]?.(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>
);
}

View File

@@ -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>
);
}

View File

@@ -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<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('/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 (
<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, paddingBottom: 100 }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#1071C2']} />
}
initialNumToRender={10}
maxToRenderPerBatch={15}
windowSize={5}
removeClippedSubviews={true}
renderItem={({ item: doc }) => (
<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}>{doc.filename}</Text>
<View className="flex-row items-center mt-2">
<Text className="text-sm text-gray-400 font-bold">{doc.date}</Text>
</View>
</View>
</View>
<TouchableOpacity
onPress={() => 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">
<Download size={24} color="#1071C2" pointerEvents="none" />
</TouchableOpacity>
</View>
)}
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>
);
}

View File

@@ -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">
<View className="w-14 h-14 bg-blue-50 rounded-2xl items-center justify-center">
<Mail size={24} color="#1071C2" pointerEvents="none" />
</View>
<View>
<Text className="text-lg text-gray-700 font-bold">Email</Text>
<Text className="text-gray-500 text-base">{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>
);
}

32
app/_layout.tsx Normal file
View File

@@ -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>
);
}

168
app/login.tsx Normal file
View File

@@ -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-gray-50 border border-gray-100 rounded-2xl h-16 px-4 flex">
<User size={24} color="#9ca3af" pointerEvents="none" />
<TextInput
className="flex-1 ml-4 text-gray-800 text-lg font-medium h-full w-full"
placeholder="Username"
placeholderTextColor="#9ca3af"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
/>
</View>
</View>
{/* Input Password */}
<View>
<View className="flex-row items-center bg-gray-50 border border-gray-100 rounded-2xl h-16 px-4 flex">
<Lock size={24} color="#9ca3af" pointerEvents="none" />
<TextInput
className="flex-1 ml-4 text-gray-800 text-lg font-medium h-full w-full"
placeholder="Password"
placeholderTextColor="#9ca3af"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={24} color="#6b7280" pointerEvents="none" />
) : (
<Eye size={24} color="#6b7280" 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

BIN
assets/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

9
babel.config.js Normal file
View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -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);

View File

@@ -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,
}}
/>
);
};

View File

@@ -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-base font-bold text-primary-dark mb-1 leading-tight uppercase" numberOfLines={2}>
{item.place}
</Text>
<Text className="text-xs font-medium text-primary-dark mb-1 leading-tight" numberOfLines={2}>
{item.address}
</Text>
<Text className="text-xs font-bold text-gray-400 mt-1">
{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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

149
components/FilterModal.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { AppDatePicker } from '@/components/AppDatePicker';
import PlaceFilter from '@/components/PlaceFilter';
import SupplierFilter from '@/components/SupplierFilter';
import MachineFilter from '@/components/MachineFilter';
import { Machine, 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[];
currentRange?: { startDate: string | null; endDate: string | null };
currentPlace?: Place | null;
currentSupplier?: Supplier | null;
currentMachine?: Machine | null;
showDate?: boolean;
showPlace?: boolean;
showSupplier?: boolean;
showMachine?: boolean;
onClose: () => void;
onApply: (range: { startDate: string | null; endDate: string | null }, place: Place | null, supplier: Supplier | null, machine: Machine | null) => void;
onReset: () => void;
}
export default function FilterModal({
visible,
places = [],
currentRange = { startDate: null, endDate: null },
currentPlace = null,
currentSupplier = null,
currentMachine = null,
showDate = true,
showPlace = true,
showSupplier = false,
showMachine = false,
onClose,
onApply,
onReset,
}: FilterModalProps) {
const [localRange, setLocalRange] = useState<{ startDate: string | null; endDate: string | null }>(currentRange);
const [localPlace, setLocalPlace] = useState<any>(currentPlace);
const [localSupplier, setLocalSupplier] = useState<any>(currentSupplier);
const [localMachine, setLocalMachine] = useState<any>(currentMachine);
// Sync local state when modal opens
useEffect(() => {
if (visible) {
setLocalRange(currentRange);
setLocalPlace(currentPlace);
setLocalSupplier(currentSupplier);
setLocalMachine(currentMachine);
}
}, [visible, currentRange, currentPlace, currentSupplier, currentMachine]);
const handleApply = () => {
onApply(localRange, localPlace, localSupplier, localMachine);
};
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}
/>
)}
{/* Supplier Selection */}
{showSupplier && (
<SupplierFilter
selectedSupplierId={localSupplier}
onSupplierSelect={setLocalSupplier}
/>
)}
{/* Machine Selection */}
{showMachine && (
<MachineFilter
selectedMachineId={localMachine}
onMachineSelect={setLocalMachine}
/>
)}
{/* 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>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -0,0 +1,57 @@
import { AlertTriangle, Car, KeySquare } from 'lucide-react-native';
import React from 'react';
import { Text, View, TouchableOpacity } from 'react-native';
import { MachineAttendanceItem } from '@/types/types';
interface MachineCardProps {
item: MachineAttendanceItem;
onExitPress: (item: MachineAttendanceItem) => void;
}
export default function MachineCard({ item, onExitPress }: MachineCardProps) {
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">
<Car size={24} color="#1071C2" pointerEvents="none"/>
</View>
<View className="flex-1 mr-2">
<Text className="text-base font-bold text-primary-dark mb-1 leading-tight uppercase" numberOfLines={2}>
{item.name}
</Text>
<Text className="text-xs font-medium text-primary-dark mb-1 leading-tight" numberOfLines={2}>
{item.description}
</Text>
<Text className="text-xs font-bold text-gray-400 mt-1">
{item.date}
</Text>
</View>
</View>
<View className="flex-row items-center flex-wrap gap-x-4 gap-y-3 px-1 justify-start">
<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>
) : (
<View className="flex-row items-center flex-1 justify-end">
<TouchableOpacity
onPress={() => onExitPress(item)}
className="bg-red-50 px-4 py-2 rounded-full border border-red-200 flex-row items-center shadow-sm active:bg-red-100"
>
<KeySquare size={14} color="#dc2626" />
<Text className="text-red-600 font-bold ml-2 text-sm">Registra Uscita</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1,165 @@
import { Machine } 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 } from 'react-native';
interface MachineFilterProps {
selectedMachineId: any;
onMachineSelect: (machineId: any) => void;
textColor?: string;
}
export default function MachineFilter({ selectedMachineId, onMachineSelect, textColor }: MachineFilterProps) {
const [showPicker, setShowPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [machines, setMachines] = useState<Machine[]>([]);
useEffect(() => {
const fetchMachines = async () => {
try {
const res = await api.get('/machine-attendance/get-machines');
if (res.data?.success) {
setMachines(res.data.machines || []);
}
} catch (err) {
console.error('Error fetching machines:', err);
}
};
fetchMachines();
}, []);
// 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 filteredMachines = useMemo(() => {
if (!showPicker) return [];
if (!searchQuery.trim()) return machines;
const lowerQuery = searchQuery.toLowerCase();
return machines.filter(s => s.label.toLowerCase().includes(lowerQuery));
}, [machines, searchQuery, showPicker]);
const selectedMachineLabel = selectedMachineId
? machines.find(s => s.id === selectedMachineId)?.label || 'Macchina Selezionata'
: 'Tutte le macchine';
return (
<View className="mb-8">
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Macchina</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 ${selectedMachineId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
{selectedMachineLabel}
</Text>
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
</TouchableOpacity>
{selectedMachineId !== null && (
<TouchableOpacity
onPress={() => onMachineSelect(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 Macchina</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 macchina..."
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={filteredMachines}
keyExtractor={(item) => item.id.toString()}
ListHeaderComponent={() => (
searchQuery.trim() === '' ? (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedMachineId === null ? 'bg-blue-50' : ''}`}
onPress={() => { onMachineSelect(null); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedMachineId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
Tutte le macchine
</Text>
</TouchableOpacity>
) : null
)}
ListEmptyComponent={() => (
<View className="py-8 items-center">
<Text className="text-gray-500 font-medium text-center">Nessuna macchina trovata per "{searchQuery}"</Text>
</View>
)}
renderItem={({ item }) => (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedMachineId === item.id ? 'bg-blue-50' : ''}`}
onPress={() => { onMachineSelect(item.id); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedMachineId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
{item.label}
</Text>
</TouchableOpacity>
)}
/>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}

View File

@@ -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>
);
}

154
components/PlaceFilter.tsx Normal file
View File

@@ -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>
);
}

102
components/QrScanModal.tsx Normal file
View File

@@ -0,0 +1,102 @@
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, Modal, TouchableOpacity, Vibration, StyleSheet, Dimensions } 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 } = Dimensions.get('window');
const squareSize = Math.min(width * 0.8, height * 0.8, 400);
// Permission Handling and Reset Scanned State on Modal Open
useEffect(() => {
if (visible) {
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 />;
}
if (!permission.granted && visible) {
requestPermission();
}
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"],
}}
/>
{/* Dark Overlay with Transparent "Hole" (Visually Simulated with Borders or Opacity) */}
<SafeAreaView className="flex-1 justify-between bg-black/60 pt-8">
{/* Header Overlay */}
<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>
{/* Central Area (Transparent for the camera) */}
<View className="items-center justify-center" style={{ height: squareSize }}>
<View style={{ width: squareSize, height: squareSize }}
className="border-2 border-[#1071C2] bg-transparent relative justify-center items-center">
{/* Decorative Corners */}
<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]" />
{/* Animated Scan Line or Icon */}
{!scanned && <ScanLine color="#1071C2" size={40} className="opacity-50" pointerEvents="none" />}
</View>
</View>
{/* Footer Overlay */}
<View className="items-center justify-end pb-12">
<TouchableOpacity onPress={onClose} className="bg-white/20 p-4 rounded-full">
<X color="white" size={32} pointerEvents="none" />
</TouchableOpacity>
<Text className="text-white mt-4 font-medium">Chiudi</Text>
</View>
</SafeAreaView>
</View>
</Modal>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,283 @@
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('/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 = {
type: type.name,
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)}
className={`py-4 px-5 rounded-xl border-2 items-center justify-center ${type?.id === t.id ? 'border-[#1071C2] bg-blue-50' : 'border-gray-100 bg-white'
}`}
>
<Text className={`text-sm font-bold ${type?.id === t.id ? 'text-[#1071C2]' : '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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

21
eas.json Normal file
View File

@@ -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": {}
}
}

10
eslint.config.js Normal file
View File

@@ -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/*'],
},
]);

3
global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

6
metro.config.js Normal file
View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname)
module.exports = withNativeWind(config, { input: './global.css' })

1
nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

14658
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"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"
},
"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",
"axios": "^1.13.2",
"babel-preset-expo": "~54.0.10",
"expo": "~54.0.36",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.10",
"expo-dev-client": "~6.0.20",
"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"
]
}
}
}
}

30
tailwind.config.js Normal file
View File

@@ -0,0 +1,30 @@
/** @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: {
primary: {
DEFAULT: '#1071C2', // Blue action (Buttons, highlights, links)
dark: '#082963', // Dark blue (Headers, Main backgrounds, dark icons)
},
background: {
DEFAULT: '#EDF1F7', // Light gray/blue app general background
paper: '#FFFFFF', // Card / form backgrounds
},
text: {
DEFAULT: '#082963', // Main text color
secondary: '#8F9BB3', // Subtitles, captions, disabled text
},
status: {
success: '#109D59', // Checkmarks, in-attendance
danger: '#DC4437', // Close, out-attendance
warning: '#FCBE1F', // Questions, alerts
}
}
},
},
plugins: [],
}

18
tsconfig.json Normal file
View File

@@ -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"
]
}

92
types/types.ts Normal file
View File

@@ -0,0 +1,92 @@
import { DateType } from "react-native-ui-datepicker";
export interface UserData {
firstName: string;
lastName: string;
email?: string;
isAdmin: boolean;
}
export interface AttendanceRecord {
id: number;
place: string;
address: 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 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 Machine {
id: number;
label: string;
type?: string;
}
export interface MachineAttendanceItem {
id: number;
machine_uuid: string;
name: string;
description: string;
date: string;
in: string;
out: string | null;
}

65
utils/api.ts Normal file
View File

@@ -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;

124
utils/authContext.tsx Normal file
View File

@@ -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>
);
}

87
utils/configProvider.tsx Normal file
View File

@@ -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>
);
};

91
utils/dateTime.ts Normal file
View File

@@ -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`;
}

105
utils/documentUtils.tsx Normal file
View File

@@ -0,0 +1,105 @@
import api from '@/utils/api';
import { Directory, File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
/**
* Handles upload of a document through the server using FormData
* @param file File to upload (must have at least the 'uri' property)
* @param siteId ID of the site to associate the document with (null for general register)
* @param customTitle Custom title for the document (optional)
*/
export const uploadDocument = async (
file: any,
siteId: number | null,
customTitle?: string
): Promise<void> => {
if (!file || !file.uri) {
throw new Error("File non valido per l'upload.");
}
try {
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: customTitle || file.name,
type: file.mimeType
} as any);
if (siteId !== null) {
formData.append('siteId', siteId.toString());
}
if (customTitle) {
formData.append('customTitle', customTitle.trim());
}
const response = await api.post('/attachment/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
console.log("Risposta server:", response.data);
if (response.data?.status === 'error') {
throw new Error(response.data.message || "Errore sconosciuto dal server");
}
} 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;
}
};

42
utils/networkProvider.tsx Normal file
View File

@@ -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}</>;
};

27
utils/version.ts Normal file
View File

@@ -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;
};