Initial commit

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

View File

@@ -0,0 +1,181 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { Modal, View, Text, TouchableOpacity, TouchableWithoutFeedback } from 'react-native';
import { CheckCircle, XCircle, Info, AlertTriangle } from 'lucide-react-native';
type AlertType = 'success' | 'error' | 'info' | 'warning';
type ConfirmButtonStyle = 'default' | 'destructive' | 'cancel';
interface ConfirmButton {
text: string;
onPress: () => void;
style?: ConfirmButtonStyle;
}
interface AlertContextData {
showAlert: (type: AlertType, title: string, message: string) => void;
showConfirm: (
title: string,
message: string,
buttons: [ConfirmButton, ConfirmButton]
) => void;
hideAlert: () => void;
}
const AlertContext = createContext<AlertContextData>({} as AlertContextData);
// TODO: Move this config to a separate file
const ALERT_CONFIG = {
success: {
icon: CheckCircle,
color: 'text-green-600',
bgColor: 'bg-green-100',
btnColor: 'bg-green-600',
},
error: {
icon: XCircle,
color: 'text-red-600',
bgColor: 'bg-red-100',
btnColor: 'bg-red-600',
},
info: {
icon: Info,
color: 'text-sky-600',
bgColor: 'bg-sky-100',
btnColor: 'bg-sky-600',
},
warning: {
icon: AlertTriangle,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
btnColor: 'bg-orange-600',
},
};
export const AlertProvider = ({ children }: { children: ReactNode }) => {
const [visible, setVisible] = useState(false);
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [type, setType] = useState<AlertType>('info');
const [isConfirmMode, setIsConfirmMode] = useState(false);
const [confirmButtons, setConfirmButtons] = useState<[ConfirmButton, ConfirmButton]>([
{ text: 'Annulla', onPress: () => {}, style: 'cancel' },
{ text: 'Conferma', onPress: () => {}, style: 'default' }
]);
const showAlert = (newType: AlertType, newTitle: string, newMessage: string) => {
setType(newType);
setTitle(newTitle);
setMessage(newMessage);
setIsConfirmMode(false);
setVisible(true);
};
const showConfirm = (
newTitle: string,
newMessage: string,
buttons: [ConfirmButton, ConfirmButton]
) => {
setTitle(newTitle);
setMessage(newMessage);
setConfirmButtons(buttons);
setIsConfirmMode(true);
setVisible(true);
};
const hideAlert = () => {
setVisible(false);
};
const { icon: Icon, color, bgColor, btnColor } = ALERT_CONFIG[type];
// TODO: Need to refactor component styles
return (
<AlertContext.Provider value={{ showAlert, showConfirm, hideAlert }}>
{children}
<Modal
transparent
visible={visible}
animationType="fade"
onRequestClose={hideAlert}
>
{/* Dark Backdrop */}
<TouchableOpacity
activeOpacity={1}
onPress={hideAlert} // Closes if you click outside (optional)
className="flex-1 bg-black/60 justify-center items-center px-6"
>
{/* Alert Container */}
<TouchableWithoutFeedback>
<View className="bg-white w-full max-w-sm rounded-3xl p-6 items-center shadow-2xl">
{/* Icon Circle - Solo per alert normali */}
{!isConfirmMode && (
<View className={`${bgColor} p-4 rounded-full mb-4`}>
<Icon size={32} className={color} strokeWidth={2.5} pointerEvents="none" />
</View>
)}
{/* Texts */}
<Text className="text-xl font-bold text-gray-900 text-center mb-2">
{title}
</Text>
<Text className="text-lg text-gray-500 text-center leading-relaxed mb-8">
{message}
</Text>
{/* Buttons - Condizionale */}
{isConfirmMode ? (
// Conferma: 2 bottoni orizzontali
<View className="flex-row gap-3 w-full">
{confirmButtons.map((button, index) => {
const isDestructive = button.style === 'destructive';
const isCancel = button.style === 'cancel';
return (
<TouchableOpacity
key={index}
onPress={() => {
hideAlert();
button.onPress();
}}
className={`flex-1 py-3.5 rounded-3xl ${
isDestructive
? 'bg-red-600'
: isCancel
? 'bg-gray-200'
: 'bg-[#1071C2]'
} active:opacity-90 shadow-sm`}
>
<Text className={`text-center font-bold text-lg ${
isCancel ? 'text-gray-700' : 'text-white'
}`}>
{button.text}
</Text>
</TouchableOpacity>
);
})}
</View>
) : (
// Alert normale: singolo bottone OK
<TouchableOpacity
onPress={hideAlert}
className={`w-full py-3.5 rounded-3xl ${btnColor} active:opacity-90 shadow-sm`}
>
<Text className="text-white text-center font-bold text-lg">
Ok, ho capito
</Text>
</TouchableOpacity>
)}
</View>
</TouchableWithoutFeedback>
</TouchableOpacity>
</Modal>
</AlertContext.Provider>
);
};
export const useAlert = () => useContext(AlertContext);

View File

@@ -0,0 +1,26 @@
import React from 'react';
import DateTimePicker, { useDefaultStyles } from 'react-native-ui-datepicker';
import { ChevronLeft, ChevronRight } from 'lucide-react-native';
type AppDatePickerProps = React.ComponentProps<typeof DateTimePicker>;
export const AppDatePicker = (props: AppDatePickerProps) => {
const defaultStyles = useDefaultStyles('light');
return (
<DateTimePicker
{...props}
locale="it"
components={{
IconPrev: <ChevronLeft size={24} color="#1f2937" pointerEvents="none" />,
IconNext: <ChevronRight size={24} color="#1f2937" pointerEvents="none" />,
...props.components,
}}
styles={{
...defaultStyles,
selected: { backgroundColor: '#1071C2' },
...props.styles,
}}
/>
);
};

View File

