Initial commit
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, TextInput, KeyboardAvoidingView, ScrollView, Platform, Dimensions, ActivityIndicator, Modal } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ChevronLeft, ImageIcon, Calendar as CalendarIcon } from 'lucide-react-native';
|
||||
import { AppDatePicker } from '@/components/AppDatePicker';
|
||||
import { DateType } from 'react-native-ui-datepicker';
|
||||
import { formatDate, formatPickerDate } from '@/utils/dateTime';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
import api from '@/utils/api';
|
||||
import { uploadDocument } from '@/utils/documentUtils';
|
||||
import { useAlert } from '@/components/AlertComponent';
|
||||
import GenericDropdown from '@/components/GenericDropdown';
|
||||
import ActivityLaborCard from '@/components/ActivityLaborCard';
|
||||
import RemovablePhotoTile from '@/components/RemovablePhotoTile';
|
||||
import CameraAddTile from '@/components/CameraAddTile';
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
||||
|
||||
export default function ActivityFormScreen() {
|
||||
const router = useRouter();
|
||||
const alert = useAlert();
|
||||
const { id } = useLocalSearchParams();
|
||||
const isEditing = !!id;
|
||||
|
||||
// Data lists
|
||||
const [subactivities, setSubactivities] = useState<any[]>([]);
|
||||
|
||||
// Form states
|
||||
const [date, setDate] = useState<DateType>(new Date());
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [selectedSubactivityUuid, setSelectedSubactivityUuid] = useState<string | null>(null);
|
||||
const [selectedSubactivityId, setSelectedSubactivityId] = useState<number | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Labor states
|
||||
const [operatorLabor, setOperatorLabor] = useState<any[]>([]);
|
||||
const [subcontractorLabor, setSubcontractorLabor] = useState<any[]>([]);
|
||||
const [otherOperatorLabor, setOtherOperatorLabor] = useState<any[]>([]);
|
||||
const [equipmentLabor, setEquipmentLabor] = useState<any[]>([]);
|
||||
|
||||
// Photos
|
||||
const [photos, setPhotos] = useState<any[]>([]); // New photos
|
||||
const [existingPhotos, setExistingPhotos] = useState<any[]>([]); // Existing (readonly)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const loadInitialData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Load subactivities
|
||||
const subRes = await api.get('/subactivity/get-subactivities');
|
||||
if (subRes.data?.success) {
|
||||
setSubactivities(subRes.data.subactivities);
|
||||
}
|
||||
|
||||
// If editing, load activity data
|
||||
if (isEditing) {
|
||||
const actRes = await api.post('/activity/get-activity-data', {
|
||||
params: JSON.stringify({ id: id })
|
||||
});
|
||||
|
||||
if (actRes.data?.success) {
|
||||
const data = actRes.data;
|
||||
const activity = data.activity;
|
||||
|
||||
setDate(new Date(activity.date));
|
||||
setDescription(activity.description || '');
|
||||
|
||||
if (subRes.data?.success) {
|
||||
const match = subRes.data.subactivities.find((s: any) => s.id === activity.id_subactivity);
|
||||
if (match) {
|
||||
setSelectedSubactivityUuid(match.uuid);
|
||||
setSelectedSubactivityId(match.id);
|
||||
}
|
||||
}
|
||||
|
||||
setOperatorLabor(data.operator_labor || []);
|
||||
setSubcontractorLabor(data.subcontractor_labor || []);
|
||||
setOtherOperatorLabor(data.other_operator_labor || []);
|
||||
setEquipmentLabor(data.materials || []);
|
||||
setExistingPhotos(data.attachments || []);
|
||||
} else {
|
||||
alert.showAlert('error', 'Errore', 'Impossibile caricare i dati dell\'attività.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
alert.showAlert('error', 'Errore', 'Si è verificato un errore di connessione.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = (params: any) => {
|
||||
setDate(params.date);
|
||||
setShowDatePicker(false);
|
||||
};
|
||||
|
||||
const pickFromGallery = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (permissionResult.granted === false) {
|
||||
alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla galleria.');
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = 50 - photos.length;
|
||||
if (limit <= 0) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: limit,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
setPhotos(prev => [...prev, ...result.assets]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore gallery:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const takePhoto = async () => {
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (permissionResult.granted === false) {
|
||||
alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla fotocamera.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (photos.length >= 50) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
setPhotos(prev => [...prev, result.assets[0]]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore fotocamera:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const removePhoto = (index: number) => {
|
||||
setPhotos(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedSubactivityUuid) {
|
||||
alert.showAlert('error', 'Campi obbligatori', 'Selezionare un Cantiere.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Se in edit mode dobbiamo passare subactivity_id, se in add mode subactivity_uuid.
|
||||
let subactivity_id = selectedSubactivityId;
|
||||
if (!subactivity_id) {
|
||||
const match = subactivities.find(s => s.uuid === selectedSubactivityUuid);
|
||||
if (match) subactivity_id = match.id;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
description: description,
|
||||
date: date ? formatPickerDate(date) : new Date().toISOString().split('T')[0], // YYYY-MM-DD
|
||||
n_files: photos.length,
|
||||
operator_labor: operatorLabor,
|
||||
subcontractor_labor: subcontractorLabor,
|
||||
other_operators_labor: otherOperatorLabor,
|
||||
equipment_labor: equipmentLabor,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
payload.id = id;
|
||||
payload.subactivity_id = subactivity_id;
|
||||
} else {
|
||||
payload.subactivity_uuid = selectedSubactivityUuid;
|
||||
}
|
||||
|
||||
const params = {
|
||||
post: JSON.stringify(payload)
|
||||
};
|
||||
|
||||
const endpoint = isEditing ? '/activity/edit' : '/activity/add';
|
||||
const res = await api.post(endpoint, params);
|
||||
|
||||
if (res.data?.success) {
|
||||
const savedId = res.data.id;
|
||||
|
||||
// Upload new photos sequentially
|
||||
if (photos.length > 0) {
|
||||
for (const file of photos) {
|
||||
const fileName = file.fileName || file.uri.split('/').pop() || 'photo.jpg';
|
||||
const mimeType = file.mimeType || 'image/jpeg';
|
||||
|
||||
await uploadDocument({
|
||||
uri: file.uri,
|
||||
name: fileName,
|
||||
mimeType: mimeType
|
||||
}, {
|
||||
endpoint: '/activity/upload',
|
||||
fileKey: 'files',
|
||||
extraData: {
|
||||
model_classname: 'Activity',
|
||||
model_id: savedId.toString(),
|
||||
method: 'put',
|
||||
name: fileName,
|
||||
type: mimeType
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
alert.showAlert('success', 'Salvato', 'Attività salvata con successo.');
|
||||
if (isEditing) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/(protected)/activity');
|
||||
}
|
||||
} else {
|
||||
alert.showAlert('error', 'Errore', res.data?.message || 'Impossibile salvare l\'attività.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore salvataggio:', error);
|
||||
alert.showAlert('error', 'Errore di connessione', 'Verifica la connessione e riprova.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#1071C2" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const windowWidth = Dimensions.get('window').width;
|
||||
const itemsPerRow = 4;
|
||||
const padding = 20;
|
||||
const gap = 12;
|
||||
const tileWidth = (windowWidth - (padding * 2) - (gap * (itemsPerRow - 1))) / itemsPerRow;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<StatusBar style="dark" />
|
||||
|
||||
{/* Header */}
|
||||
<View className="bg-white px-4 pb-4 shadow-sm border-b border-gray-100">
|
||||
<SafeAreaView edges={['top']} className="pt-2">
|
||||
<View className="flex-row items-center justify-between px-2">
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100 w-12 items-center justify-center">
|
||||
<ChevronLeft size={28} color="#4b5563" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-xl font-bold text-gray-800 uppercase flex-1 text-center" numberOfLines={1}>
|
||||
{isEditing ? 'Modifica Attività' : 'Nuova Attività'}
|
||||
</Text>
|
||||
|
||||
<View className="w-12" />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={{ padding: 20 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* General Info Card */}
|
||||
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-4">
|
||||
<View className="mb-4">
|
||||
<Text className="text-[#082963] font-bold text-sm mb-2 uppercase">Cantiere <Text className="text-red-500">*</Text></Text>
|
||||
<View>
|
||||
<GenericDropdown
|
||||
options={subactivities.map(s => ({ id: s.uuid, label: s.label }))}
|
||||
selectedId={selectedSubactivityUuid}
|
||||
onSelect={(id) => setSelectedSubactivityUuid(id as string)}
|
||||
placeholder="Seleziona il cantiere"
|
||||
searchPlaceholder="Cerca cantiere"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-[#082963] font-bold text-sm mb-2 uppercase">Data</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowDatePicker(true)}
|
||||
className="bg-gray-50 border border-gray-200 p-3 rounded-2xl flex-row items-center justify-between"
|
||||
>
|
||||
<Text className="text-gray-800 text-base">{date ? formatDate(formatPickerDate(date) || undefined) : ''}</Text>
|
||||
<CalendarIcon size={20} color="#9ca3af" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{showDatePicker && (
|
||||
<Modal visible={showDatePicker} transparent animationType="fade">
|
||||
<View className="flex-1 bg-black/50 justify-center px-4">
|
||||
<View className="bg-white rounded-3xl p-5 w-full max-w-sm self-center shadow-lg">
|
||||
<AppDatePicker
|
||||
date={date}
|
||||
mode="single"
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowDatePicker(false)}
|
||||
className="mt-4 p-3 rounded-xl items-center border border-gray-200 active:bg-gray-50"
|
||||
>
|
||||
<Text className="text-gray-600 font-bold">Chiudi</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-[#082963] font-bold text-sm mb-2 uppercase">Descrizione</Text>
|
||||
<TextInput
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Inserisci una descrizione (opzionale)"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
className="bg-gray-50 border border-gray-200 p-4 rounded-2xl text-gray-800 text-base min-h-[100px]"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Labor Cards */}
|
||||
<View>
|
||||
<ActivityLaborCard
|
||||
title="Operai"
|
||||
fetchUrl="/activity/get-operators"
|
||||
laborList={operatorLabor}
|
||||
onAddLabor={(labor) => setOperatorLabor([...operatorLabor, labor])}
|
||||
onRemoveLabor={(idx) => setOperatorLabor(operatorLabor.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
|
||||
<ActivityLaborCard
|
||||
title="Subappaltatori"
|
||||
fetchUrl="/activity/get-subcontractors"
|
||||
laborList={subcontractorLabor}
|
||||
onAddLabor={(labor) => setSubcontractorLabor([...subcontractorLabor, labor])}
|
||||
onRemoveLabor={(idx) => setSubcontractorLabor(subcontractorLabor.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
|
||||
<ActivityLaborCard
|
||||
title="Operai Distaccati"
|
||||
fetchUrl="/activity/get-other-operators"
|
||||
laborList={otherOperatorLabor}
|
||||
onAddLabor={(labor) => setOtherOperatorLabor([...otherOperatorLabor, labor])}
|
||||
onRemoveLabor={(idx) => setOtherOperatorLabor(otherOperatorLabor.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
|
||||
<ActivityLaborCard
|
||||
title="Attrezzature"
|
||||
fetchUrl="/activity/get-equipment"
|
||||
laborList={equipmentLabor}
|
||||
onAddLabor={(labor) => setEquipmentLabor([...equipmentLabor, labor])}
|
||||
onRemoveLabor={(idx) => setEquipmentLabor(equipmentLabor.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Photos */}
|
||||
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-6">
|
||||
<Text className="text-[#082963] font-bold text-lg mb-4">Allegati</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: gap }}>
|
||||
{/* Existing Photos (Read-only) */}
|
||||
{existingPhotos.map((photo, index) => (
|
||||
<View key={`ext-${index}`} style={{ width: tileWidth, height: tileWidth }} className="rounded-2xl overflow-hidden border border-gray-200">
|
||||
<Image source={{ uri: photo.uri }} style={{ width: '100%', height: '100%' }} contentFit="cover" />
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* New Photos */}
|
||||
{photos.map((photo, index) => (
|
||||
<RemovablePhotoTile key={`new-${index}`} uri={photo.uri} onRemove={() => removePhoto(index)} size={tileWidth} />
|
||||
))}
|
||||
|
||||
{/* Add Buttons */}
|
||||
{(photos.length + existingPhotos.length) < 50 && (
|
||||
<>
|
||||
<CameraAddTile onPress={takePhoto} size={tileWidth} />
|
||||
<TouchableOpacity
|
||||
onPress={pickFromGallery}
|
||||
style={{ width: tileWidth, height: tileWidth }}
|
||||
className="bg-blue-50 items-center justify-center rounded-2xl border border-blue-100 border-dashed"
|
||||
>
|
||||
<ImageIcon size={24} color="#1071C2" />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Submit */}
|
||||
<TouchableOpacity
|
||||
onPress={handleSave}
|
||||
disabled={isSubmitting}
|
||||
className={`bg-[#1071C2] p-4 rounded-full items-center justify-center mt-2 flex-row gap-2 ${isSubmitting ? 'opacity-70' : 'active:bg-[#0d5a9b]'}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-white font-bold text-lg uppercase tracking-wider">
|
||||
{isEditing ? 'Salva Modifiche' : 'Salva Attività'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</KeyboardAwareScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user