First commit of the new app

This commit is contained in:
2026-05-26 09:18:37 +01:00
parent 295d1bda21
commit b427fb0f85
110 changed files with 6483 additions and 833 deletions

View File

@@ -0,0 +1,401 @@
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { DashedDivider } from '@/assets/components/reserva/DashedDivider';
import { DocumentoRow } from '@/assets/components/reserva/DocumentoRow';
import {
formatCurrency,
formatDateLong,
formatDateShort,
getImageUrl,
} from '@/assets/components/reserva/formatters';
import { HotelCard } from '@/assets/components/reserva/HotelCard';
import { PassageiroCard } from '@/assets/components/reserva/PassageiroCard';
import { Section } from '@/assets/components/reserva/Section';
import { StatusBadge } from '@/assets/components/reserva/StatusBadge';
import { ValorReservaCard } from '@/assets/components/reserva/ValorReservaCard';
import { VooCard } from '@/assets/components/reserva/VooCard';
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
import { useAuth } from '@/assets/contexts/useAuth';
import { cacheReservaFull, getCachedReservaFull } from '@/assets/services/offlineStorage';
import {
Documento,
Escala,
JsonData,
Pagamento,
ReservaData,
ReservaResponse,
VooDirection,
VooSegment,
} from '@/assets/types';
import styles from '@/styles/screens/reserva/detail.styles';
import { FontAwesome } from '@expo/vector-icons';
import { Stack, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
Image,
RefreshControl,
ScrollView,
Text,
View,
} from 'react-native';
export default function ReservaDetalheScreen() {
const { referencia } = useLocalSearchParams<{ referencia: string }>();
const { token } = useAuth();
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reservaData, setReservaData] = useState<ReservaData | null>(null);
const [pagamentos, setPagamentos] = useState<Pagamento[]>([]);
const [documentos, setDocumentos] = useState<Documento[]>([]);
const [expandedPassageiros, setExpandedPassageiros] = useState<Set<number>>(new Set());
const [cachedAt, setCachedAt] = useState<string | null>(null);
const [isFromCache, setIsFromCache] = useState(false);
const togglePassageiro = (idx: number) => {
setExpandedPassageiros((prev) => {
const next = new Set(prev);
if (next.has(idx)) {
next.delete(idx);
} else {
next.add(idx);
}
return next;
});
};
const fetchReserva = async () => {
try {
setError(null);
if (!token || !referencia) {
throw new Error('Dados de autenticacao em falta');
}
const formData = new FormData();
formData.append('token', token);
formData.append('referenciaViagem', referencia);
const response = await fetch(buildApiUrl(API_ENDPOINTS.GET_RESERVA), {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
const data: ReservaResponse = await response.json();
if (!response.ok || data.status !== 200 || !data.reserva) {
throw new Error(data.message || 'Nao foi possivel carregar a reserva');
}
const pagamentosData = data.pagamentos || [];
const documentosData = data.documentos || [];
const now = new Date().toISOString();
setReservaData(data.reserva);
setPagamentos(pagamentosData);
setDocumentos(documentosData);
setCachedAt(now);
setIsFromCache(false);
await cacheReservaFull(referencia, {
reservaData: data.reserva,
pagamentos: pagamentosData,
documentos: documentosData,
cachedAt: now,
});
} catch {
const cached = await getCachedReservaFull(referencia);
if (cached) {
setReservaData(cached.reservaData);
setPagamentos(cached.pagamentos);
setDocumentos(cached.documentos);
setCachedAt(cached.cachedAt);
setIsFromCache(true);
setError(null);
} else {
setError('Sem ligação à internet e sem dados guardados para esta reserva.');
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchReserva();
}, [referencia, token]);
const onRefresh = async () => {
setRefreshing(true);
await fetchReserva();
setRefreshing(false);
};
const parsedJsonData = useMemo((): JsonData | null => {
if (!reservaData?.jsonData) return null;
try {
return typeof reservaData.jsonData === 'string'
? (JSON.parse(reservaData.jsonData) as JsonData)
: reservaData.jsonData;
} catch {
return null;
}
}, [reservaData?.jsonData]);
const hotelInfo = parsedJsonData?.hotel || [];
const extrasData = parsedJsonData?.extras ?? parsedJsonData?.extra ?? [];
const voosData = parsedJsonData?.voo;
const getVoosFromLeg = (leg?: VooDirection): (Escala | VooSegment)[] => {
if (!leg) return [];
if (Array.isArray(leg)) return leg;
return leg.infoEscalas ?? [];
};
const voosIda = getVoosFromLeg(voosData?.departure);
const voosVolta = getVoosFromLeg(voosData?.arrival);
const buildQuartosPassageiros = (data: ReservaData) => {
const partes: string[] = [];
if (data.quartos) partes.push(`${data.quartos} Quartos`);
if (data.adultos) partes.push(`${data.adultos} Adultos`);
if (data.criancas && data.criancas !== '0') partes.push(`${data.criancas} Crianças`);
return partes.length ? partes.join(' · ') : `${data.pessoas} Passageiros`;
};
const statusCode = String(reservaData?.status ?? '');
return (
<View style={styles.container}>
<Stack.Screen
options={{
headerShown: true,
headerTitleAlign: 'center',
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerStyle: { backgroundColor: '#F4F7FE' },
headerTitle: () => (
<Image
source={require('@/assets/icons/logotipo-azul.png')}
style={styles.logo}
resizeMode="contain"
/>
),
}}
/>
{isLoading ? (
<View style={styles.centered}>
<LoadingSpinner size="large" />
</View>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{!!error && <Text style={styles.errorText}>{error}</Text>}
{reservaData && (
<>
{isFromCache && (
<View style={styles.offlineBanner}>
<FontAwesome name="wifi" size={13} color="#fff" />
<Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text>
</View>
)}
<Text style={styles.destination}>{reservaData.destino}</Text>
<Section>
{!!reservaData.imagemCidade && (
<Image
source={{ uri: getImageUrl(reservaData.imagemCidade) }}
style={styles.summaryImage}
/>
)}
<View style={styles.rowBetween}>
<View style={styles.idBlock}>
<Text style={styles.idTop}>ID #{reservaData.referenciaViagem}</Text>
<Text style={styles.fieldLabel}>Ref. da Reserva</Text>
</View>
<StatusBadge statusCode={statusCode} />
</View>
<DashedDivider width={0.5} borderStyle="solid" />
<View>
<Text style={styles.idTop}>{reservaData.localizador || '---'}</Text>
<Text style={styles.fieldLabel}>Ref. do Operador</Text>
</View>
</Section>
<Section title="Resumo da Reserva">
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Destino</Text>
<Text style={styles.rowValue}>{reservaData.destino}</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Data Viagem</Text>
<Text style={styles.rowValue}>
{formatDateLong(reservaData.startDate)} -{' '}
{formatDateLong(reservaData.endDate)}
</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Nr. de Noites</Text>
<Text style={styles.rowValue}>{reservaData.noites} noites</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Quartos e Passageiros</Text>
<Text style={styles.rowValue}>{buildQuartosPassageiros(reservaData)}</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
<View style={styles.rowLineNoBorder}>
<Text style={styles.rowLabel}>Operador</Text>
<Text style={styles.rowValue}>{reservaData.operador || '---'}</Text>
</View>
<DashedDivider width={1} borderStyle="dashed" />
</Section>
<Section title="Alojamento">
{hotelInfo.length > 0 ? (
hotelInfo.map((hotel, idx) => (
<HotelCard
key={`hotel-${hotel.id_hotel}-${idx}`}
hotel={hotel}
isFirst={idx === 0}
/>
))
) : (
<Text style={styles.empty}>Sem dados de alojamento.</Text>
)}
</Section>
<Section title="Voos">
{voosIda.length > 0 || voosVolta.length > 0 ? (
<View style={styles.vooList}>
{voosIda.length > 0 && (
<VooCard segmentos={voosIda} tipo="ida" />
)}
{voosVolta.length > 0 && (
<VooCard segmentos={voosVolta} tipo="volta" />
)}
</View>
) : (
<Text style={styles.empty}>Sem dados de voos.</Text>
)}
</Section>
<Section title="Passageiros">
<DashedDivider width={1} borderStyle="dashed" />
{reservaData.passageiros && reservaData.passageiros.length > 0 ? (
reservaData.passageiros.map((passageiro, idx) => (
<PassageiroCard
key={`passageiro-${idx}`}
passageiro={passageiro}
index={idx}
isFirst={idx === 0}
isOpen={expandedPassageiros.has(idx)}
onToggle={() => togglePassageiro(idx)}
/>
))
) : (
<Text style={styles.empty}>Sem passageiros associados.</Text>
)}
</Section>
<Section title="Extras">
{extrasData.length > 0 ? (
<View style={styles.extrasList}>
{extrasData.map((extra) => (
<View style={styles.extrasItem} key={extra.name}>
<Text style={styles.extrasItemText}>{extra.name}</Text>
</View>
))}
</View>
) : (
<Text style={styles.empty}>Sem extras associados.</Text>
)}
</Section>
<Section title="Histórico de Pagamentos">
{pagamentos.length > 0 ? (
<>
<View style={styles.tableHeader}>
<View style={styles.tableCellLeft}>
<Text style={styles.tableHeaderText}>Data</Text>
</View>
<View style={styles.tableCellMid}>
<Text style={styles.tableHeaderText}>Valor</Text>
</View>
<View style={styles.tableCellRight}>
<Text style={styles.tableHeaderText}>Meio Pag.</Text>
</View>
</View>
<DashedDivider width={1} borderStyle="dashed" />
{pagamentos.map((pagamento, idx) => (
<React.Fragment key={`pagamento-${idx}`}>
<View
style={[
styles.tableRow,
idx === pagamentos.length - 1 && styles.tableRowLast,
]}>
<View style={styles.tableCellLeft}>
<Text style={styles.tableValueText}>
{formatDateShort(pagamento.data_pagamento || pagamento.data_hora)}
</Text>
</View>
<View style={styles.tableCellMid}>
<Text style={styles.tableValueText}>
{formatCurrency(pagamento.valor)}
</Text>
</View>
<View style={styles.tableCellRight}>
<Text style={styles.tableValueText}>{pagamento.metodoPagamento}</Text>
</View>
</View>
<DashedDivider width={1} borderStyle="dashed" />
</React.Fragment>
))}
</>
) : (
<Text style={styles.empty}>Sem pagamentos registados.</Text>
)}
</Section>
<ValorReservaCard reserva={reservaData} />
<Section title="Documentos">
<DashedDivider width={1} borderStyle="dashed" />
{documentos.length > 0 ? (
documentos.map((documento, idx) => (
<React.Fragment key={`documento-${idx}`}>
<DocumentoRow
documento={documento}
isLast={idx === documentos.length - 1}
/>
<DashedDivider width={1} borderStyle="dashed" />
</React.Fragment>
))
) : (
<Text style={styles.empty}>Sem documentos associados.</Text>
)}
</Section>
{!!cachedAt && (
<Text style={styles.syncLabel}>
Atualizado em: {new Date(cachedAt).toLocaleDateString('pt-PT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
)}
</>
)}
</ScrollView>
)}
</View>
);
}