feat: Implement document management features
- Added a new DocumentsScreen for managing user documents with search and date filtering capabilities. - Created AddDocumentModal for uploading documents with file selection and custom title options. - Introduced SiteDocumentsScreen to display documents related to specific construction sites. - Implemented SitesScreen for listing construction sites with search functionality. - Updated ProfileScreen to link to the new DocumentsScreen. - Refactored RangePickerModal for selecting date ranges in document filtering. - Improved date formatting utilities for better timestamp handling. - Added necessary API calls for document and site management. - Updated types to reflect changes in document structure and site information. - Added expo-document-picker dependency for document selection functionality.
This commit is contained in:
@ -1,5 +1,5 @@
|
|||||||
import { Redirect, Tabs } from 'expo-router';
|
import { Redirect, Tabs } from 'expo-router';
|
||||||
import { Home, Clock, FileText, Zap, CalendarIcon } from 'lucide-react-native';
|
import { Home, Clock, Zap, CalendarIcon, Building } from 'lucide-react-native';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { AuthContext } from '@/utils/authContext';
|
import { AuthContext } from '@/utils/authContext';
|
||||||
|
|
||||||
@ -59,10 +59,10 @@ export default function ProtectedLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="documents/index"
|
name="sites"
|
||||||
options={{
|
options={{
|
||||||
title: 'Moduli',
|
title: 'Cantieri',
|
||||||
tabBarIcon: ({ color, size }) => <FileText color={color} size={24} />,
|
tabBarIcon: ({ color, size }) => <Building color={color} size={24} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* // TODO: Rimuovere all'utente e mostrare solo a admin */}
|
{/* // TODO: Rimuovere all'utente e mostrare solo a admin */}
|
||||||
|
|||||||
@ -1,12 +1,41 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { use, useEffect, useState } from 'react';
|
||||||
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
|
import { View, Text, TouchableOpacity, ScrollView, Alert, RefreshControl } from 'react-native';
|
||||||
import { QrCode, CheckCircle2 } from 'lucide-react-native';
|
import { QrCode, CheckCircle2 } from 'lucide-react-native';
|
||||||
import { ATTENDANCE_DATA } from '@/data/data';
|
import { ATTENDANCE_DATA } from '@/data/data';
|
||||||
import QrScanModal from '@/components/QrScanModal';
|
import QrScanModal from '@/components/QrScanModal';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
|
||||||
export default function AttendanceScreen() {
|
export default function AttendanceScreen() {
|
||||||
const [showScanner, setShowScanner] = useState(false);
|
const [showScanner, setShowScanner] = useState(false);
|
||||||
const [lastScan, setLastScan] = useState<{ type: string; time: string; site: string } | null>(null);
|
const [lastScan, setLastScan] = useState<{ type: string; time: string; site: string } | null>(null);
|
||||||
|
const [attendances, setAttendances] = useState(ATTENDANCE_DATA);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const fetchAttendances = async () => {
|
||||||
|
try {
|
||||||
|
if (!refreshing) setIsLoading(true);
|
||||||
|
// Fetch today's attendance data from API
|
||||||
|
const response = await api.get('/attendance/list');
|
||||||
|
// setAttendances(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel recupero delle presenze:', error);
|
||||||
|
Alert.alert('Errore', 'Impossibile recuperare le presenze. Riprova più tardi.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttendances();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchAttendances();
|
||||||
|
};
|
||||||
|
|
||||||
const handleQRScan = () => {
|
const handleQRScan = () => {
|
||||||
setShowScanner(true);
|
setShowScanner(true);
|
||||||
@ -22,6 +51,10 @@ export default function AttendanceScreen() {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading && !refreshing) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -30,7 +63,13 @@ export default function AttendanceScreen() {
|
|||||||
<Text className="text-base text-gray-500">Registra i tuoi movimenti</Text>
|
<Text className="text-base text-gray-500">Registra i tuoi movimenti</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
|
<ScrollView
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} />
|
||||||
|
}
|
||||||
|
>
|
||||||
<View className="flex-1 p-5 items-center">
|
<View className="flex-1 p-5 items-center">
|
||||||
|
|
||||||
{/* Feedback Card - OPZIONALE */}
|
{/* Feedback Card - OPZIONALE */}
|
||||||
@ -69,7 +108,7 @@ export default function AttendanceScreen() {
|
|||||||
|
|
||||||
{/* Mini History */}
|
{/* Mini History */}
|
||||||
<View className="w-full mt-4">
|
<View className="w-full mt-4">
|
||||||
<Text className="text-gray-500 font-bold text-base mb-4 uppercase tracking-wider px-2">Oggi</Text>
|
<Text className="text-gray-500 font-bold text-base mb-4 uppercase tracking-wider px-2">Ultime Presenze</Text>
|
||||||
<View className="bg-white rounded-3xl shadow-sm overflow-hidden border border-gray-100">
|
<View className="bg-white rounded-3xl shadow-sm overflow-hidden border border-gray-100">
|
||||||
{ATTENDANCE_DATA.map((item, index) => (
|
{ATTENDANCE_DATA.map((item, index) => (
|
||||||
<View key={item.id} className={`p-6 flex-row justify-between items-center ${index !== 0 ? 'border-t border-gray-100' : ''}`}>
|
<View key={item.id} className={`p-6 flex-row justify-between items-center ${index !== 0 ? 'border-t border-gray-100' : ''}`}>
|
||||||
@ -81,9 +120,9 @@ export default function AttendanceScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{item.status === 'complete' && (
|
{item.status === 'complete' && (
|
||||||
<View className="bg-gray-100 px-3 py-1.5 rounded-lg">
|
<View className="bg-gray-100 px-3 py-1.5 rounded-lg">
|
||||||
<Text className="text-base font-mono text-gray-600 font-bold">8h</Text>
|
<Text className="text-base font-mono text-gray-600 font-bold">8h</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@ -94,9 +133,9 @@ export default function AttendanceScreen() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Scanner Modal */}
|
{/* Scanner Modal */}
|
||||||
<QrScanModal
|
<QrScanModal
|
||||||
visible={showScanner}
|
visible={showScanner}
|
||||||
onClose={() => setShowScanner(false)}
|
onClose={() => setShowScanner(false)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { Stack } from "expo-router";
|
|||||||
|
|
||||||
export default function ProfileLayout() {
|
export default function ProfileLayout() {
|
||||||
return (
|
return (
|
||||||
<Stack screenOptions={{headerShown: false}} />
|
<Stack screenOptions={{headerShown: false}}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="documents" />
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,37 +1,62 @@
|
|||||||
import { DOCUMENTS_DATA } from '@/data/data';
|
|
||||||
import { Download, FileText, MapPin, Plus, Search, CalendarIcon } from 'lucide-react-native';
|
import { Download, FileText, MapPin, Plus, Search, CalendarIcon } from 'lucide-react-native';
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { RangePickerModal } from '@/components/RangePickerModal';
|
import { RangePickerModal } from '@/components/RangePickerModal';
|
||||||
import { ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
import { Alert, RefreshControl, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { DocumentItem } from '@/types/types';
|
||||||
|
import api from '@/utils/api';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import { formatTimestamp, parseTimestamp } from '@/utils/dateTime';
|
||||||
|
import AddDocumentModal from '@/components/AddDocumentModal';
|
||||||
|
|
||||||
export default function DocumentsScreen() {
|
export default function DocumentsScreen() {
|
||||||
|
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const [showRangePicker, setShowRangePicker] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
// Gestiamo le date come oggetti Dayjs o null per il datepicker, e stringhe per il filtro
|
|
||||||
const [range, setRange] = useState<{ startDate: any; endDate: any }>({
|
const [range, setRange] = useState<{ startDate: any; endDate: any }>({
|
||||||
startDate: null,
|
startDate: null,
|
||||||
endDate: null
|
endDate: null
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showRangePicker, setShowRangePicker] = useState(false);
|
const fetchUserDocuments = async () => {
|
||||||
|
try {
|
||||||
|
if (!refreshing) setIsLoading(true);
|
||||||
|
|
||||||
// Funzione helper per convertire DD/MM/YYYY in oggetto Date (per il filtro esistente)
|
// Fetch Documenti Utente
|
||||||
const parseDate = (dateStr: string) => {
|
const response = await api.get(`/attachment/get-user-attachments`);
|
||||||
if (!dateStr) return new Date();
|
setDocuments(response.data);
|
||||||
const [day, month, year] = dateStr.split('/').map(Number);
|
} catch (error) {
|
||||||
return new Date(year, month - 1, day);
|
console.error('Errore nel recupero dei documenti utente:', error);
|
||||||
|
Alert.alert('Errore', 'Impossibile recuperare i documenti. Riprova più tardi.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredDocs = DOCUMENTS_DATA.filter(doc => {
|
useEffect(() => {
|
||||||
// Filtro Testuale
|
fetchUserDocuments();
|
||||||
const matchesSearch = doc.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
}, []);
|
||||||
doc.site.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchUserDocuments();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtra Documenti in base a searchTerm e range
|
||||||
|
const filteredDocs = documents.filter(doc => {
|
||||||
|
// Filtro Testuale
|
||||||
|
const matchesSearch = doc.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
if (!matchesSearch) return false;
|
if (!matchesSearch) return false;
|
||||||
|
|
||||||
// Filtro Date Range
|
// Filtro Date Range
|
||||||
if (range.startDate || range.endDate) {
|
if (range.startDate || range.endDate) {
|
||||||
const docDate = parseDate(doc.date); // doc.date è "DD/MM/YYYY"
|
const docDate = parseTimestamp(doc.updated_at); // doc.date è "DD/MM/YYYY"
|
||||||
|
|
||||||
// Controllo Data Inizio
|
// Controllo Data Inizio
|
||||||
if (range.startDate) {
|
if (range.startDate) {
|
||||||
@ -50,31 +75,66 @@ export default function DocumentsScreen() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Funzione per formattare la visualizzazione
|
// Gestione Caricamento Documento
|
||||||
const formatDateDisplay = (date: any) => {
|
const handleUploadDocument = async (file: any, customTitle?: string) => {
|
||||||
return date ? dayjs(date).format('DD/MM/YYYY') : 'gg/mm/aaaa';
|
console.log('Caricamento documento:', file, 'con titolo personalizzato:', customTitle);
|
||||||
};
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
// const formData = new FormData();
|
||||||
|
// formData.append('file', {
|
||||||
|
// uri: file.uri,
|
||||||
|
// name: file.name,
|
||||||
|
// type: file.mimeType || 'application/octet-stream',
|
||||||
|
// } as any);
|
||||||
|
// if (customTitle) {
|
||||||
|
// formData.append('title', customTitle);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const response = await api.post('/attachment/upload', formData, {
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'multipart/form-data',
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// console.log('Risposta caricamento:', response.data);
|
||||||
|
// Alert.alert('Successo', 'Documento caricato con successo!');
|
||||||
|
// setShowUploadModal(false);
|
||||||
|
// fetchUserDocuments(); // Ricarica la lista dei documenti
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel caricamento del documento:', error);
|
||||||
|
Alert.alert('Errore', 'Impossibile caricare il documento. Riprova più tardi.');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && !refreshing) {
|
||||||
|
return (
|
||||||
|
<LoadingScreen />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
{/* TODO: Aggiungi torna indietro */}
|
||||||
<View className="bg-white p-6 pt-16 shadow-sm border-b border-gray-100">
|
<View className="bg-white p-6 pt-16 shadow-sm border-b border-gray-100">
|
||||||
<Text className="text-3xl font-bold text-gray-800 mb-1">Documenti</Text>
|
<Text className="text-3xl font-bold text-gray-800 mb-1">Documenti</Text>
|
||||||
<Text className="text-base text-gray-500">Gestione modulistica e schemi</Text>
|
<Text className="text-base text-gray-500">Gestisci i tuoi documenti</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="p-5 gap-6 flex-1">
|
<View className="p-5 gap-6 flex-1">
|
||||||
{/* Search + Date Row */}
|
{/* Search + Date Row */}
|
||||||
<View className="flex-row gap-2">
|
<View className="flex-row gap-2">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<View className={`flex-1 relative justify-center shadow-sm rounded-2xl border ${searchTerm ? 'border-[#099499]' : 'border-gray-200'}`}>
|
<View className={`flex-1 relative justify-center`}>
|
||||||
<View className="absolute left-4 z-10">
|
<View className="absolute left-4 z-10">
|
||||||
<Search size={24} color="#9ca3af" />
|
<Search size={24} color="#9ca3af" />
|
||||||
</View>
|
</View>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Cerca nome, cantiere..."
|
placeholder="Cerca nome del documento..."
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#9ca3af"
|
||||||
className="w-full pl-12 pr-4 py-4 bg-white rounded-2xl text-gray-800 text-lg"
|
className={`w-full pl-12 pr-4 py-4 bg-white shadow-sm rounded-2xl text-gray-800 text-lg border shadow-sm ${searchTerm ? 'border-[#099499]' : 'border-gray-200'}`}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChangeText={setSearchTerm}
|
onChangeText={setSearchTerm}
|
||||||
/>
|
/>
|
||||||
@ -101,6 +161,9 @@ export default function DocumentsScreen() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ gap: 16, paddingBottom: 100 }}
|
contentContainerStyle={{ gap: 16, paddingBottom: 100 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{filteredDocs.map((doc) => (
|
{filteredDocs.map((doc) => (
|
||||||
<View key={doc.id} className="bg-white p-5 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100">
|
<View key={doc.id} className="bg-white p-5 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100">
|
||||||
@ -109,10 +172,10 @@ export default function DocumentsScreen() {
|
|||||||
<FileText size={32} color="#ef4444" />
|
<FileText size={32} color="#ef4444" />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="font-bold text-gray-800 text-lg mb-1">{doc.name}</Text>
|
<Text className="font-bold text-gray-800 text-lg mb-1">{doc.title}</Text>
|
||||||
<View className="flex-row items-center mt-1">
|
<View className="flex-row items-center mt-1">
|
||||||
<MapPin size={16} color="#9ca3af" />
|
<MapPin size={16} color="#9ca3af" />
|
||||||
<Text className="text-sm text-gray-400 ml-1 font-medium">{doc.site}</Text>
|
<Text className="text-sm text-gray-400 ml-1 font-medium">{formatTimestamp(doc.updated_at)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -121,16 +184,28 @@ export default function DocumentsScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{filteredDocs.length === 0 && (
|
||||||
|
<Text className="text-center text-gray-400 mt-10">Nessun documento trovato</Text>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => alert('Aggiungi nuovo documento')}
|
onPress={() => setShowUploadModal(true)}
|
||||||
className="absolute bottom-8 right-6 w-16 h-16 bg-[#099499] rounded-full shadow-lg items-center justify-center active:scale-90"
|
className="absolute bottom-8 right-6 w-16 h-16 bg-[#099499] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||||
>
|
>
|
||||||
<Plus size={32} color="white" />
|
<Plus size={32} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Modale Caricamento Documento */}
|
||||||
|
<AddDocumentModal
|
||||||
|
visible={showUploadModal}
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
onUpload={handleUploadDocument}
|
||||||
|
isUploading={isUploading}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { ChevronLeft, LogOut, Mail, Settings, Smartphone, User } from 'lucide-react-native';
|
import { ChevronLeft, FileText, LogOut, Mail, Settings, User } from 'lucide-react-native';
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { MOCK_USER } from '@/data/data';
|
|
||||||
import { AuthContext } from '@/utils/authContext';
|
import { AuthContext } from '@/utils/authContext';
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
@ -58,17 +57,6 @@ export default function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* // TODO: Rimuovere telefono, si potrebbe sostituire con altro dato? */}
|
|
||||||
{/* <View className="flex-row items-center gap-5">
|
|
||||||
<View className="w-14 h-14 bg-gray-100 rounded-2xl items-center justify-center">
|
|
||||||
<Smartphone size={24} color="#374151" />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-lg text-gray-700 font-bold">Telefono</Text>
|
|
||||||
<Text className="text-gray-500 text-base">{phone}</Text>
|
|
||||||
</View>
|
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<View className="flex-row items-center gap-5">
|
<View className="flex-row items-center gap-5">
|
||||||
<View className="w-14 h-14 bg-gray-100 rounded-2xl items-center justify-center">
|
<View className="w-14 h-14 bg-gray-100 rounded-2xl items-center justify-center">
|
||||||
<User size={24} color="#374151" />
|
<User size={24} color="#374151" />
|
||||||
@ -85,14 +73,14 @@ export default function ProfileScreen() {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-gray-800 text-2xl font-bold mb-5 px-1">Azioni</Text>
|
<Text className="text-gray-800 text-2xl font-bold mb-5 px-1">Azioni</Text>
|
||||||
|
|
||||||
<TouchableOpacity onPress={() => router.push('/permits')} className="bg-white p-4 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100 mb-4">
|
<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="flex-row items-center gap-5">
|
||||||
<View className="bg-[#099499]/10 p-3.5 rounded-2xl">
|
<View className="bg-[#099499]/10 p-3.5 rounded-2xl">
|
||||||
<Settings size={26} color="#099499" />
|
<FileText size={26} color="#099499" />
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg text-gray-800 font-bold">I miei permessi</Text>
|
<Text className="text-lg text-gray-800 font-bold">I miei documenti</Text>
|
||||||
<Text className="text-base text-gray-400 mt-0.5">Richiedi o controlla lo stato</Text>
|
<Text className="text-base text-gray-400 mt-0.5">Gestisci i tuoi documenti</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-[#099499] text-base font-bold">Apri</Text>
|
<Text className="text-[#099499] text-base font-bold">Apri</Text>
|
||||||
|
|||||||
199
app/(protected)/sites/[id].tsx
Normal file
199
app/(protected)/sites/[id].tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { Download, FileText, Plus, Search, Calendar as CalendarIcon, ArrowLeft } from 'lucide-react-native';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { RangePickerModal } from '@/components/RangePickerModal';
|
||||||
|
import { Alert, RefreshControl, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { ConstructionSite, DocumentItem } from '@/types/types';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { formatTimestamp, parseTimestamp } from '@/utils/dateTime';
|
||||||
|
|
||||||
|
export default function SiteDocumentsScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const [site, setSite] = useState<ConstructionSite | null>(null);
|
||||||
|
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Fetch dei documenti del cantiere
|
||||||
|
const fetchSiteDocuments = useCallback(async (siteId: number, isRefreshing = false) => {
|
||||||
|
try {
|
||||||
|
if (!isRefreshing) setIsLoading(true);
|
||||||
|
|
||||||
|
// Fetch Documenti
|
||||||
|
const response = await api.get(`/attachment/get-site-attachments`, {
|
||||||
|
params: { siteId }
|
||||||
|
});
|
||||||
|
setDocuments(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel recupero dei documenti del cantiere:', error);
|
||||||
|
Alert.alert('Errore', 'Impossibile recuperare i documenti del cantiere. Riprova più tardi.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.siteData) {
|
||||||
|
try {
|
||||||
|
const jsonString = Array.isArray(params.siteData) ? params.siteData[0] : params.siteData;
|
||||||
|
const parsedSite = JSON.parse(jsonString);
|
||||||
|
setSite(parsedSite);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel parsing dei dati del cantiere:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [params.siteData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.id) {
|
||||||
|
setIsLoading(true); // Caricamento iniziale
|
||||||
|
fetchSiteDocuments(Number(params.id));
|
||||||
|
}
|
||||||
|
}, [params.id, fetchSiteDocuments]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchSiteDocuments(Number(params.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [range, setRange] = useState<{ startDate: any; endDate: any }>({
|
||||||
|
startDate: null,
|
||||||
|
endDate: null
|
||||||
|
});
|
||||||
|
const [showRangePicker, setShowRangePicker] = useState(false);
|
||||||
|
|
||||||
|
// Filtraggio Documenti
|
||||||
|
const filteredDocs = documents.filter(doc => {
|
||||||
|
// Filtro Testuale (su nome documento)
|
||||||
|
const matchesSearch = doc.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
|
||||||
|
// Filtro Date Range
|
||||||
|
if (range.startDate || range.endDate) {
|
||||||
|
const docDate = parseTimestamp(doc.updated_at);
|
||||||
|
if (range.startDate) {
|
||||||
|
const start = dayjs(range.startDate).startOf('day').toDate();
|
||||||
|
if (docDate <= start) return false;
|
||||||
|
}
|
||||||
|
if (range.endDate) {
|
||||||
|
const end = dayjs(range.endDate).endOf('day').toDate();
|
||||||
|
if (docDate >= end) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!site || (isLoading && !refreshing)) {
|
||||||
|
return (
|
||||||
|
<LoadingScreen />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<View className="bg-white p-6 pt-16 shadow-sm border-b border-gray-100 flex-row items-center gap-4">
|
||||||
|
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100">
|
||||||
|
<ArrowLeft size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
{/* Badge Codice Cantiere */}
|
||||||
|
{site.code && (
|
||||||
|
<View className="self-start bg-[#E6F4F4] px-2 py-0.5 rounded mb-1 border border-[#099499]/20">
|
||||||
|
<Text className="text-base font-bold text-[#099499]">
|
||||||
|
{site.code}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nome Cantiere con Truncate funzionante */}
|
||||||
|
<Text
|
||||||
|
className="text-xl font-bold text-gray-800 leading-tight"
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
>
|
||||||
|
{site.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-sm text-gray-500 mt-0.5">Archivio documenti</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="p-5 gap-6 flex-1">
|
||||||
|
{/* Search + Date Row (Spostato qui) */}
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
<View className="flex-1 relative justify-center">
|
||||||
|
<View className="absolute left-4 z-10">
|
||||||
|
<Search size={24} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Cerca documento..."
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
className={`w-full pl-12 pr-4 py-4 bg-white rounded-2xl text-gray-800 text-lg border shadow-sm ${searchTerm ? 'border-[#099499]' : 'border-gray-200'}`}
|
||||||
|
value={searchTerm}
|
||||||
|
onChangeText={setSearchTerm}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowRangePicker(true)}
|
||||||
|
className={`p-4 bg-white rounded-2xl shadow-sm border ${range.startDate ? 'border-[#099499]' : 'border-gray-200'}`}
|
||||||
|
>
|
||||||
|
<CalendarIcon size={24} color={range.startDate ? "#099499" : "#9ca3af"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<RangePickerModal
|
||||||
|
visible={showRangePicker}
|
||||||
|
onClose={() => setShowRangePicker(false)}
|
||||||
|
currentRange={range}
|
||||||
|
onApply={setRange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Lista Documenti */}
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ gap: 16, paddingBottom: 100 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filteredDocs.map((doc) => (
|
||||||
|
<View key={doc.id} 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-red-50 p-4 rounded-2xl">
|
||||||
|
<FileText size={32} color="#ef4444" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="font-bold text-gray-800 text-lg mb-1">{doc.title}</Text>
|
||||||
|
<Text className="text-sm text-gray-400 font-medium">{formatTimestamp(doc.updated_at)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity className="p-4 bg-gray-50 rounded-2xl active:bg-gray-100">
|
||||||
|
<Download size={24} color="#6b7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredDocs.length === 0 && (
|
||||||
|
<Text className="text-center text-gray-400 mt-10">Nessun documento trovato</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* FAB (Spostato qui) */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => alert(`Aggiungi doc a ${site.name}`)}
|
||||||
|
className="absolute bottom-8 right-6 w-16 h-16 bg-[#099499] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||||
|
>
|
||||||
|
<Plus size={32} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/(protected)/sites/_layout.tsx
Normal file
10
app/(protected)/sites/_layout.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import {Stack} from 'expo-router';
|
||||||
|
|
||||||
|
export default function SitesLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{headerShown: false}}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="[id]" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
app/(protected)/sites/index.tsx
Normal file
124
app/(protected)/sites/index.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { Building2, ChevronRight, MapPin, Search } from 'lucide-react-native';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, RefreshControl, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import api from '@/utils/api';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
import { ConstructionSite } from '@/types/types';
|
||||||
|
|
||||||
|
export default function SitesScreen() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [constructionSites, setConstructionSites] = useState<ConstructionSite[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Fetch cantieri e documenti
|
||||||
|
const fetchConstructionSites = async () => {
|
||||||
|
try {
|
||||||
|
if (!refreshing) setIsLoading(true);
|
||||||
|
|
||||||
|
// Fetch Cantieri
|
||||||
|
const response = await api.get('/construction-site/sites-attachments');
|
||||||
|
setConstructionSites(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel recupero dei cantieri:', error);
|
||||||
|
Alert.alert('Errore', 'Impossibile recuperare i cantieri. Riprova più tardi.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConstructionSites();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchConstructionSites();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtriamo i cantieri in base alla ricerca
|
||||||
|
const filteredSites = constructionSites.filter(site =>
|
||||||
|
site.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
site.code.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading && !refreshing) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<View className="bg-white p-6 pt-16 shadow-sm border-b border-gray-100">
|
||||||
|
<Text className="text-3xl font-bold text-gray-800 mb-1">Cantieri</Text>
|
||||||
|
<Text className="text-base text-gray-500">Seleziona un cantiere per vedere i documenti</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-5 mt-5 gap-6 flex-1">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View className={`relative justify-center`}>
|
||||||
|
<View className="absolute left-4 z-10">
|
||||||
|
<Search size={24} color="#9ca3af" />
|
||||||
|
</View>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Cerca per codice o nome cantiere..."
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
className={`w-full pl-12 pr-4 py-4 bg-white shadow-sm rounded-2xl border ${searchTerm ? 'border-[#099499]' : 'border-gray-200'} text-gray-800 text-lg`}
|
||||||
|
value={searchTerm}
|
||||||
|
onChangeText={setSearchTerm}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Lista Cantieri */}
|
||||||
|
{/* TODO: Rimuovere lo ScrollIndicator? */}
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ gap: 16, paddingBottom: 40 }}
|
||||||
|
showsVerticalScrollIndicator={true}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filteredSites.map((site, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
onPress={() => router.push({
|
||||||
|
pathname: '/(protected)/sites/[id]',
|
||||||
|
params: {
|
||||||
|
id: site.id ,
|
||||||
|
siteData: JSON.stringify(site),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
className="bg-white p-5 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-5 flex-1">
|
||||||
|
<View className="bg-blue-50 p-4 rounded-2xl">
|
||||||
|
<Building2 size={32} color="#2563eb" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-base font-medium text-gray-400 mb-0.5">{site.code}</Text>
|
||||||
|
<Text className="font-bold text-gray-800 text-lg mb-1">{site.name}</Text>
|
||||||
|
<View className="flex-row items-center mt-1">
|
||||||
|
<MapPin size={16} color="#9ca3af" />
|
||||||
|
<Text className="text-sm text-gray-400 ml-1 font-medium">
|
||||||
|
{site.attachments_count} Documenti disponibili
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Freccia al posto del download */}
|
||||||
|
<ChevronRight size={24} color="#9ca3af" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredSites.length === 0 && (
|
||||||
|
<Text className="text-center text-gray-400 mt-10">Nessun cantiere trovato</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,16 +1,6 @@
|
|||||||
import React, { useState, useContext } from 'react';
|
import React, { useState, useContext } from 'react';
|
||||||
import { Eye, EyeOff, Lock, LogIn, Mail } from 'lucide-react-native';
|
import { Eye, EyeOff, Lock, LogIn, Mail } from 'lucide-react-native';
|
||||||
import {
|
import { KeyboardAvoidingView, Platform, ScrollView, Text, TextInput, TouchableOpacity, View, Image, Alert } from 'react-native';
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
Image,
|
|
||||||
Alert
|
|
||||||
} from 'react-native';
|
|
||||||
import { AuthContext } from '@/utils/authContext';
|
import { AuthContext } from '@/utils/authContext';
|
||||||
import api from '@/utils/api';
|
import api from '@/utils/api';
|
||||||
|
|
||||||
@ -21,6 +11,7 @@ export default function LoginScreen() {
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// TODO: Riscrivere funzione per migliorare leggibilità e gestione errori
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
// TODO: Implementa toast o messaggio di errore più user-friendly
|
// TODO: Implementa toast o messaggio di errore più user-friendly
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
@ -31,9 +22,10 @@ export default function LoginScreen() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Tentativo login con:", username);
|
username.trim();
|
||||||
|
password.trim();
|
||||||
|
|
||||||
// Chiamata vera al backend
|
// Esegui richiesta di login
|
||||||
const response = await api.post("/user/login", {
|
const response = await api.post("/user/login", {
|
||||||
username: username,
|
username: username,
|
||||||
password: password
|
password: password
|
||||||
@ -46,10 +38,8 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
// Passiamo token e dati utente al context che gestirà salvataggio e redirect
|
// Passiamo token e dati utente al context che gestirà salvataggio e redirect
|
||||||
authContext.logIn(token, user);
|
authContext.logIn(token, user);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Login Error:", error);
|
console.error("Login Error:", error);
|
||||||
|
|
||||||
let message = "Si è verificato un errore durante l'accesso.";
|
let message = "Si è verificato un errore durante l'accesso.";
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
|||||||
161
components/AddDocumentModal.tsx
Normal file
161
components/AddDocumentModal.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal, Text, TouchableOpacity, View, TextInput, ActivityIndicator } from 'react-native';
|
||||||
|
import { X, Upload, FileText, Trash2 } from 'lucide-react-native';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker'; // TODO: Testare in ambiente iOS
|
||||||
|
|
||||||
|
interface AddDocumentModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpload: (file: DocumentPicker.DocumentPickerAsset, customTitle?: string) => Promise<void>;
|
||||||
|
isUploading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddDocumentModal({ visible, onClose, onUpload, isUploading = false }: AddDocumentModalProps) {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<DocumentPicker.DocumentPickerAsset | null>(null);
|
||||||
|
const [customTitle, setCustomTitle] = useState('');
|
||||||
|
|
||||||
|
// Reset dello stato quando il modale si apre/chiude
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setCustomTitle('');
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const pickDocument = async () => {
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: '*/*', // Puoi limitare a 'application/pdf', 'image/*', ecc.
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
setSelectedFile(asset);
|
||||||
|
|
||||||
|
// Pre-compila il titolo con il nome del file (senza estensione se vuoi essere fancy, qui lo lascio intero)
|
||||||
|
setCustomTitle(asset.name);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Errore selezione file:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
// Se il titolo custom è vuoto, usiamo il nome originale
|
||||||
|
onUpload(selectedFile, customTitle || selectedFile.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setCustomTitle('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formatta dimensione file
|
||||||
|
const formatSize = (size?: number) => {
|
||||||
|
if (!size) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(size) / Math.log(k));
|
||||||
|
return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
visible={visible}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center items-center bg-black/50 px-6">
|
||||||
|
<View className="bg-white w-full rounded-[2rem] p-6 shadow-xl">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View className="flex-row justify-between items-center mb-6">
|
||||||
|
<Text className="text-xl font-bold text-gray-800">Carica Documento</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-50 rounded-full">
|
||||||
|
<X size={20} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<View className="gap-5">
|
||||||
|
|
||||||
|
{/* Area Selezione File */}
|
||||||
|
{!selectedFile ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={pickDocument}
|
||||||
|
className="h-64 border-2 border-dashed border-gray-300 rounded-2xl items-center justify-center bg-gray-50 active:bg-gray-100"
|
||||||
|
>
|
||||||
|
<View className="bg-white p-4 rounded-full shadow-sm mb-3">
|
||||||
|
<Upload size={32} color="#099499" />
|
||||||
|
</View>
|
||||||
|
<Text className="text-lg text-gray-600 font-medium">Tocca per selezionare un file</Text>
|
||||||
|
<Text className="text-gray-400 text-sm mt-1">PDF, Immagini, Word</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
// Visualizzazione File Selezionato
|
||||||
|
<View className="bg-[#E6F4F4] p-4 rounded-2xl border border-[#099499]/20 flex-row items-center gap-4">
|
||||||
|
<View className="bg-white p-3 rounded-xl">
|
||||||
|
<FileText size={24} color="#099499" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-gray-800 font-bold text-sm" numberOfLines={1}>
|
||||||
|
{selectedFile.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-500 text-xs">
|
||||||
|
{formatSize(selectedFile.size)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={removeFile} className="p-2 bg-white rounded-lg">
|
||||||
|
<Trash2 size={18} color="#ef4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Campo Rinomina (Visibile solo se c'è un file) */}
|
||||||
|
{selectedFile && (
|
||||||
|
<View>
|
||||||
|
<Text className="text-gray-700 font-bold mb-2 ml-1 text-sm">Nome Documento (Opzionale)</Text>
|
||||||
|
<TextInput
|
||||||
|
value={customTitle}
|
||||||
|
onChangeText={setCustomTitle}
|
||||||
|
placeholder="Come vuoi chiamare questo file?"
|
||||||
|
className="w-full p-4 bg-gray-50 rounded-xl border border-gray-200 text-gray-800 text-base"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer Buttons */}
|
||||||
|
<View className="flex-row gap-3 mt-8">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
className="flex-1 py-4 bg-gray-100 rounded-xl items-center"
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<Text className="text-gray-600 font-bold text-base">Annulla</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleUpload}
|
||||||
|
disabled={!selectedFile || isUploading}
|
||||||
|
className={`flex-1 py-4 rounded-xl items-center flex-row justify-center gap-2 ${!selectedFile ? 'bg-gray-300' : 'bg-[#099499]'}`}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<ActivityIndicator color="white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={20} color="white" />
|
||||||
|
<Text className="text-white font-bold text-base">Carica</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,20 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Alert, Modal, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, Modal, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import DateTimePicker, { DateType, useDefaultStyles, useDefaultClassNames } from 'react-native-ui-datepicker';
|
import DateTimePicker, { DateType, useDefaultStyles } from 'react-native-ui-datepicker';
|
||||||
import { Check, X } from 'lucide-react-native';
|
import { Check, X } from 'lucide-react-native';
|
||||||
|
|
||||||
export const RangePickerModal = ({ visible, onClose, currentRange, onApply }: any) => {
|
export const RangePickerModal = ({ visible, onClose, onApply }: any) => {
|
||||||
const defaultStyles = useDefaultStyles();
|
const defaultStyles = useDefaultStyles();
|
||||||
// const defaultClassNames = useDefaultClassNames();
|
|
||||||
const [range, setRange] = useState<{
|
const [range, setRange] = useState<{
|
||||||
startDate: DateType;
|
startDate: DateType;
|
||||||
endDate: DateType;
|
endDate: DateType;
|
||||||
}>({ startDate: undefined, endDate: undefined });
|
}>({ startDate: null, endDate: null });
|
||||||
|
|
||||||
|
const clearCalendar = () => {
|
||||||
|
setRange({ startDate: null, endDate: null });
|
||||||
|
onApply({ startDate: null, endDate: null });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -38,10 +43,6 @@ export const RangePickerModal = ({ visible, onClose, currentRange, onApply }: an
|
|||||||
...defaultStyles,
|
...defaultStyles,
|
||||||
selected: { backgroundColor: '#099499' }
|
selected: { backgroundColor: '#099499' }
|
||||||
}}
|
}}
|
||||||
// classNames={{
|
|
||||||
// ...defaultClassNames,
|
|
||||||
// selected: 'bg-#099499'
|
|
||||||
// }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@ -57,11 +58,7 @@ export const RangePickerModal = ({ visible, onClose, currentRange, onApply }: an
|
|||||||
<Text className="text-white font-bold text-lg ml-2">Applica Filtro</Text>
|
<Text className="text-white font-bold text-lg ml-2">Applica Filtro</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={clearCalendar}
|
||||||
console.log("Reset pressed");
|
|
||||||
range.startDate = range.endDate = undefined;
|
|
||||||
// TODO: deselect dates from calendar
|
|
||||||
}}
|
|
||||||
className="mt-2 bg-gray-200 rounded-xl py-4 flex-row justify-center items-center active:bg-gray-300"
|
className="mt-2 bg-gray-200 rounded-xl py-4 flex-row justify-center items-center active:bg-gray-300"
|
||||||
>
|
>
|
||||||
<Text className="text-gray-700 font-bold text-lg ml-2">Reset</Text>
|
<Text className="text-gray-700 font-bold text-lg ml-2">Reset</Text>
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -16,6 +16,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"expo": "~54.0.25",
|
"expo": "~54.0.25",
|
||||||
"expo-constants": "~18.0.10",
|
"expo-constants": "~18.0.10",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
@ -6442,6 +6443,15 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-document-picker": {
|
||||||
|
"version": "14.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||||
|
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.20",
|
"version": "19.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.20.tgz",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"expo": "~54.0.25",
|
"expo": "~54.0.25",
|
||||||
"expo-constants": "~18.0.10",
|
"expo-constants": "~18.0.10",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
|
|||||||
@ -22,10 +22,9 @@ export interface AttendanceRecord {
|
|||||||
|
|
||||||
export interface DocumentItem {
|
export interface DocumentItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
title: string;
|
||||||
type: string;
|
url: string;
|
||||||
site: string;
|
updated_at: string;
|
||||||
date: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OfficeItem {
|
export interface OfficeItem {
|
||||||
@ -42,8 +41,8 @@ export interface TimeOffRequestType {
|
|||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
abbreviation: string;
|
abbreviation: string;
|
||||||
time_required: number; // backend usa 0/1
|
time_required: number;
|
||||||
deleted: number; // backend usa 0/1
|
deleted: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeOffRequest {
|
export interface TimeOffRequest {
|
||||||
@ -57,4 +56,12 @@ export interface TimeOffRequest {
|
|||||||
message?: string | null;
|
message?: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
timeOffRequestType: TimeOffRequestType;
|
timeOffRequestType: TimeOffRequestType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstructionSite {
|
||||||
|
id: number;
|
||||||
|
// id_client: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
attachments_count: number;
|
||||||
}
|
}
|
||||||
@ -39,3 +39,37 @@ export const formatPickerDate = (d: DateType | null | undefined) => {
|
|||||||
|
|
||||||
return `${yyyy}-${mm}-${dd}`;
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trasforma un timestamp in stringa "DD/MM/YYYY HH:mm:ss"
|
||||||
|
* @param timestamp stringa o oggetto Date
|
||||||
|
* @returns stringa formattata oppure vuota se input non valido
|
||||||
|
*/
|
||||||
|
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'); // mesi da 0 a 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}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converte un timestamp ISO in oggetto Date
|
||||||
|
* @param dateStr stringa data in formato ISO
|
||||||
|
* @returns oggetto Date corrispondente
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user