@@ -0,0 +1,53 @@
import { AlertTriangle, Clock } from 'lucide-react-native';
import React from 'react';
import { Text, View } from 'react-native';
import { AttendanceRecord } from '@/types/types';
interface AttendanceCardProps {
item: AttendanceRecord;
}
export default function AttendanceCard({ item }: AttendanceCardProps) {
return (
<View className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-col mb-4">
<View className="flex-row items-center border-b border-gray-50 pb-3 mb-3">
<View className="bg-blue-50 p-4 rounded-full mr-4 flex-shrink-0">
<Clock size={24} color="#1071C2" pointerEvents="none"/>
</View>
<View className="flex-1 mr-2">
<Text className="text-base font-bold text-primary-dark mb-1 leading-tight uppercase" numberOfLines={2}>
{item.place}
</Text>
<Text className="text-xs font-medium text-primary-dark mb-1 leading-tight" numberOfLines={2}>
{item.address}
</Text>
<Text className="text-xs font-bold text-gray-400 mt-1">
{item.date}
</Text>
</View>
</View>
<View className="flex-row items-center flex-wrap gap-x-6 gap-y-2 px-1">
<View className="flex-row items-center">
<Text className={`font-bold ${!item.out ? 'text-red-600' : 'text-green-600'}`}>
Entrata: {item.in}
</Text>
</View>
{item.out && (
<View className="flex-row items-center">
<Text className="font-bold text-green-600">
Uscita: {item.out}
</Text>
</View>
)}
{!item.out && (
<View className="flex-row items-center">
<AlertTriangle size={16} color="#dc2626" />
<Text className="font-bold text-red-600 ml-1">
In corso
</Text>
</View>
)}
</View>
</View>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { ChevronLeft, ChevronRight } from 'lucide-react-native';
import { TimeOffRequest, TimeOffRequestType } from '@/types/types';
interface CalendarWidgetProps {
events: TimeOffRequest[];
types: TimeOffRequestType[];
onMonthChange?: (date: Date) => void;
initialDate?: Date;
}
export default function CalendarWidget({ events, types, onMonthChange, initialDate }: CalendarWidgetProps) {
const [currentDate, setCurrentDate] = useState(initialDate || new Date());
// Calendar helpers
const daysInMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
const firstDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getDay(); // 0 = Sun
const adjustedFirstDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1; // 0 = Mon
const weekDays = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
const changeMonth = (increment: number) => {
const newDate = new Date(currentDate.setMonth(currentDate.getMonth() + increment));
setCurrentDate(new Date(newDate));
if (onMonthChange) {
onMonthChange(newDate);
}
};
const getEventForDay = (day: number) => {
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const dayStr = String(day).padStart(2, '0');
const dateStr = `${year}-${month}-${dayStr}`;
return events.find(event => {
if (!event.start_date) return false;
const evtStart = String(event.start_date).split(' ')[0];
if (event.timeOffRequestType.name === 'Permesso') return evtStart === dateStr;
const evtEnd = event.end_date ? String(event.end_date).split(' ')[0] : evtStart;
return dateStr >= evtStart && dateStr <= evtEnd;
});
};
return (
<View className="bg-white rounded-[2rem] p-6 shadow-sm border border-gray-100">
{/* Month Header */}
<View className="flex-row justify-between items-center mb-6">
<TouchableOpacity
onPress={() => changeMonth(-1)}
className="p-2 bg-gray-50 rounded-full"
>
<ChevronLeft size={24} color="#374151" pointerEvents="none" />
</TouchableOpacity>
<Text className="text-xl font-bold text-gray-800 capitalize">
{currentDate.toLocaleString('it-IT', { month: 'long', year: 'numeric' })}
</Text>
<TouchableOpacity
onPress={() => changeMonth(1)}
className="p-2 bg-gray-50 rounded-full"
>
<ChevronRight size={24} color="#374151" pointerEvents="none" />
</TouchableOpacity>
</View>
{/* Week Header */}
<View className="flex-row justify-between mb-4">
{weekDays.map(day => (
<Text key={day} className="w-10 text-center text-xs font-bold text-gray-400 uppercase">{day}</Text>
))}
</View>
{/* Days Grid */}
<View className="flex-row flex-wrap gap-y-4">
{/* Empty slots for alignment */}
{Array.from({ length: adjustedFirstDay }).map((_, i) => (
<View key={`empty-${i}`} style={{ width: '14.28%' }} />
))}
{/* Days */}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const event = getEventForDay(day);
let bgClass = 'bg-transparent';
let textClass = 'text-gray-700';
let borderClass = 'border-transparent';
const bgColor = event?.timeOffRequestType?.color ? `${event.timeOffRequestType.color}25` : 'transparent';
const borderColor = event?.timeOffRequestType?.color || 'transparent';
const textColor = event ? event.timeOffRequestType?.color : '#374151';
return (
<View key={day} style={{ width: '14.28%' }} className="items-center">
<View className={`w-10 h-10 rounded-full items-center justify-center border`} style={{backgroundColor: bgColor, borderColor: borderColor }}>
<Text className={`text-sm ${event ? 'font-bold' : ''}`} style={{ color: textColor }}>{day}</Text>
</View>
</View>
);
})}
</View>
{/* Legend */}
<View className="flex-row flex-wrap justify-center gap-4 mt-8 pt-4 border-t border-gray-100">
{types.map((type) => (
<View key={type.id} className="flex-row items-center" >
<View className={`w-3 h-3 rounded-full mr-2`} style={{ backgroundColor: type.color }} />
<Text className="text-sm font-medium text-gray-500">{type.name}</Text>
</View>
))}
</View>
</View>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { TouchableOpacity } from 'react-native';
import { Camera } from 'lucide-react-native';
interface CameraAddTileProps {
onPress: () => void;
size: number;
}
export default function CameraAddTile({ onPress, size }: CameraAddTileProps) {
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={{ width: size, height: size }}
className="mb-2 bg-blue-50/50 rounded-2xl border-2 border-dashed border-[#1071C2]/40 items-center justify-center"
>
<Camera size={28} color="#1071C2" />
</TouchableOpacity>
);
}

