Initial commit
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, TouchableOpacity, TextInput } from 'react-native';
|
||||
import { Plus, Trash2, CheckCircle2 } from 'lucide-react-native';
|
||||
import GenericDropdown from './GenericDropdown';
|
||||
import api from '@/utils/api';
|
||||
import { useAlert } from './AlertComponent';
|
||||
|
||||
interface LaborItem {
|
||||
id_registry: number;
|
||||
id_user: number | null;
|
||||
name: string;
|
||||
hours: string | number;
|
||||
minutes: string | number;
|
||||
sync?: boolean;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface ActivityLaborCardProps {
|
||||
title: string;
|
||||
fetchUrl: string;
|
||||
laborList: LaborItem[];
|
||||
onAddLabor: (labor: LaborItem) => void;
|
||||
onRemoveLabor: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function ActivityLaborCard({
|
||||
title,
|
||||
fetchUrl,
|
||||
laborList,
|
||||
onAddLabor,
|
||||
onRemoveLabor
|
||||
}: ActivityLaborCardProps) {
|
||||
const alert = useAlert();
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
|
||||
// Form states
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [hours, setHours] = useState('');
|
||||
const [minutes, setMinutes] = useState('');
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get(fetchUrl);
|
||||
if (res.data?.success && res.data.items) {
|
||||
// Map the items to include an 'id' property required by GenericDropdown
|
||||
const mappedItems = res.data.items.map((item: any) => ({
|
||||
...item,
|
||||
id: item.id_registry
|
||||
}));
|
||||
setItems(mappedItems);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching labor items:', error);
|
||||
}
|
||||
}, [fetchUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selectedId) {
|
||||
alert.showAlert('error', 'Attenzione', 'Selezionare un elemento dalla lista');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hours === '' && minutes === '') {
|
||||
alert.showAlert('error', 'Attenzione', 'Inserire le ore o i minuti di lavoro');
|
||||
return;
|
||||
}
|
||||
|
||||
const el = items.find(data => data.id_registry === selectedId);
|
||||
if (el) {
|
||||
const newLabor: LaborItem = {
|
||||
id_registry: selectedId,
|
||||
id_user: el.id_user,
|
||||
name: el.label,
|
||||
hours: hours === '' ? '0' : hours,
|
||||
minutes: minutes === '' ? '0' : minutes,
|
||||
};
|
||||
onAddLabor(newLabor);
|
||||
|
||||
// Reset and hide
|
||||
setSelectedId(null);
|
||||
setHours('');
|
||||
setMinutes('');
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInput = () => {
|
||||
setSelectedId(null);
|
||||
setHours('');
|
||||
setMinutes('');
|
||||
setShowInput(!showInput);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 mb-4">
|
||||
<View className="flex-row items-center justify-between border-b border-gray-50 pb-3 mb-3">
|
||||
<Text className="text-[#082963] font-bold text-lg">{title}</Text>
|
||||
<TouchableOpacity onPress={toggleInput} className="active:opacity-70">
|
||||
<Text className="text-[#1071C2] font-bold text-sm">
|
||||
{showInput ? 'Annulla' : 'Aggiungi'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{showInput && (
|
||||
<View className="bg-blue-50/50 p-4 rounded-2xl mb-4 border border-blue-100">
|
||||
<View className="mb-3 z-50">
|
||||
<GenericDropdown
|
||||
options={items}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => setSelectedId(id as number)}
|
||||
placeholder="Seleziona..."
|
||||
searchPlaceholder="Cerca per nome"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-2">
|
||||
<View className="flex-1 bg-white rounded-xl border border-gray-200 px-3 py-2">
|
||||
<TextInput
|
||||
value={hours}
|
||||
onChangeText={setHours}
|
||||
placeholder="Ore"
|
||||
placeholderTextColor="#9ca3af"
|
||||
keyboardType="numeric"
|
||||
className="text-gray-800 font-medium"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1 bg-white rounded-xl border border-gray-200 px-3 py-2">
|
||||
<TextInput
|
||||
value={minutes}
|
||||
onChangeText={setMinutes}
|
||||
placeholder="Minuti"
|
||||
placeholderTextColor="#9ca3af"
|
||||
keyboardType="numeric"
|
||||
className="text-gray-800 font-medium"
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleAdd}
|
||||
className="bg-[#1071C2] h-[46px] w-[46px] items-center justify-center rounded-xl"
|
||||
>
|
||||
<Plus size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="gap-2">
|
||||
{laborList.map((item, index) => (
|
||||
<View key={index} className="flex-row justify-between items-center bg-gray-50 p-3 rounded-2xl">
|
||||
<Text className="text-gray-800 font-medium flex-1 mr-2" numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="bg-white px-3 py-1.5 rounded-xl border border-gray-200">
|
||||
<Text className="text-[#1071C2] font-bold">
|
||||
{item.hours}h {item.minutes}m
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{item.sync ? (
|
||||
<View className="w-8 items-center justify-center">
|
||||
<CheckCircle2 size={20} color="#0F9D58" />
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemoveLabor(index)}
|
||||
className="w-8 items-center justify-center"
|
||||
>
|
||||
<Trash2 size={20} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{laborList.length === 0 && !showInput && (
|
||||
<Text className="text-gray-400 text-center italic mt-2">Nessun elemento inserito</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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-lg font-bold text-primary-dark mb-1 leading-tight uppercase" numberOfLines={2}>
|
||||
{item.constructionSite}
|
||||
</Text>
|
||||
<Text className="text-base font-medium text-primary-dark mb-1 leading-tight" numberOfLines={2}>
|
||||
{item.subactivity}
|
||||
</Text>
|
||||
<Text className="text-sm font-bold text-gray-400">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Client } 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 ClientFilterProps {
|
||||
clients: Client[];
|
||||
selectedClientId: any;
|
||||
onClientSelect: (clientId: any) => void;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export default function ClientFilter({ clients, selectedClientId, onClientSelect, textColor }: ClientFilterProps) {
|
||||
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 filteredClients = useMemo(() => {
|
||||
if (!showPicker) return [];
|
||||
if (!searchQuery.trim()) return clients;
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return clients.filter(c => c.label.toLowerCase().includes(lowerQuery));
|
||||
}, [clients, searchQuery, showPicker]);
|
||||
|
||||
const selectedClientLabel = selectedClientId
|
||||
? clients.find(c => c.id === selectedClientId)?.label || 'Committente Selezionato'
|
||||
: 'Tutti i committenti';
|
||||
|
||||
return (
|
||||
<View className="mb-8">
|
||||
<Text className={`text-lg font-bold ${textColor || 'text-gray-700'} mb-3`}>Committente</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 ${selectedClientId ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
|
||||
{selectedClientLabel}
|
||||
</Text>
|
||||
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{selectedClientId !== null && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onClientSelect(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 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 Committente</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 committente..."
|
||||
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={filteredClients}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
ListHeaderComponent={() => (
|
||||
searchQuery.trim() === '' ? (
|
||||
<TouchableOpacity
|
||||
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedClientId === null ? 'bg-blue-50' : ''}`}
|
||||
onPress={() => { onClientSelect(null); setShowPicker(false); }}
|
||||
>
|
||||
<Text className={`text-lg ${selectedClientId === null ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||
Tutti i committenti
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 font-medium text-center">Nessun committente 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 ${selectedClientId === item.id ? 'bg-blue-50' : ''}`}
|
||||
onPress={() => { onClientSelect(item.id); setShowPicker(false); }}
|
||||
>
|
||||
<Text className={`text-lg ${selectedClientId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ConstructionSite } from '@/types/types';
|
||||
import { Building2, MapPin } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface ConstructionSiteCardProps {
|
||||
item: ConstructionSite;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export default function ConstructionSiteCard({ item, onPress }: ConstructionSiteCardProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
className="bg-white rounded-3xl p-5 mb-4 shadow-sm border border-slate-100 flex-row items-center active:scale-[0.98]"
|
||||
>
|
||||
<View className="bg-primary-50 p-4 rounded-2xl mr-4 shadow-sm">
|
||||
<Building2 size={28} color="#1071C2" pointerEvents="none" />
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-bold text-text uppercase leading-tight">
|
||||
{item.label}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-start pr-2">
|
||||
<Text className="text-sm font-medium text-slate-500 leading-snug">
|
||||
{item.address}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{item.client && (
|
||||
<Text className="text-xs font-bold text-slate-400 uppercase tracking-wider leading-relaxed">
|
||||
{item.client}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DocumentItem } from '@/types/types';
|
||||
import { downloadAndShareDocument } from '@/utils/documentUtils';
|
||||
import { Download, FileText } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface DocumentListCardProps {
|
||||
item: DocumentItem;
|
||||
}
|
||||
|
||||
export default function DocumentListCard({ item }: DocumentListCardProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadAndShareDocument(item.mimetype, item.filename, item.url);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="bg-white p-5 rounded-3xl shadow-sm flex-row items-center justify-between border border-gray-100">
|
||||
<View className="flex-row items-center gap-5 flex-1">
|
||||
<View className="bg-blue-50 p-4 rounded-2xl flex-shrink-0">
|
||||
<FileText size={32} color="#1071C2" pointerEvents="none" />
|
||||
</View>
|
||||
<View className="flex-1 mr-2">
|
||||
<Text className="font-bold text-gray-800 text-base leading-tight uppercase" numberOfLines={3}>
|
||||
{item.filename}
|
||||
</Text>
|
||||
<View className="flex-row items-center mt-2">
|
||||
<Text className="text-sm text-gray-400 font-bold">{item.date}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="p-4 bg-gray-50 rounded-2xl active:bg-gray-100 flex-shrink-0 border border-gray-100"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size="small" color="#1071C2" />
|
||||
) : (
|
||||
<Download size={24} color="#1071C2" pointerEvents="none" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as echarts from 'echarts/core';
|
||||
import SvgChart, { SVGRenderer } from '@wuba/react-native-echarts/svgChart';
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts';
|
||||
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components';
|
||||
|
||||
echarts.use([
|
||||
SVGRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent
|
||||
]);
|
||||
|
||||
interface EchartWrapperProps {
|
||||
option: any;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function EchartWrapper({ option, width, height }: EchartWrapperProps) {
|
||||
const chartRef = useRef<any>(null);
|
||||
const instanceRef = useRef<any>(null);
|
||||
|
||||
// Chart initialization and disposal
|
||||
useEffect(() => {
|
||||
let chart: any;
|
||||
if (chartRef.current) {
|
||||
chart = echarts.init(chartRef.current, 'light', {
|
||||
renderer: 'svg',
|
||||
width,
|
||||
height,
|
||||
});
|
||||
instanceRef.current = chart;
|
||||
if (option) {
|
||||
chart.setOption(option, true);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
chart?.dispose();
|
||||
instanceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update options when option prop changes
|
||||
useEffect(() => {
|
||||
if (instanceRef.current && option) {
|
||||
instanceRef.current.setOption(option, true);
|
||||
}
|
||||
}, [option]);
|
||||
|
||||
// Handle dynamic resize
|
||||
useEffect(() => {
|
||||
if (instanceRef.current && width && height) {
|
||||
instanceRef.current.resize({ width, height });
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
return <SvgChart ref={chartRef} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { FileText, Trash2 } from 'lucide-react-native';
|
||||
import { DocumentPickerAsset } from 'expo-document-picker';
|
||||
|
||||
interface FileAttachmentCardProps {
|
||||
file: DocumentPickerAsset;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export default function FileAttachmentCard({ file, onRemove }: FileAttachmentCardProps) {
|
||||
|
||||
// Format size in readable format if available
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes) return 'Dimensione sconosciuta';
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center bg-white border border-gray-200 rounded-2xl p-4 mb-3 shadow-sm">
|
||||
{/* File Icon */}
|
||||
<View className="bg-blue-50 p-3 rounded-xl mr-4 border border-blue-100">
|
||||
<FileText size={28} color="#1071C2" />
|
||||
</View>
|
||||
|
||||
{/* File Info */}
|
||||
<View className="flex-1 mr-3 justify-center">
|
||||
<Text
|
||||
className="text-gray-800 font-bold text-base mb-1"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text className="text-gray-400 font-medium text-sm">
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Remove Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onRemove}
|
||||
className="p-3 bg-red-50 border border-red-100 rounded-xl active:bg-red-100"
|
||||
>
|
||||
<Trash2 size={18} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AppDatePicker } from '@/components/AppDatePicker';
|
||||
import ClientFilter from '@/components/ClientFilter';
|
||||
import PlaceFilter from '@/components/PlaceFilter';
|
||||
import SupplierFilter from '@/components/SupplierFilter';
|
||||
import { Client, 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[];
|
||||
clients?: Client[];
|
||||
currentRange?: { startDate: string | null; endDate: string | null };
|
||||
currentPlace?: Place | null;
|
||||
currentClient?: Client | null;
|
||||
currentSupplier?: Supplier | null;
|
||||
showDate?: boolean;
|
||||
showPlace?: boolean;
|
||||
showClient?: boolean;
|
||||
showSupplier?: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (range: { startDate: string | null; endDate: string | null }, place: Place | null, client: Client | null, supplier: Supplier | null) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export default function FilterModal({
|
||||
visible,
|
||||
places = [],
|
||||
clients = [],
|
||||
currentRange = { startDate: null, endDate: null },
|
||||
currentPlace = null,
|
||||
currentClient = null,
|
||||
currentSupplier = null,
|
||||
showDate = true,
|
||||
showPlace = true,
|
||||
showClient = false,
|
||||
showSupplier = false,
|
||||
onClose,
|
||||
onApply,
|
||||
onReset,
|
||||
}: FilterModalProps) {
|
||||
const [localRange, setLocalRange] = useState<{ startDate: string | null; endDate: string | null }>(currentRange);
|
||||
const [localPlace, setLocalPlace] = useState<any>(currentPlace);
|
||||
const [localClient, setLocalClient] = useState<any>(currentClient);
|
||||
const [localSupplier, setLocalSupplier] = useState<any>(currentSupplier);
|
||||
|
||||
// Sync local state when modal opens
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setLocalRange(currentRange);
|
||||
setLocalPlace(currentPlace);
|
||||
setLocalClient(currentClient);
|
||||
setLocalSupplier(currentSupplier);
|
||||
}
|
||||
}, [visible, currentRange, currentPlace, currentClient, currentSupplier]);
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(localRange, localPlace, localClient, localSupplier);
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Client Selection */}
|
||||
{showClient && (
|
||||
<ClientFilter
|
||||
clients={clients}
|
||||
selectedClientId={localClient}
|
||||
onClientSelect={setLocalClient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Supplier Selection */}
|
||||
{showSupplier && (
|
||||
<SupplierFilter
|
||||
selectedSupplierId={localSupplier}
|
||||
onSupplierSelect={setLocalSupplier}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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 DropdownOption {
|
||||
id: any;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GenericDropdownProps {
|
||||
options: DropdownOption[];
|
||||
selectedId: any;
|
||||
onSelect: (id: any) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export default function GenericDropdown({
|
||||
options,
|
||||
selectedId,
|
||||
onSelect,
|
||||
placeholder = 'Seleziona un\'opzione...',
|
||||
searchPlaceholder = 'Cerca...',
|
||||
showSearch = true
|
||||
}: GenericDropdownProps) {
|
||||
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 filteredOptions = useMemo(() => {
|
||||
if (!showPicker) return [];
|
||||
if (!searchQuery.trim()) return options;
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return options.filter(o => o.label.toLowerCase().includes(lowerQuery));
|
||||
}, [options, searchQuery, showPicker]);
|
||||
|
||||
const selectedLabel = selectedId !== null && selectedId !== undefined
|
||||
? options.find(o => o.id === selectedId)?.label || placeholder
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<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 ${selectedId !== null && selectedId !== undefined ? 'text-gray-800' : 'text-gray-500'}`} numberOfLines={1}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<ChevronDown size={20} color="#6b7280" pointerEvents="none" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{selectedId !== null && selectedId !== undefined && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onSelect(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>
|
||||
|
||||
<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</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 */}
|
||||
{showSearch && (
|
||||
<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={searchPlaceholder}
|
||||
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={filteredOptions}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
ListHeaderComponent={() => (
|
||||
searchQuery.trim() === '' ? (
|
||||
<TouchableOpacity
|
||||
className={`py-4 px-4 rounded-xl flex-row justify-between items-center mb-2 ${selectedId === null || selectedId === undefined ? 'bg-blue-50' : ''}`}
|
||||
onPress={() => { onSelect(null); setShowPicker(false); }}
|
||||
>
|
||||
<Text className={`text-lg ${selectedId === null || selectedId === undefined ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||
Nessuna selezione
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 font-medium text-center">Nessun risultato trovato</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 ${selectedId === item.id ? 'bg-blue-50' : ''}`}
|
||||
onPress={() => { onSelect(item.id); setShowPicker(false); }}
|
||||
>
|
||||
<Text className={`text-lg ${selectedId === item.id ? 'font-bold text-[#1071C2]' : 'text-gray-700'}`}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, Text, Modal, TouchableOpacity, Vibration, StyleSheet, useWindowDimensions } 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 } = useWindowDimensions();
|
||||
const squareSize = Math.min(width * 0.8, height * 0.8, 400);
|
||||
|
||||
// Permission handling and scanned state reset on modal open
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
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 />;
|
||||
}
|
||||
|
||||
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'],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mask Overlay with transparent cutout */}
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents="none">
|
||||
<View className="flex-1 bg-black/60" />
|
||||
<View style={{ height: squareSize }} className="flex-row">
|
||||
<View className="flex-1 bg-black/60" />
|
||||
|
||||
{/* Scanner Target Frame */}
|
||||
<View
|
||||
style={{ width: squareSize, height: squareSize }}
|
||||
className="border-2 border-[#1071C2] justify-center items-center relative"
|
||||
>
|
||||
<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]" />
|
||||
|
||||
{!scanned && <ScanLine color="#1071C2" size={40} />}
|
||||
</View>
|
||||
|
||||
<View className="flex-1 bg-black/60" />
|
||||
</View>
|
||||
<View className="flex-1 bg-black/60" />
|
||||
</View>
|
||||
|
||||
{/* UI Overlay */}
|
||||
<SafeAreaView style={StyleSheet.absoluteFillObject} pointerEvents="box-none">
|
||||
<View className="flex-1 justify-between pt-8">
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="items-center pb-12">
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className="bg-white/20 p-4 rounded-full"
|
||||
>
|
||||
<X color="white" size={32} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-white mt-4 font-medium">
|
||||
Chiudi
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { CheckCircle2, AlertTriangle, Briefcase, Calendar, MapPin } from 'lucide-react-native';
|
||||
import { QualityControlItem } from '@/types/types';
|
||||
|
||||
interface QualityControlCardProps {
|
||||
item: QualityControlItem;
|
||||
}
|
||||
|
||||
export default function QualityControlCard({ item }: QualityControlCardProps) {
|
||||
const isSuccess = item.result === 1;
|
||||
|
||||
return (
|
||||
<View className="bg-white rounded-3xl p-5 mb-4 shadow-sm border border-gray-100 flex-row items-center gap-4">
|
||||
|
||||
{/* Icon Status */}
|
||||
<View className={`w-14 h-14 rounded-full items-center justify-center shadow-sm ${isSuccess ? 'bg-green-100 shadow-green-200' : 'bg-red-100 shadow-red-200'}`}>
|
||||
{isSuccess ? (
|
||||
<CheckCircle2 size={32} color="#16a34a" pointerEvents="none" />
|
||||
) : (
|
||||
<AlertTriangle size={32} color="#dc2626" pointerEvents="none" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1 justify-center">
|
||||
<Text
|
||||
className="text-lg font-bold text-gray-800 uppercase leading-tight mb-1"
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{item.constructionSite}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-1 mb-1.5 mt-0.5">
|
||||
<MapPin size={14} color="#6b7280" />
|
||||
<Text
|
||||
className="text-sm font-medium text-gray-500 leading-snug flex-1"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{item.subactivity}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Calendar size={14} color="#9ca3af" />
|
||||
<Text className="text-sm font-semibold text-gray-400">
|
||||
{item.date}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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('/time-off-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 = {
|
||||
id_type: type.id,
|
||||
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)}
|
||||
style={type?.id === t.id ? { borderColor: t.color, backgroundColor: `${t.color}15` } : {}}
|
||||
className={`py-4 px-5 rounded-xl border-2 items-center justify-center ${type?.id !== t.id ? 'border-gray-100 bg-white' : ''}`}
|
||||
>
|
||||
<Text
|
||||
style={type?.id === t.id ? { color: t.color } : {}}
|
||||
className={`text-sm font-bold ${type?.id !== t.id ? '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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user