Initial commit

This commit is contained in:
2026-08-31 16:50:53 +02:00
commit a68c8864b0
80 changed files with 22222 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
import axios from 'axios';
import * as SecureStore from 'expo-secure-store';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL;
export const KEY_TOKEN = 'auth_key';
// Create an Axios instance with default configuration
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
timeout: 10000, // 10 seconds timeout
});
// Export function to update base URL
export const setApiBaseUrl = (url: string) => {
if (url) {
api.defaults.baseURL = url;
console.log(`[API] Base URL updated to: ${url}`);
}
};
// Interceptor: Adds the token to EVERY request if it exists
api.interceptors.request.use(
async (config) => {
const token = await SecureStore.getItemAsync(KEY_TOKEN);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log(`[API REQUEST] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor: Global error handling (e.g., expired token)
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response) {
const isLoginRequest = originalRequest?.url?.includes('/user/login');
if (!(error.response.status === 401 && isLoginRequest)) {
console.error('[API ERROR]', error.response.status, error.response.data);
}
// If we receive 401 (Unauthorized), we might want to force logout
if (error.response.status === 401) {
// TODO: Here you can add logic to redirect to login screen if needed
await SecureStore.deleteItemAsync(KEY_TOKEN);
}
} else {
console.error('[API NETWORK ERROR]', error.message);
}
return Promise.reject(error);
}
);
export default api;
+124
View File
@@ -0,0 +1,124 @@
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react';
import { SplashScreen, useRouter, useSegments } from 'expo-router';
import { UserData } from '@/types/types';
import * as SecureStore from 'expo-secure-store';
import api, { KEY_TOKEN } from './api';
type AuthState = {
isAuthenticated: boolean;
isReady: boolean;
user: UserData | null;
logIn: (token: string, userData: UserData) => void;
logOut: () => void;
};
SplashScreen.preventAutoHideAsync();
export const AuthContext = createContext<AuthState>({
isAuthenticated: false,
isReady: false,
user: null,
logIn: () => { },
logOut: () => { },
});
export const useAuth = () => useContext(AuthContext);
export function AuthProvider({ children }: PropsWithChildren) {
const [isReady, setIsReady] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<UserData | null>(null);
const router = useRouter();
const segments = useSegments();
const logIn = async (token: string, userData: UserData) => {
try {
await SecureStore.setItemAsync(KEY_TOKEN, token);
setIsAuthenticated(true);
setUser(userData);
router.replace('/');
} catch (error) {
console.error('Errore durante il login:', error);
}
};
const logOut = async () => {
try {
await SecureStore.deleteItemAsync(KEY_TOKEN);
setIsAuthenticated(false);
setUser(null);
router.replace('/login');
} catch (error) {
console.error('Errore durante il logout:', error);
}
};
useEffect(() => {
const initApp = async () => {
try {
// Get saved Token from SecureStore
const savedToken = await SecureStore.getItemAsync(KEY_TOKEN);
if (savedToken) {
console.log("Token trovato:", savedToken);
// Call backend to verify token and fetch user data
// Note: api.ts already adds the Authorization header thanks to the interceptor (if configured to read from SecureStore)
// If your api.ts reads from AsyncStorage, make sure they are aligned, otherwise pass it manually here:
const response = await api.get("/user", {
headers: { Authorization: `Bearer ${savedToken}` }
});
const result = response.data;
console.log("Sessione valida, dati utente caricati:", result);
const loadedUser: UserData = {
firstName: result.nome,
lastName: result.cognome,
email: result.email,
isAdmin: result.isAdmin
};
setUser(loadedUser);
setIsAuthenticated(true);
} else {
console.log("Nessun token salvato.");
}
} catch (error: any) {
console.error('Errore inizializzazione (Token scaduto o Server down):', error.message);
// If the token is not valid, clear everything
await SecureStore.deleteItemAsync(KEY_TOKEN);
setIsAuthenticated(false);
setUser(null);
} finally {
setIsReady(true);
await SplashScreen.hideAsync();
}
};
initApp();
}, []);
// Route protection (optional, but recommended here or in the Layout)
useEffect(() => {
if (!isReady) return;
const inAuthGroup = segments[0] === '(protected)';
if (!isAuthenticated && inAuthGroup) {
router.replace('/login');
} else if (isAuthenticated && !inAuthGroup) {
router.replace('/');
}
}, [isReady, isAuthenticated, segments]);
return (
<AuthContext.Provider value={{ isReady, isAuthenticated, user, logIn, logOut }}>
{children}
</AuthContext.Provider>
);
}
+87
View File
@@ -0,0 +1,87 @@
import React, { createContext, useState, useEffect, ReactNode } from 'react';
import { Linking, Platform } from 'react-native';
import Constants from 'expo-constants';
import axios from 'axios';
import LoadingScreen from '@/components/LoadingScreen';
import UpdateScreen from '@/components/UpdateScreen';
import { isUpdateAvailable } from '@/utils/version';
import { setApiBaseUrl } from './api';
interface ConfigContextProps {
children: ReactNode
};
// Context (useful if you want to trigger manual checks from inside the app in the future)
export const ConfigContext = createContext({});
const GW_API = process.env.EXPO_PUBLIC_GW_API_URL;
const GW_UUID = process.env.EXPO_PUBLIC_GW_UUID;
const GW_TOKEN = process.env.EXPO_PUBLIC_GW_API_TOKEN;
export const ConfigProvider = ({ children }: ConfigContextProps) => {
const [isChecking, setIsChecking] = useState(true);
const [needsUpdate, setNeedsUpdate] = useState(false);
const [updateUrl, setUpdateUrl] = useState('');
useEffect(() => {
const checkAppVersion = async () => {
try {
const apiUrl = `${GW_API}${GW_UUID}`;
const response = await axios.get(apiUrl, {
headers: { "x-access-tokens": GW_TOKEN }
});
// Update API URL: prioritize environment variable (override) over gateway response
setApiBaseUrl(process.env.EXPO_PUBLIC_API_URL || response.data.url);
const currentVersion = Constants.expoConfig?.version;
console.log("Versione attuale dell'app:", currentVersion);
const latestVersion = response.data.version;
console.log("Versione più recente disponibile:", latestVersion);
// Check if an update is needed
if (isUpdateAvailable(currentVersion, latestVersion)) {
setNeedsUpdate(true);
setUpdateUrl(Platform.OS === 'ios' ? response.data.app_url_ios : response.data.app_url_android);
}
} catch (error) {
console.error("Errore durante il controllo della versione:", error);
setNeedsUpdate(false);
} finally {
setIsChecking(false);
}
};
checkAppVersion();
}, []);
const handleUpdate = async () => {
if (updateUrl) {
Linking.openURL(updateUrl);
}
};
// Loading state
if (isChecking) {
return (
<LoadingScreen />
);
}
// Update state: blocks children rendering
if (needsUpdate) {
return (
<UpdateScreen onUpdate={handleUpdate} />
);
}
// Version is up to date
return (
<ConfigContext.Provider value={{ isChecking, needsUpdate }}>
{children}
</ConfigContext.Provider>
);
};
+203
View File
@@ -0,0 +1,203 @@
export const CHART_ENDPOINTS = {
costiRicavi: '/dashboard/costi-ricavi',
costiRicaviCum: '/dashboard/costi-ricavi',
fatturatoCliente: '/dashboard/fatturato-cliente',
aperteChiuseCliente: '/dashboard/aperte-chiuse-cliente',
aperteChiuseFornitore: '/dashboard/aperte-chiuse-fornitore',
fatturatoSoa: '/dashboard/fatturato-soa',
apertoCliente: '/dashboard/partite-cliente',
chiusoCliente: '/dashboard/partite-cliente',
marginalitaMediaSoa: '/dashboard/marginalita-media-soa',
marginalitaCategoriaSoa: '/dashboard/aggregati-soa',
pesoFatturatoSoa: '/dashboard/aggregati-soa'
};
export const CHART_DATA_KEY: Record<string, string> = {
costiRicaviCum: 'costiRicavi',
apertoCliente: 'partiteCliente',
chiusoCliente: 'partiteCliente',
marginalitaCategoriaSoa: 'aggregatiSoa',
pesoFatturatoSoa: 'aggregatiSoa'
};
export const cumulate = (arr: number[]): number[] => {
let sum = 0;
return (arr || []).map(v => (sum += (Number(v) || 0)));
};
export const formatEuro = (val: string | number): string => {
const num = parseFloat(val as string);
return (isNaN(num) ? 0 : num).toLocaleString('it-IT', {
style: 'currency',
currency: 'EUR'
});
};
const TOOLTIP_BASE = {
renderMode: 'richText',
confine: true,
textStyle: { fontSize: 10 }
};
const wrapText = (text: string, maxChars = 30): string => {
const words = String(text).split(' ');
const lines = [];
let line = '';
words.forEach(word => {
if (line && (line.length + 1 + word.length) > maxChars) {
lines.push(line);
line = word;
} else {
line = line ? `${line} ${word}` : word;
}
});
if (line) { lines.push(line); }
return lines.join('\n');
};
export const buildPieOption = (data: any) => ({
legend: {
type: 'plain',
top: 190,
left: 'center',
itemGap: 8,
itemWidth: 14,
itemHeight: 10,
textStyle: { fontSize: 12 }
},
series: [
{
name: 'Fatturato',
type: 'pie',
radius: [45, 80],
center: ['50%', 95],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 5,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 11,
fontWeight: 'bold',
formatter: (params: any) => {
const val = (typeof params.value === 'number') ? params.value : 0;
return `${params.name}\n${val.toLocaleString('it-IT', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}`;
}
}
},
labelLine: {
show: false
},
data: data
}
]
});
export const buildSoaBarOption = (rows: any[], { color, valueLabel, extraLines }: any) => {
const labels = rows.map(r => `${r.code} - ${r.name}`);
return {
grid: {
left: 8,
right: 60,
top: 10,
bottom: 10,
containLabel: true
},
xAxis: {
type: 'value',
axisLabel: { formatter: '{value}%', fontSize: 10 }
},
yAxis: {
type: 'category',
data: rows.map(r => r.code),
inverse: true,
axisLabel: { interval: 0, fontSize: 10 }
},
tooltip: {
...TOOLTIP_BASE,
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const p = params[0];
const row = rows[p.dataIndex];
const extra = extraLines ? extraLines(row) : [];
return [
wrapText(labels[p.dataIndex]),
`${valueLabel}: ${Number(p.value).toFixed(2)}%`,
...extra
].join('\n');
}
},
series: [
{
name: valueLabel,
type: 'bar',
data: rows.map(r => r.value),
label: {
show: true,
position: 'right',
formatter: (p: any) => `${Number(p.value).toFixed(2)}%`,
fontSize: 10
},
itemStyle: {
color: (p: any) => (p.value >= 0 ? color : '#ee6666'),
borderRadius: [0, 4, 4, 0]
}
}
]
};
};
export const commesseLine = (r: any) => [`Commesse: ${r.n_commesse}`];
export const buildAperteChiuseOption = (d: any) => ({
tooltip: {
...TOOLTIP_BASE,
trigger: 'axis',
valueFormatter: formatEuro
},
legend: {
bottom: 0,
itemGap: 10,
itemWidth: 14,
itemHeight: 10,
textStyle: { fontSize: 12 },
data: [
`Aperto(${d.current_year})`,
`Chiuso(${d.current_year})`,
`Aperto(${d.prev_year})`,
`Chiuso(${d.prev_year})`
],
selected: {
[`Aperto(${d.prev_year})`]: false,
[`Chiuso(${d.prev_year})`]: false
},
},
grid: {
left: '3%',
right: '4%',
top: '10%',
bottom: 75,
containLabel: true
},
xAxis: {
type: 'category',
data: d.mesi
},
yAxis: {
type: 'value'
},
series: [
{ name: `Aperto(${d.current_year})`, type: 'bar', stack: 'one', data: d.aperto },
{ name: `Chiuso(${d.current_year})`, type: 'bar', stack: 'one', data: d.chiuso },
{ name: `Aperto(${d.prev_year})`, type: 'bar', stack: 'one', itemStyle: { color: '#b3b3b3' }, data: d.aperto_prev },
{ name: `Chiuso(${d.prev_year})`, type: 'bar', stack: 'one', itemStyle: { color: '#d9d9d9' }, data: d.chiuso_prev }
]
});
+91
View File
@@ -0,0 +1,91 @@
import { DateType } from "react-native-ui-datepicker";
/**
* Transforms "YYYY-MM-DD" to "DD/MM/YYYY"
* @param dateStr string in ISO date format "YYYY-MM-DD"
* @returns formatted string "DD/MM/YYYY"
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '';
const [year, month, day] = dateStr.split('-');
return `${day}/${month}/${year}`;
};
/**
* Transforms time from "HH:MM:SS" to "HH:MM"
* @param timeStr string in time format "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS"
* @returns formatted string "HH:MM"
*/
export const formatTime = (timeStr: string | null | undefined): string => {
if (!timeStr) return '';
// Handle both "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS" formats
const timePart = timeStr.includes(' ') ? timeStr.split(' ')[1] : timeStr;
const [hours, minutes] = timePart.split(':');
return `${hours}:${minutes}`;
};
/**
* Formats a date for use with a date picker, normalizing it to midnight
* @param d Date in DateType format
* @returns string in "YYYY-MM-DD" format or null if input is null/undefined
*/
export const formatPickerDate = (d: DateType | null | undefined) => {
if (!d) return null;
const date = new Date(d as string | number | Date);
const normalized = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const yyyy = normalized.getFullYear();
const mm = String(normalized.getMonth() + 1).padStart(2, "0");
const dd = String(normalized.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
/**
* Transforms a timestamp into a string "DD/MM/YYYY HH:mm:ss"
* @param timestamp string or Date object
* @returns formatted string or empty string if input is invalid
*/
export const formatTimestamp = (timestamp: string | Date | null | undefined): string => {
if (!timestamp) return '';
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
if (isNaN(date.getTime())) return '';
const dd = String(date.getDate()).padStart(2, '0');
const mm = String(date.getMonth() + 1).padStart(2, '0'); // months from 0 to 11
const yyyy = date.getFullYear();
const hh = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${dd}/${mm}/${yyyy} ${hh}:${min}:${ss}`;
};
/**
* Converts an ISO timestamp to a Date object
* @param dateStr string in ISO date format
* @returns corresponding Date object
*/
export const parseTimestamp = (dateStr: string | undefined | null): Date => {
if (!dateStr) return new Date();
const date = new Date(dateStr);
if (isNaN(date.getTime())) return new Date();
return date;
};
export const parseSecondsToTime = (totalSeconds: number | null | undefined): string => {
if (totalSeconds == null || isNaN(totalSeconds)) return '';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const hh = String(hours);
const mm = String(minutes).padStart(2, '0');
const ss = String(seconds).padStart(2, '0');
return `${hh}h`;
}
+111
View File
@@ -0,0 +1,111 @@
import api from '@/utils/api';
import { Directory, File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { Platform } from 'react-native';
interface UploadOptions {
endpoint: string;
fileKey?: string;
extraData?: Record<string, string>;
}
/**
* Handles upload of a document through the server using FormData
* @param file File to upload (must have at least the 'uri' property)
* @param options Configuration for the upload
*/
export const uploadDocument = async (
file: any,
options: UploadOptions
): Promise<any> => {
if (!file || !file.uri) {
throw new Error("File non valido per l'upload.");
}
try {
const formData = new FormData();
const fileKey = options.fileKey || 'file';
formData.append(fileKey, {
uri: Platform.OS === 'android' ? file.uri : file.uri.replace('file://', ''),
name: file.name,
type: file.mimeType || 'application/octet-stream'
} as any);
if (options.extraData) {
Object.keys(options.extraData).forEach(key => {
formData.append(key, options.extraData![key]);
});
}
const response = await api.post(options.endpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
console.log("Risposta server upload:", response.data);
if (response.data?.status === 'error' || response.data?.success === false) {
throw new Error(response.data.message || "Errore sconosciuto dal server");
}
return response.data;
} catch (error: any) {
console.error("Errore durante l'upload del documento:", error);
if (error.response) {
const serverMessage = error.response.data?.message || error.message;
throw new Error(`Errore Server (${error.response.status}): ${serverMessage}`);
} else if (error.request) {
throw new Error("Il server non risponde. Controlla la connessione.");
} else {
throw error;
}
}
};
/**
* Download and share a document (expo-sharing)
* @param attachmentId ID or relative URL of the document
* @param fileName Name to save the file as
* @param fileUrl Full URL of the file to download
*/
export const downloadAndShareDocument = async (
mimetype: string,
fileName: string,
fileUrl: string
): Promise<void> => {
try {
// TODO: Download based on expo-sharing - some mime types may not be supported
if (!fileUrl || !fileName) {
throw new Error("Parametri mancanti per il download del documento.");
}
const destination = new Directory(Paths.cache, 'documents');
destination.exists ? destination.delete() : null;
destination.create({ overwrite: true });
const tmpFile = await File.downloadFileAsync(fileUrl, destination);
console.log("File temporaneo scaricato in:", tmpFile.uri);
const outFile = new File(destination, fileName);
await tmpFile.move(outFile);
console.log("File spostato in:", outFile.uri);
console.log("File type:", mimetype);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(outFile.uri, {
mimeType: mimetype,
dialogTitle: `Scarica ${fileName}`,
UTI: 'public.item'
});
} else {
throw new Error("Condivisione non supportata su questo dispositivo.");
}
} catch (error) {
console.error("Download Error:", error);
throw error;
}
};
+42
View File
@@ -0,0 +1,42 @@
import React, { useState, useEffect, ReactNode } from 'react';
import NetInfo, { useNetInfo } from '@react-native-community/netinfo';
import OfflineScreen from '@/components/OfflineScreen';
interface NetworkProviderProps {
children: ReactNode;
}
export const NetworkProvider = ({ children }: NetworkProviderProps) => {
const netInfo = useNetInfo();
const [isOffline, setIsOffline] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
useEffect(() => {
if (netInfo.isConnected === false) {
setIsOffline(true);
} else {
setIsOffline(false);
}
}, [netInfo.isConnected]);
// Manual Retry Handler
const handleManualRetry = async () => {
setIsRetrying(true);
const state = await NetInfo.fetch();
setTimeout(() => {
setIsOffline(state.isConnected === false);
setIsRetrying(false);
}, 1000);
};
if (isOffline) {
return (
<OfflineScreen
onRetry={handleManualRetry}
isRetrying={isRetrying}
/>
);
}
return <>{children}</>;
};
+27
View File
@@ -0,0 +1,27 @@
/**
* Compare two version strings.
* Returns true if 'latest' is greater than 'current' (an update is available).
*/
export const isUpdateAvailable = (currentVersion: string | undefined, latestVersion: string) => {
if (!currentVersion || !latestVersion) return false;
// Split strings into an array of numbers: "1.2.10" -> [1, 2, 10]
const currentParts = currentVersion.split('.').map(Number);
const latestParts = latestVersion.split('.').map(Number);
const maxLength = Math.max(currentParts.length, latestParts.length);
for (let i = 0; i < maxLength; i++) {
// If a part is missing, we consider it as 0 (e.g. "1.0" -> [1, 0, 0])
const current = currentParts[i] || 0;
const latest = latestParts[i] || 0;
if (current < latest) {
return true; // It needs an update
}
if (current > latest) {
return false;
}
}
return false;
};