149
components/FilterModal.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { AppDatePicker } from '@/components/AppDatePicker';
import PlaceFilter from '@/components/PlaceFilter';
import SupplierFilter from '@/components/SupplierFilter';
import MachineFilter from '@/components/MachineFilter';
import { Machine, Place, Supplier } from '@/types/types';
import { formatPickerDate } from '@/utils/dateTime';
import { X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { Modal, ScrollView, Text, TouchableOpacity, View } from 'react-native';
interface FilterModalProps {
visible: boolean;
places?: Place[];
currentRange?: { startDate: string | null; endDate: string | null };
currentPlace?: Place | null;
currentSupplier?: Supplier | null;
currentMachine?: Machine | null;
showDate?: boolean;
showPlace?: boolean;
showSupplier?: boolean;
showMachine?: boolean;
onClose: () => void;
onApply: (range: { startDate: string | null; endDate: string | null }, place: Place | null, supplier: Supplier | null, machine: Machine | null) => void;
onReset: () => void;
}
export default function FilterModal({
visible,
places = [],
currentRange = { startDate: null, endDate: null },
currentPlace = null,
currentSupplier = null,
currentMachine = null,
showDate = true,
showPlace = true,
showSupplier = false,
showMachine = false,
onClose,
onApply,
onReset,
}: FilterModalProps) {
const [localRange, setLocalRange] = useState<{ startDate: string | null; endDate: string | null }>(currentRange);
const [localPlace, setLocalPlace] = useState<any>(currentPlace);
const [localSupplier, setLocalSupplier] = useState<any>(currentSupplier);
const [localMachine, setLocalMachine] = useState<any>(currentMachine);
// Sync local state when modal opens
useEffect(() => {
if (visible) {
setLocalRange(currentRange);
setLocalPlace(currentPlace);
setLocalSupplier(currentSupplier);
setLocalMachine(currentMachine);
}
}, [visible, currentRange, currentPlace, currentSupplier, currentMachine]);
const handleApply = () => {
onApply(localRange, localPlace, localSupplier, localMachine);
};
const handleReset = () => {
onReset();
};
return (
<Modal
visible={visible}
transparent={true}
animationType="slide"
statusBarTranslucent
>
<View className="flex-1 bg-black/60 justify-end sm:justify-center">
<View className="bg-white w-full rounded-t-[2.5rem] p-6 shadow-2xl max-h-[90%]">
{/* Modal Header */}
<View className="flex-row justify-between items-center mb-6">
<Text className="text-2xl font-bold text-gray-800">Filtra Risultati</Text>
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
<X size={24} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 40 }}>
<View className="space-y-6">
{/* Date Range Selection */}
{showDate && (
<View className="mb-6">
<Text className="text-lg font-bold text-gray-700 mb-3">Periodo</Text>
<AppDatePicker
mode="range"
startDate={localRange.startDate}
endDate={localRange.endDate}
onChange={(params: any) => {
setLocalRange({
startDate: params.startDate ? formatPickerDate(params.startDate) : null,
endDate: params.endDate ? formatPickerDate(params.endDate) : null
});
}}
/>
</View>
)}
{/* Place Selection */}
{showPlace && (
<PlaceFilter
places={places}
selectedPlaceId={localPlace}
onPlaceSelect={setLocalPlace}
/>
)}
{/* Supplier Selection */}
{showSupplier && (
<SupplierFilter
selectedSupplierId={localSupplier}
onSupplierSelect={setLocalSupplier}
/>
)}
{/* Machine Selection */}
{showMachine && (
<MachineFilter
selectedMachineId={localMachine}
onMachineSelect={setLocalMachine}
/>
)}
{/* Actions */}
<View className="flex-row gap-4">
<TouchableOpacity
onPress={handleReset}
className="flex-1 py-4 bg-gray-200 rounded-2xl shadow-sm active:bg-gray-300"
>
<Text className="text-gray-700 text-center font-bold text-lg">Reset</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleApply}
className="flex-1 py-4 bg-[#1071C2] rounded-2xl shadow-lg active:scale-[0.98]"
>
<Text className="text-white text-center font-bold text-lg">Applica Filtri</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</View>
</View>
</Modal>
);
}

View File

@@ -0,0 +1,42 @@
import { InvoiceItem } from '@/types/types';
import { ArrowRight, FileText, MapPin } from 'lucide-react-native';
import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
interface InvoiceCardProps {
item: InvoiceItem;
onPress: (id: number) => void;
}
function InvoiceCard({ item, onPress }: InvoiceCardProps) {
return (
<TouchableOpacity
onPress={() => onPress(item.id)}
className="bg-white p-5 rounded-3xl shadow-sm border border-gray-100 flex-row justify-between items-center active:bg-gray-50"
>
<View className="flex-row items-center gap-4 flex-1">
<View className="p-4 rounded-2xl bg-blue-50">
<FileText size={24} color="#1071C2" />
</View>
<View className="flex-1">
<View className="mb-0.5">
<Text className="text-gray-500 text-xs font-bold uppercase mt-1">N° {item.documentNumber} del {item.date}</Text>
</View>
<Text className="font-bold text-gray-800 text-lg mb-1" numberOfLines={1}>{item.supplier}</Text>
<View className="flex-row items-center justify-between mt-1 gap-2">
<View className="flex-row items-center gap-1 flex-1">
<MapPin size={12} color="#8F9BB3" />
<Text className="text-[#8F9BB3] text-xs font-medium flex-1" numberOfLines={1}>{item.placeName}</Text>
</View>
<Text className="font-bold text-[#1071C2] text-base leading-tight">{item.totalAmount}</Text>
</View>
</View>
</View>
<View className="ml-3">
<ArrowRight size={20} color="#D1D5DB" />
</View>
</TouchableOpacity>
);
}
export default React.memo(InvoiceCard);

View File

@@ -0,0 +1,10 @@
import { View, Text, ActivityIndicator } from 'react-native';
export default function LoadingScreen() {
return (
<View className="flex-1 justify-center items-center bg-gray-50">
<ActivityIndicator size="large" color="#1071C2" />
<Text className="text-gray-500 mt-2">Caricamento...</Text>
</View>
);
}

View File

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

View File

