Initial commit
This commit is contained in:
282
app/(protected)/journal/[id].tsx
Normal file
282
app/(protected)/journal/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/(protected)/journal/_layout.tsx
Normal file
11
app/(protected)/journal/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
292
app/(protected)/journal/add.tsx
Normal file
292
app/(protected)/journal/add.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
app/(protected)/journal/index.tsx
Normal file
196
app/(protected)/journal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user