@@ -0,0 +1,165 @@
import { Machine } from '@/types/types';
import api from '@/utils/api';
import { ChevronDown, Search, X } from 'lucide-react-native';
import React, { useState, useEffect, useMemo } from 'react';
import { Modal, FlatList, Text, TextInput, TouchableOpacity, View, Keyboard, KeyboardEvent, Platform } from 'react-native';
interface MachineFilterProps {
selectedMachineId: any;
onMachineSelect: (machineId: any) => void;
textColor?: string;
}
export default function MachineFilter({ selectedMachineId, onMachineSelect, textColor }: MachineFilterProps) {
const [showPicker, setShowPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [machines, setMachines] = useState<Machine[]>([]);
useEffect(() => {
const fetchMachines = async () => {
try {
const res = await api.get('/machine-attendance/get-machines');
if (res.data?.success) {
setMachines(res.data.machines || []);
}
} catch (err) {
console.error('Error fetching machines:', err);
}
};
fetchMachines();
}, []);
// Keyboard height listener
useEffect(() => {
const showSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
(e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
);
const hideSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
() => setKeyboardHeight(0)
);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
// Reset search query every time modal is opened
useEffect(() => {
if (showPicker) {
setSearchQuery('');
}
}, [showPicker]);
const filteredMachines = useMemo(() => {
if (!showPicker) return [];
if (!searchQuery.trim()) return machines;
const lowerQuery = searchQuery.toLowerCase();
return machines.filter(s => s.label.toLowerCase().includes(lowerQuery));
}, [machines, searchQuery, showPicker]);
const selectedMachineLabel = selectedMachineId
? machines.find(s => s.id === selectedMachineId)?.label || 'Macchina Selezionata'
: 'Tutte le macchine';
return (
<View className="mb-8">
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Macchina</Text>
<View className="flex-row items-center gap-3">
<TouchableOpacity
onPress={() => setShowPicker(true)}
className="flex-1 flex-row items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 shadow-sm"
>
<Text className={`font-medium text-base flex-1 mr-2 ${selectedMachineId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
{selectedMachineLabel}
</Text>
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
</TouchableOpacity>
{selectedMachineId !== null && (
<TouchableOpacity
onPress={() => onMachineSelect(null)}
className="bg-gray-50 p-4 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
>
<X size={22} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
)}
</View>
{/* Nested Place Picker Modal */}
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
<TouchableOpacity
activeOpacity={1}
onPress={() => setShowPicker(false)}
className="flex-1 bg-black/50 justify-end"
style={{ paddingBottom: keyboardHeight }}
>
<View className="bg-white rounded-t-3xl p-5 max-h-[70%]" onStartShouldSetResponder={() => true}>
<View className="flex-row justify-between items-center mb-4 border-b border-gray-100 pb-4">
<Text className="text-xl font-bold text-gray-800">Seleziona Macchina</Text>
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full active:bg-gray-200">
<X size={20} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
</View>
{/* Search Bar */}
<View className="bg-gray-100 flex-row items-center px-4 py-3 rounded-2xl mb-4 border border-gray-200 focus:border-[#1071C2]">
<Search size={20} color="#9ca3af" />
<TextInput
className="flex-1 ml-3 text-base text-gray-800"
placeholder="Cerca macchina..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor="#9ca3af"
autoCorrect={false}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')} className="p-1">
<X size={18} color="#6b7280" />
</TouchableOpacity>
)}
</View>
<FlatList
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 30 }}
keyboardShouldPersistTaps="handled"
data={filteredMachines}
keyExtractor={(item) => item.id.toString()}
ListHeaderComponent={() => (
searchQuery.trim() === '' ? (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedMachineId === null ? 'bg-blue-50' : ''}`}
onPress={() => { onMachineSelect(null); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedMachineId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
Tutte le macchine
</Text>
</TouchableOpacity>
) : null
)}
ListEmptyComponent={() => (
<View className="py-8 items-center">
<Text className="text-gray-500 font-medium text-center">Nessuna macchina trovata per "{searchQuery}"</Text>
</View>
)}
renderItem={({ item }) => (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedMachineId === item.id ? 'bg-blue-50' : ''}`}
onPress={() => { onMachineSelect(item.id); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedMachineId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
{item.label}
</Text>
</TouchableOpacity>
)}
/>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WifiOff } from 'lucide-react-native';
interface OfflineScreenProps {
onRetry: () => void;
isRetrying?: boolean;
}
export default function OfflineScreen({ onRetry, isRetrying = false }: OfflineScreenProps) {
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center px-8">
{/* Icon */}
<View className="bg-gray-100 p-6 rounded-full mb-6">
<WifiOff size={64} className="text-gray-400" pointerEvents="none" />
</View>
<Text className="text-2xl font-bold text-gray-800 mb-2 text-center">
Sei Offline
</Text>
<Text className="text-base text-gray-500 text-center mb-10 leading-6">
Sembra che non ci sia connessione a internet.{'\n'}Controlla il Wi-Fi o i dati mobili e riprova.
</Text>
{/* Retry Button */}
<TouchableOpacity
onPress={onRetry}
disabled={isRetrying}
className={`flex-row items-center justify-center w-full py-4 rounded-[2rem] gap-4 ${
isRetrying ? 'bg-gray-300' : 'bg-[#1071C2] active:opacity-90'
}`}
>
<Text className="text-white font-bold text-lg">
{isRetrying ? 'Controllo...' : 'Riprova'}
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}

154
components/PlaceFilter.tsx Normal file
View File

@@ -0,0 +1,154 @@
import { Place } from '@/types/types';
import { ChevronDown, Search, X } from 'lucide-react-native';
import React, { useState, useEffect, useMemo } from 'react';
import { Modal, FlatList, Text, TextInput, TouchableOpacity, View, Keyboard, KeyboardEvent, Platform } from 'react-native';
interface PlaceFilterProps {
places: Place[];
selectedPlaceId: any;
onPlaceSelect: (placeId: any) => void;
textColor?: string; // Optional prop for text color
}
export default function PlaceFilter({ places, selectedPlaceId, onPlaceSelect, textColor }: PlaceFilterProps) {
const [showPicker, setShowPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [keyboardHeight, setKeyboardHeight] = useState(0);
// Keyboard height listener
useEffect(() => {
const showSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
(e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
);
const hideSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
() => setKeyboardHeight(0)
);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
// Reset search query every time modal is opened
useEffect(() => {
if (showPicker) {
setSearchQuery('');
}
}, [showPicker]);
const filteredPlaces = useMemo(() => {
if (!showPicker) return [];
if (!searchQuery.trim()) return places;
const lowerQuery = searchQuery.toLowerCase();
return places.filter(p => p.label.toLowerCase().includes(lowerQuery));
}, [places, searchQuery, showPicker]);
const selectedPlaceLabel = selectedPlaceId
? places.find(p => p.id === selectedPlaceId)?.label || 'Cantiere Selezionato'
: 'Tutti i cantieri';
return (
<View className="mb-8">
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Cantiere</Text>
<View className="flex-row items-center gap-3">
<TouchableOpacity
onPress={() => setShowPicker(true)}
className="flex-1 flex-row items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 shadow-sm"
>
<Text className={`font-medium text-base flex-1 mr-2 ${selectedPlaceId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
{selectedPlaceLabel}
</Text>
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
</TouchableOpacity>
{selectedPlaceId !== null && (
<TouchableOpacity
onPress={() => onPlaceSelect(null)}
className="bg-gray-50 p-4 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
>
<X size={22} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
)}
</View>
{/* Nested Place Picker Modal */}
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
<TouchableOpacity
activeOpacity={1}
onPress={() => setShowPicker(false)}
className="flex-1 bg-black/50 justify-end"
style={{ paddingBottom: keyboardHeight }}
>
<View className="bg-white rounded-t-3xl p-5 max-h-[70%]" onStartShouldSetResponder={() => true}>
<View className="flex-row justify-between items-center mb-4 border-b border-gray-100 pb-4">
<Text className="text-xl font-bold text-gray-800">Seleziona Cantiere</Text>
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full active:bg-gray-200">
<X size={20} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
</View>
{/* Search Bar */}
<View className="bg-gray-100 flex-row items-center px-4 py-3 rounded-2xl mb-4 border border-gray-200 focus:border-[#1071C2]">
<Search size={20} color="#9ca3af" />
<TextInput
className="flex-1 ml-3 text-base text-gray-800"
placeholder="Cerca cantiere..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor="#9ca3af"
autoCorrect={false}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')} className="p-1">
<X size={18} color="#6b7280" />
</TouchableOpacity>
)}
</View>
<FlatList
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 30 }}
keyboardShouldPersistTaps="handled"
data={filteredPlaces}
keyExtractor={(item) => item.id.toString()}
ListHeaderComponent={() => (
searchQuery.trim() === '' ? (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedPlaceId === null ? 'bg-blue-50' : ''}`}
onPress={() => { onPlaceSelect(null); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedPlaceId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
Tutti i cantieri
</Text>
</TouchableOpacity>
) : null
)}
ListEmptyComponent={() => (
<View className="py-8 items-center">
<Text className="text-gray-500 font-medium text-center">Nessun cantiere trovato per "{searchQuery}"</Text>
</View>
)}
initialNumToRender={15}
maxToRenderPerBatch={20}
windowSize={5}
removeClippedSubviews={true}
renderItem={({ item }) => (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedPlaceId === item.id ? 'bg-blue-50' : ''}`}
onPress={() => { onPlaceSelect(item.id); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedPlaceId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
{item.label}
</Text>
</TouchableOpacity>
)}
/>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}

102
components/QrScanModal.tsx Normal file
View File

@@ -0,0 +1,102 @@
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, Modal, TouchableOpacity, Vibration, StyleSheet, Dimensions } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { X, ScanLine } from 'lucide-react-native';
interface QrScanModalProps {
visible: boolean;
onClose: () => void;
onScan: (data: string) => void;
}
export default function QrScanModal({ visible, onClose, onScan }: QrScanModalProps) {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const scanInProgress = useRef(false);
const { width, height } = Dimensions.get('window');
const squareSize = Math.min(width * 0.8, height * 0.8, 400);
// Permission Handling and Reset Scanned State on Modal Open
useEffect(() => {
if (visible) {
setScanned(false);
scanInProgress.current = false;
if (permission && !permission.granted && permission.canAskAgain) {
requestPermission();
}
}
}, [visible, permission]);
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
if (scanInProgress.current) return;
scanInProgress.current = true;
setScanned(true);
Vibration.vibrate();
console.log(`Bar code with type ${type} and data ${data} has been scanned!`);
onScan(data);
onClose();
};
if (!permission) {
return <View />;
}
if (!permission.granted && visible) {
requestPermission();
}
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={onClose}
>
<View className="flex-1 bg-black">
{/* Camera Full Screen */}
<CameraView
style={StyleSheet.absoluteFillObject}
facing="back"
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
/>
{/* Dark Overlay with Transparent "Hole" (Visually Simulated with Borders or Opacity) */}
<SafeAreaView className="flex-1 justify-between bg-black/60 pt-8">
{/* Header Overlay */}
<View className="items-center">
<Text className="text-white text-xl font-bold">Scansiona QR Code</Text>
<Text className="text-gray-300 text-base mt-1">Inquadra il codice nel riquadro</Text>
</View>
{/* Central Area (Transparent for the camera) */}
<View className="items-center justify-center" style={{ height: squareSize }}>
<View style={{ width: squareSize, height: squareSize }}
className="border-2 border-[#1071C2] bg-transparent relative justify-center items-center">
{/* Decorative Corners */}
<View className="absolute top-0 left-0 w-6 h-6 border-l-4 border-t-4 border-[#1071C2]" />
<View className="absolute top-0 right-0 w-6 h-6 border-r-4 border-t-4 border-[#1071C2]" />
<View className="absolute bottom-0 left-0 w-6 h-6 border-l-4 border-b-4 border-[#1071C2]" />
<View className="absolute bottom-0 right-0 w-6 h-6 border-r-4 border-b-4 border-[#1071C2]" />
{/* Animated Scan Line or Icon */}
{!scanned && <ScanLine color="#1071C2" size={40} className="opacity-50" pointerEvents="none" />}
</View>
</View>
{/* Footer Overlay */}
<View className="items-center justify-end pb-12">
<TouchableOpacity onPress={onClose} className="bg-white/20 p-4 rounded-full">
<X color="white" size={32} pointerEvents="none" />
</TouchableOpacity>
<Text className="text-white mt-4 font-medium">Chiudi</Text>
</View>
</SafeAreaView>
</View>
</Modal>
);
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { X } from 'lucide-react-native';
interface RemovablePhotoTileProps {
uri: string;
onRemove: () => void;
size: number;
}
export default function RemovablePhotoTile({ uri, onRemove, size }: RemovablePhotoTileProps) {
return (
<View style={{ width: size, height: size }} className="mb-2">
<View className="bg-gray-100 rounded-2xl overflow-hidden border border-gray-200" style={{ flex: 1 }}>
<Image
source={{ uri }}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
transition={200}
/>
</View>
<TouchableOpacity
onPress={onRemove}
className="absolute top-[-6px] right-[-6px] bg-red-500 rounded-full p-1 border-2 border-white shadow-sm"
activeOpacity={0.8}
>
<X size={14} color="white" strokeWidth={3} />
</TouchableOpacity>
</View>
);
}

View File

@@ -0,0 +1,283 @@
import { useAlert } from '@/components/AlertComponent';
import React, { useState } from 'react';
import { View, Text, Modal, TouchableOpacity, TextInput, ScrollView, Platform } from 'react-native';
import { TimeOffRequestType } from '@/types/types';
import { X } from 'lucide-react-native';
import { TimePickerModal } from './TimePickerModal';
import api from '@/utils/api';
import { formatPickerDate } from '@/utils/dateTime';
import { AppDatePicker } from '@/components/AppDatePicker';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
interface RequestPermitModalProps {
visible: boolean;
types: TimeOffRequestType[];
onClose: () => void;
onSubmit: (data: any) => void;
}
export default function RequestPermitModal({ visible, types, onClose, onSubmit }: RequestPermitModalProps) {
const alert = useAlert();
const [type, setType] = useState<TimeOffRequestType>(types[0]); // Default to first type
const [date, setDate] = useState<string | null>();
const [range, setRange] = useState<{
startDate: string | null;
endDate: string | null;
}>({ startDate: null, endDate: null });
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [message, setMessage] = useState('');
// Clean up function to reset all fields
const clearCalendar = () => {
setDate(null);
setRange({ startDate: null, endDate: null });
setStartTime(''); setEndTime('');
setMessage('');
setType(types[0]);
};
// Function to validate the request
function validateRequest(type: TimeOffRequestType, date: string | null | undefined, range: { startDate: string | null; endDate: string | null }, startTime: string, endTime: string): string | null {
if (!type) return "Seleziona una tipologia di assenza.";
if (type.time_required === 0) {
if (!range.startDate) return "Seleziona una data di inizio.";
return null;
}
if (!date) return "Seleziona una data.";
if (!startTime || !endTime) return "Seleziona gli orari.";
if (startTime >= endTime) return "L'orario di fine deve essere successivo a quello di inizio.";
return null;
}
// Function to send the request to the API
const saveRequest = async (requestData: any) => {
try {
const response = await api.post('/request/add', requestData);
if (response.data.success) {
alert.showAlert('success', 'Successo', response.data.message || 'La tua richiesta è stata inviata con successo.');
onSubmit(requestData);
onClose();
} else {
alert.showAlert('error', 'Errore', response.data.message || 'Impossibile inviare la richiesta.');
}
} catch (error: any) {
console.error('Errore nell\'invio della richiesta:', error);
throw new Error('Impossibile inviare la richiesta.');
}
};
// Function to submit the request
const handleSubmit = async () => {
const error = validateRequest(type, date, range, startTime, endTime);
if (error) {
alert.showAlert("error", "Errore", error);
return;
}
// Prepare the interval based on the type of request
let interval = null;
if (type.time_required === 0) {
if (range.startDate) {
interval = range.startDate;
}
if (range.endDate && range.endDate !== range.startDate) {
if (interval) {
interval += ',';
}
interval += range.endDate;
}
} else {
interval = date;
}
// Build the request data object
const requestData = {
type: type.name,
interval: interval,
startTime: type.time_required === 1 ? startTime : null,
endTime: type.time_required === 1 ? endTime : null,
message: message ? message : null
};
try {
await saveRequest(requestData);
} catch (e) {
alert.showAlert("error", "Errore", "Impossibile inviare la richiesta.");
}
};
return (
<Modal
visible={visible}
transparent={true}
animationType="slide"
statusBarTranslucent
>
<View className="flex-1 bg-black/60 justify-end sm:justify-center">
<View className="bg-white w-full rounded-t-[2.5rem] p-6 shadow-2xl h-[85%] sm:h-auto">
{/* Modal Header */}
<View className="flex-row justify-between items-center mb-6">
<Text className="text-2xl font-bold text-gray-800">Nuova Richiesta</Text>
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
<X size={24} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
</View>
<KeyboardAwareScrollView
bottomOffset={Platform.OS === 'ios' ? 50 : 80}
disableScrollOnKeyboardHide={false}
enabled={true}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: 70,
flexGrow: 1
}}
className="flex-1"
>
<View className="space-y-6">
{/* Permit Type */}
<View className='mb-6'>
<Text className="text-lg font-bold text-gray-700 mb-3">Tipologia Assenza</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 0, gap: 12 }}
>
{types.map((t) => (
<TouchableOpacity
key={t.id}
onPress={() => setType(t)}
className={`py-4 px-5 rounded-xl border-2 items-center justify-center ${type?.id === t.id ? 'border-[#1071C2] bg-blue-50' : 'border-gray-100 bg-white'
}`}
>
<Text className={`text-sm font-bold ${type?.id === t.id ? 'text-[#1071C2]' : 'text-gray-500'}`}>
{t.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Date and Time Selection */}
{type?.time_required === 0 ? (
<AppDatePicker
mode="range"
startDate={range.startDate}
endDate={range.endDate}
onChange={(params) => {
setRange({
startDate: params.startDate ? formatPickerDate(params.startDate) : null,
endDate: params.endDate ? formatPickerDate(params.endDate) : null
})
}}
/>
) : (
<AppDatePicker
mode="single"
date={date}
onChange={({ date }) => setDate(date ? formatPickerDate(date) : null)}
/>
)}
<View className='flex-column bg-gray-50 rounded-xl border border-gray-100 mb-6'>
{type?.time_required === 1 && (
<View>
<View className="flex-row gap-4 p-4">
<View className="flex-1">
<Text className="text-sm font-bold text-gray-700 mb-2 uppercase">Dalle Ore</Text>
<TouchableOpacity onPress={() => setShowStartPicker(true)}>
<TextInput
placeholder="09:00"
placeholderTextColor="#9CA3AF"
className="w-full p-3 bg-white rounded-lg border border-gray-200 font-bold text-gray-800 text-center"
value={startTime}
onChangeText={setStartTime}
editable={false}
pointerEvents="none"
/>
</TouchableOpacity>
</View>
<View className="flex-1">
<Text className="text-sm font-bold text-gray-700 mb-2 uppercase">Alle Ore</Text>
<TouchableOpacity onPress={() => setShowEndPicker(true)}>
<TextInput
placeholder="18:00"
placeholderTextColor="#9CA3AF"
className="w-full p-3 bg-white rounded-lg border border-gray-200 font-bold text-gray-800 text-center"
value={endTime}
onChangeText={setEndTime}
editable={false}
pointerEvents="none"
/>
</TouchableOpacity>
</View>
</View>
<TimePickerModal
visible={showStartPicker}
initialDate={new Date()}
title="Seleziona Ora Inizio"
onConfirm={(time) => setStartTime(time)}
onClose={() => setShowStartPicker(false)}
/>
<TimePickerModal
visible={showEndPicker}
initialDate={new Date()}
title="Seleziona Ora Fine"
onConfirm={(time) => setEndTime(time)}
onClose={() => setShowEndPicker(false)}
/>
</View>
)}
{/* Reason field */}
<View className="p-4 pt-2">
<Text className="text-sm font-bold text-gray-700 mb-2 uppercase">Motivo</Text>
<TextInput
placeholder="(opzionale)"
placeholderTextColor="#9CA3AF"
className="w-full px-3 py-3 bg-white font-bold text-gray-800 rounded-lg border border-gray-200"
textAlignVertical="top"
value={message}
onChangeText={setMessage}
multiline
numberOfLines={3}
/>
</View>
</View>
{/* Actions */}
<View className="flex-row gap-4">
<TouchableOpacity
onPress={() => {
clearCalendar();
onClose();
}}
className="flex-1 py-4 bg-gray-200 rounded-2xl shadow-sm active:bg-gray-300"
>
<Text className="text-gray-700 text-center font-bold text-lg">Annulla Richiesta</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSubmit}
className="flex-1 py-4 bg-[#1071C2] rounded-2xl shadow-lg active:scale-[0.98]"
>
<Text className="text-white text-center font-bold text-lg">Invia Richiesta</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAwareScrollView>
</View>
</View>
</Modal>
);
};

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import {
KeyboardAvoidingView,
Modal,
Platform,
Text,
TextInput,
TouchableOpacity,
View,
Keyboard,
ScrollView
} from 'react-native';
import { X } from 'lucide-react-native';
interface SetDescriptionModalProps {
visible: boolean;
initialDescription: string;
onClose: () => void;
onSave: (desc: string) => void;
}
export default function SetDescriptionModal({
visible,
initialDescription,
onClose,
onSave
}: SetDescriptionModalProps) {
const [desc, setDesc] = useState(initialDescription);
useEffect(() => {
if (visible) {
setDesc(initialDescription || '');
}
}, [visible, initialDescription]);
return (
<Modal
visible={visible}
transparent
animationType="slide"
statusBarTranslucent
onRequestClose={onClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
className="flex-1"
>
<View className="flex-1 bg-black/60 justify-end">
{/* Backdrop */}
<TouchableOpacity
className="flex-1 w-full"
onPress={() => {
Keyboard.dismiss();
onClose();
}}
activeOpacity={1}
/>
<View className="bg-white w-full rounded-t-[2.5rem] p-6 shadow-2xl max-h-[85%]">
{/* Header */}
<View className="flex-row justify-between items-center mb-6">
<Text className="text-2xl font-bold text-gray-800">Descrizione Foto</Text>
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
<X size={24} color="#4b5563" />
</TouchableOpacity>
</View>
{/* Text Area with Scroll */}
<ScrollView
className="mb-6"
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
bounces={false}
>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-2xl p-4 text-base text-gray-800"
placeholder="Inserisci una descrizione... (max 250 caratteri)"
placeholderTextColor="#9ca3af"
multiline
maxLength={250}
value={desc}
onChangeText={setDesc}
style={{ minHeight: 120, maxHeight: 200, textAlignVertical: 'top' }}
/>
<Text className={`text-right mt-2 text-sm font-medium ${desc.length >= 250 ? 'text-red-500' : 'text-gray-500'}`}>
{desc.length}/250
</Text>
</ScrollView>
{/* Actions */}
<View className="flex-row gap-4 mb-4">
<TouchableOpacity
onPress={onClose}
className="flex-1 py-4 bg-gray-200 rounded-2xl shadow-sm active:bg-gray-300"
>
<Text className="text-gray-700 text-center font-bold text-lg">Annulla</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onSave(desc)}
className="flex-1 py-4 rounded-2xl shadow-lg active:scale-[0.98] bg-[#1071C2]"
>
<Text className="text-white text-center font-bold text-lg">Salva</Text>
</TouchableOpacity>
</View>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}

View File

@@ -0,0 +1,180 @@
import { Supplier } from '@/types/types';
import api from '@/utils/api';
import { ChevronDown, Search, X } from 'lucide-react-native';
import React, { useState, useEffect, useMemo } from 'react';
import { Modal, FlatList, Text, TextInput, TouchableOpacity, View, Keyboard, KeyboardEvent, Platform, InteractionManager, ActivityIndicator } from 'react-native';
interface SupplierFilterProps {
selectedSupplierId: any;
onSupplierSelect: (supplierId: any) => void;
textColor?: string;
}
export default function SupplierFilter({ selectedSupplierId, onSupplierSelect, textColor }: SupplierFilterProps) {
const [showPicker, setShowPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [isFetching, setIsFetching] = useState(true);
useEffect(() => {
const fetchSuppliers = async () => {
try {
const res = await api.get('/registry/get-suppliers');
if (res.data?.success) {
setSuppliers(res.data.suppliers || []);
}
} catch (err) {
console.error('Error fetching suppliers:', err);
} finally {
setIsFetching(false);
}
};
// Delay the heavy API call and state update until the modal animation finishes
InteractionManager.runAfterInteractions(() => {
fetchSuppliers();
});
}, []);
// Keyboard height listener
useEffect(() => {
const showSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
(e: KeyboardEvent) => setKeyboardHeight(e.endCoordinates.height)
);
const hideSubscription = Keyboard.addListener(
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
() => setKeyboardHeight(0)
);
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
// Reset search query every time modal is opened
useEffect(() => {
if (showPicker) {
setSearchQuery('');
}
}, [showPicker]);
const filteredSuppliers = useMemo(() => {
if (!showPicker) return [];
if (!searchQuery.trim()) return suppliers;
const lowerQuery = searchQuery.toLowerCase();
return suppliers.filter(s => s.label.toLowerCase().includes(lowerQuery));
}, [suppliers, searchQuery, showPicker]);
const selectedSupplierLabel = selectedSupplierId
? suppliers.find(s => s.code === selectedSupplierId)?.label || 'Fornitore Selezionato'
: 'Tutti i fornitori';
return (
<View className="mb-8">
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Fornitore</Text>
<View className="flex-row items-center gap-3">
<TouchableOpacity
onPress={() => setShowPicker(true)}
className="flex-1 flex-row items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 shadow-sm"
>
<Text className={`font-medium text-base flex-1 mr-2 ${selectedSupplierId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
{selectedSupplierLabel}
</Text>
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
</TouchableOpacity>
{selectedSupplierId !== null && (
<TouchableOpacity
onPress={() => onSupplierSelect(null)}
className="bg-gray-50 p-4 rounded-2xl border border-gray-200 shadow-sm justify-center items-center"
>
<X size={22} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
)}
</View>
{/* Nested Place Picker Modal */}
<Modal visible={showPicker} transparent={true} animationType="fade" onRequestClose={() => setShowPicker(false)}>
<TouchableOpacity
activeOpacity={1}
onPress={() => setShowPicker(false)}
className="flex-1 bg-black/50 justify-end"
style={{ paddingBottom: keyboardHeight }}
>
<View className="bg-white rounded-t-3xl p-5 max-h-[70%]" onStartShouldSetResponder={() => true}>
<View className="flex-row justify-between items-center mb-4 border-b border-gray-100 pb-4">
<Text className="text-xl font-bold text-gray-800">Seleziona Fornitore</Text>
<TouchableOpacity onPress={() => setShowPicker(false)} className="p-2 bg-gray-100 rounded-full active:bg-gray-200">
<X size={20} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
</View>
{/* Search Bar */}
<View className="bg-gray-100 flex-row items-center px-4 py-3 rounded-2xl mb-4 border border-gray-200 focus:border-[#1071C2]">
<Search size={20} color="#9ca3af" />
<TextInput
className="flex-1 ml-3 text-base text-gray-800"
placeholder="Cerca fornitore..."
value={searchQuery}
onChangeText={setSearchQuery}
placeholderTextColor="#9ca3af"
autoCorrect={false}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')} className="p-1">
<X size={18} color="#6b7280" />
</TouchableOpacity>
)}
</View>
<FlatList
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 30 }}
keyboardShouldPersistTaps="handled"
data={filteredSuppliers}
keyExtractor={(item, index) => item.code?.toString() ?? `no-code-${index}`}
ListHeaderComponent={() => (
searchQuery.trim() === '' ? (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedSupplierId === null ? 'bg-blue-50' : ''}`}
onPress={() => { onSupplierSelect(null); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedSupplierId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
Tutti i fornitori
</Text>
</TouchableOpacity>
) : null
)}
ListEmptyComponent={() => (
<View className="py-8 items-center">
{isFetching ? (
<ActivityIndicator size="large" color="#1071C2" />
) : (
<Text className="text-gray-500 font-medium text-center">Nessun fornitore trovato per "{searchQuery}"</Text>
)}
</View>
)}
initialNumToRender={15}
maxToRenderPerBatch={20}
windowSize={5}
removeClippedSubviews={true}
renderItem={({ item }) => (
<TouchableOpacity
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedSupplierId === item.code ? 'bg-blue-50' : ''}`}
onPress={() => { onSupplierSelect(item.code); setShowPicker(false); }}
>
<Text className={`text-lg ${selectedSupplierId === item.code ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
{item.label}
</Text>
</TouchableOpacity>
)}
/>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}

View File

@@ -0,0 +1,70 @@
import React, { useState } from 'react';
import { Modal, View, TouchableOpacity, Text } from 'react-native';
import DateTimePicker, { DateType, useDefaultStyles } from 'react-native-ui-datepicker';
import { X } from 'lucide-react-native';
import dayjs from 'dayjs';
interface TimePickerModalProps {
visible: boolean;
initialDate?: DateType;
title?: string;
onConfirm: (time: string) => void;
onClose: () => void;
}
export const TimePickerModal = ({ visible, initialDate, title, onConfirm, onClose }: TimePickerModalProps) => {
const defaultStyles = useDefaultStyles('light');
const [selectedDate, setSelectedDate] = useState<DateType>(initialDate || new Date());
const formatTime = (date?: DateType | null) => {
if (!date) return "00:00";
date = dayjs(date);
const hour = date?.hour().toString().padStart(2, "0") ?? "00";
const minute = date?.minute().toString().padStart(2, "0") ?? "00";
return `${hour}:${minute}`;
};
const handleConfirm = () => {
const time = formatTime(selectedDate);
console.log("Selected time:", time);
onConfirm(time);
onClose();
};
return (
<Modal visible={visible} transparent animationType="fade">
<View className="flex-1 justify-center items-center bg-black/50">
<View className="bg-white rounded-xl p-4 w-[90%] max-h-[400px]">
{/* Header */}
<View className="flex-row justify-between items-center mb-4">
<Text className="text-lg font-bold text-gray-800">{title}</Text>
<TouchableOpacity onPress={onClose} className="p-2 bg-gray-100 rounded-full">
<X size={20} color="#4b5563" pointerEvents="none" />
</TouchableOpacity>
</View>
{/* TimePicker */}
<DateTimePicker
mode="single"
timePicker
date={selectedDate}
initialView="time"
hideHeader
containerHeight={200}
styles={defaultStyles}
onChange={(d) => setSelectedDate(d.date || new Date())}
/>
{/* Confirm Button */}
<TouchableOpacity
onPress={handleConfirm}
className="mt-4 w-full py-3 bg-[#1071C2] rounded-xl shadow-lg active:scale-[0.98]"
>
<Text className="text-white text-center font-bold text-lg">Applica</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { CloudDownload } from 'lucide-react-native';
interface UpdateScreenProps {
onUpdate: () => void;
isOpeningStore?: boolean;
}
export default function UpdateScreen({ onUpdate, isOpeningStore = false }: UpdateScreenProps) {
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center px-8">
{/* Icon */}
<View className="bg-blue-50 p-6 rounded-full mb-6">
<CloudDownload size={64} className="text-[#1071C2]" pointerEvents="none" />
</View>
<Text className="text-2xl font-bold text-gray-800 mb-2 text-center">
Aggiornamento Richiesto
</Text>
<Text className="text-base text-gray-500 text-center mb-10 leading-6">
È disponibile una nuova versione dell'applicazione.{'\n'}Per continuare a utilizzarla è necessario effettuare l'aggiornamento.
</Text>
{/* Update Button */}
<TouchableOpacity
onPress={onUpdate}
disabled={isOpeningStore}
className={`flex-row items-center justify-center w-full py-4 rounded-[2rem] gap-4 ${
isOpeningStore ? 'bg-gray-300' : 'bg-[#1071C2] active:opacity-90'
}`}
>
<Text className="text-white font-bold text-lg">
{isOpeningStore ? 'Apertura store...' : 'Aggiorna Ora'}
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}