Sincronismy on system

This commit is contained in:
Rayan Konecny 2026-05-03 02:47:00 -03:00
parent 2712fd6fa9
commit 0dabfcc484
10 changed files with 1676 additions and 297 deletions

View file

@ -0,0 +1,36 @@
-- ============================================================
-- TopTran — Script de criação das tabelas no PostgreSQL
-- ============================================================
CREATE TABLE IF NOT EXISTS empresas (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
custo_por_km NUMERIC(10,2) NOT NULL,
observacoes TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS usuarios (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
token TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS corridas (
id TEXT PRIMARY KEY,
usuario_id TEXT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
empresa TEXT NOT NULL,
km NUMERIC(10,2) NOT NULL,
custo_por_km NUMERIC(10,2) NOT NULL,
total NUMERIC(10,2) NOT NULL,
data TEXT NOT NULL,
sincronizado SMALLINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Índices para performance
CREATE INDEX IF NOT EXISTS idx_corridas_usuario_id ON corridas(usuario_id);
CREATE INDEX IF NOT EXISTS idx_corridas_sincronizado ON corridas(sincronizado);
CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email);

View file

@ -0,0 +1,190 @@
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { router } from "expo-router";
import React from "react";
import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
type CadastroCard = {
icon: string;
label: string;
description: string;
onPress: () => void;
highlight?: boolean;
};
export default function CadastrosPage() {
const cards: CadastroCard[] = [
{
icon: "🏢",
label: "Empresas",
description: "Gerencie empresas e custos por km",
onPress: () => router.push("/empresas"),
highlight: true,
},
{
icon: "👤",
label: "Motoristas",
description: "Cadastro de motoristas",
onPress: () => {},
},
{
icon: "🚛",
label: "Veículos",
description: "Cadastro de veículos",
onPress: () => {},
},
{
icon: "📍",
label: "Rotas",
description: "Rotas e destinos frequentes",
onPress: () => {},
},
];
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.titleSection}>
<Text style={styles.title}>Cadastros</Text>
<Text style={styles.subtitle}>
Gerencie os dados base do sistema
</Text>
</View>
<Text style={styles.sectionTitle}>Categorias</Text>
<View style={styles.grid}>
{cards.map((card, index) => (
<TouchableOpacity
key={index}
style={[styles.card, card.highlight && styles.cardHighlight]}
onPress={card.onPress}
activeOpacity={0.75}
>
<Text style={styles.cardIcon}>{card.icon}</Text>
<Text
style={[
styles.cardLabel,
card.highlight && styles.cardLabelHighlight,
]}
>
{card.label}
</Text>
<Text style={styles.cardDescription}>{card.description}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
scrollContent: {
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
titleSection: {
marginBottom: SPACING.xl,
marginTop: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: COLORS.textTertiary,
},
sectionTitle: {
fontSize: 11,
fontWeight: "700",
color: COLORS.textTertiary,
textTransform: "uppercase",
letterSpacing: 1.2,
marginBottom: SPACING.md,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: SPACING.sm,
},
card: {
width: "48%",
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.lg,
borderWidth: 1,
borderColor: COLORS.border,
minHeight: 120,
justifyContent: "space-between",
},
cardHighlight: {
backgroundColor: COLORS.surfaceLight,
borderColor: COLORS.text,
},
cardIcon: {
fontSize: 30,
marginBottom: SPACING.sm,
},
cardLabel: {
fontSize: 15,
fontWeight: "700",
color: COLORS.text,
marginBottom: 3,
},
cardLabelHighlight: {
color: COLORS.text,
},
cardDescription: {
fontSize: 11,
color: COLORS.textTertiary,
lineHeight: 15,
},
});

View file

@ -1,121 +1,9 @@
import { useAuth } from "@/contexts/AuthContext"; import { router } from "expo-router";
import { obterCorridas } from "@/services/db"; import { useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Historico from "./historico";
import Lancamento, { type Corrida } from "./lancamento";
export default function Corrida() { export default function Corrida() {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<"lancamento" | "historico">(
"lancamento",
);
const [historico, setHistorico] = useState<Corrida[]>([]);
const loadHistorico = useCallback(async () => {
if (!user?.id) return;
try {
const corridasDB = await obterCorridas(user.id);
setHistorico(
corridasDB.map((c) => ({
id: c.id,
data: c.data,
empresa: c.empresa,
km: c.km,
custoPorKm: c.custo_por_km,
total: c.total,
usuario: user.email,
})),
);
} catch (error) {
console.error("Erro ao carregar histórico:", error);
}
}, [user?.id, user?.email]);
useEffect(() => { useEffect(() => {
loadHistorico(); router.replace("/lancamento");
}, [loadHistorico]); }, []);
return null;
const handleLancarCorrida = async (_corrida: Corrida) => {
await loadHistorico();
setActiveTab("historico");
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.tabsContainer}>
<TouchableOpacity
style={[styles.tab, activeTab === "lancamento" && styles.tabActive]}
onPress={() => setActiveTab("lancamento")}
>
<Text
style={[
styles.tabLabel,
activeTab === "lancamento" && styles.tabLabelActive,
]}
>
📍 Lançar
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === "historico" && styles.tabActive]}
onPress={() => setActiveTab("historico")}
>
<Text
style={[
styles.tabLabel,
activeTab === "historico" && styles.tabLabelActive,
]}
>
📋 Histórico
</Text>
</TouchableOpacity>
</View>
<View style={styles.content}>
{activeTab === "lancamento" ? (
<Lancamento onLancar={handleLancarCorrida} />
) : (
<Historico historico={historico} />
)}
</View>
</SafeAreaView>
);
} }
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
tabsContainer: {
flexDirection: "row",
backgroundColor: "#ffffff",
borderBottomWidth: 2,
borderBottomColor: "#000000",
},
tab: {
flex: 1,
paddingVertical: 16,
justifyContent: "center",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: "transparent",
},
tabActive: {
borderBottomColor: "#000000",
},
tabLabel: {
fontSize: 14,
fontWeight: "600",
color: "#666666",
},
tabLabelActive: {
color: "#000000",
},
content: {
flex: 1,
},
});

View file

@ -0,0 +1,520 @@
import { Input } from "@/components/Input";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import {
EmpresaDB,
atualizarEmpresa,
deletarEmpresa,
obterEmpresas,
salvarEmpresa,
} from "@/services/db";
import { router, useFocusEffect } from "expo-router";
import React, { useCallback, useState } from "react";
import {
Alert,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
type FormState = {
nome: string;
custoPorKm: string;
observacoes: string;
};
const FORM_VAZIO: FormState = { nome: "", custoPorKm: "", observacoes: "" };
export default function EmpresasPage() {
const [empresas, setEmpresas] = useState<EmpresaDB[]>([]);
const [modalVisible, setModalVisible] = useState(false);
const [editando, setEditando] = useState<EmpresaDB | null>(null);
const [form, setForm] = useState<FormState>(FORM_VAZIO);
const [loading, setLoading] = useState(false);
const loadEmpresas = useCallback(async () => {
try {
setEmpresas(await obterEmpresas());
} catch {
Alert.alert("Erro", "Não foi possível carregar as empresas.");
}
}, []);
useFocusEffect(useCallback(() => { loadEmpresas(); }, [loadEmpresas]));
const abrirAdicionar = () => {
setEditando(null);
setForm(FORM_VAZIO);
setModalVisible(true);
};
const abrirEditar = (empresa: EmpresaDB) => {
setEditando(empresa);
setForm({
nome: empresa.nome,
custoPorKm: empresa.custo_por_km.toString(),
observacoes: empresa.observacoes ?? "",
});
setModalVisible(true);
};
const fecharModal = () => {
setModalVisible(false);
setEditando(null);
setForm(FORM_VAZIO);
};
const handleSalvar = async () => {
const nomeTrimmed = form.nome.trim();
const custo = parseFloat(form.custoPorKm.replace(",", "."));
if (!nomeTrimmed) {
Alert.alert("Validação", "Informe o nome da empresa.");
return;
}
if (isNaN(custo) || custo <= 0) {
Alert.alert("Validação", "Informe um custo por km válido.");
return;
}
try {
setLoading(true);
if (editando) {
await atualizarEmpresa({
id: editando.id,
nome: nomeTrimmed,
custo_por_km: custo,
observacoes: form.observacoes.trim(),
});
} else {
await salvarEmpresa({
id: Date.now().toString(),
nome: nomeTrimmed,
custo_por_km: custo,
observacoes: form.observacoes.trim(),
});
}
await loadEmpresas();
fecharModal();
} catch {
Alert.alert("Erro", "Não foi possível salvar a empresa.");
} finally {
setLoading(false);
}
};
const handleDeletar = (empresa: EmpresaDB) => {
Alert.alert(
"Excluir empresa",
`Deseja excluir "${empresa.nome}"? Esta ação não pode ser desfeita.`,
[
{ text: "Cancelar", style: "cancel" },
{
text: "Excluir",
style: "destructive",
onPress: async () => {
try {
await deletarEmpresa(empresa.id);
await loadEmpresas();
} catch {
Alert.alert("Erro", "Não foi possível excluir a empresa.");
}
},
},
],
);
};
const renderItem = ({ item, index }: { item: EmpresaDB; index: number }) => (
<View style={styles.item}>
<View style={styles.itemLeft}>
<View style={styles.itemIndex}>
<Text style={styles.itemIndexText}>{index + 1}</Text>
</View>
<View style={styles.itemInfo}>
<Text style={styles.itemNome}>{item.nome}</Text>
<Text style={styles.itemCusto}>R$ {item.custo_por_km.toFixed(2)} / km</Text>
{item.observacoes ? (
<Text style={styles.itemObs} numberOfLines={1}>
{item.observacoes}
</Text>
) : null}
</View>
</View>
<View style={styles.itemActions}>
<TouchableOpacity
style={[styles.actionBtn, styles.editBtn]}
onPress={() => abrirEditar(item)}
activeOpacity={0.7}
>
<Text style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.deleteBtn]}
onPress={() => handleDeletar(item)}
activeOpacity={0.7}
>
<Text style={styles.actionBtnText}>🗑</Text>
</TouchableOpacity>
</View>
</View>
);
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.addButton}
onPress={abrirAdicionar}
activeOpacity={0.8}
>
<Text style={styles.addButtonText}>+ Adicionar</Text>
</TouchableOpacity>
</View>
{/* Title */}
<View style={styles.titleSection}>
<Text style={styles.title}>Empresas</Text>
<Text style={styles.subtitle}>
{empresas.length} empresa{empresas.length !== 1 ? "s" : ""} cadastrada{empresas.length !== 1 ? "s" : ""}
</Text>
</View>
{/* Lista */}
{empresas.length === 0 ? (
<ScrollView contentContainerStyle={styles.emptyContainer}>
<Text style={styles.emptyEmoji}>🏢</Text>
<Text style={styles.emptyTitle}>Nenhuma empresa cadastrada</Text>
<Text style={styles.emptyText}>
Toque em "+ Adicionar" para cadastrar a primeira empresa.
</Text>
</ScrollView>
) : (
<FlatList
data={empresas}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
)}
{/* Modal Add/Edit */}
<Modal
visible={modalVisible}
animationType="slide"
transparent
onRequestClose={fecharModal}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.select({ ios: "padding", android: "height" })}
>
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editando ? "Editar Empresa" : "Nova Empresa"}
</Text>
<TouchableOpacity onPress={fecharModal} activeOpacity={0.7}>
<Text style={styles.modalClose}></Text>
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Nome da Empresa</Text>
<Input
placeholder="Ex: MULTI B"
value={form.nome}
onChangeText={(v) => setForm((f) => ({ ...f, nome: v }))}
autoCapitalize="characters"
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Custo por km (R$)</Text>
<Input
placeholder="Ex: 2.70"
keyboardType="decimal-pad"
value={form.custoPorKm}
onChangeText={(v) => setForm((f) => ({ ...f, custoPorKm: v }))}
/>
</View>
<View style={styles.formGroup}>
<Text style={styles.formLabel}>Observações (opcional)</Text>
<Input
placeholder="Ex: Pagamento quinzenal"
value={form.observacoes}
onChangeText={(v) => setForm((f) => ({ ...f, observacoes: v }))}
/>
</View>
<TouchableOpacity
style={[styles.saveButton, loading && styles.saveButtonDisabled]}
onPress={handleSalvar}
disabled={loading}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>
{loading ? "Salvando..." : editando ? "Salvar alterações" : "Cadastrar empresa"}
</Text>
</TouchableOpacity>
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
// Header
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
addButton: {
backgroundColor: COLORS.surface,
borderWidth: 1,
borderColor: COLORS.borderLight,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
borderRadius: BORDER_RADIUS.md,
},
addButtonText: {
fontSize: 13,
fontWeight: "700",
color: COLORS.text,
},
// Title
titleSection: {
paddingHorizontal: SPACING.lg,
paddingTop: SPACING.lg,
paddingBottom: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 13,
color: COLORS.textTertiary,
},
// List
listContent: {
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
item: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
marginBottom: SPACING.sm,
padding: SPACING.md,
borderWidth: 1,
borderColor: COLORS.border,
},
itemLeft: {
flexDirection: "row",
alignItems: "center",
flex: 1,
gap: SPACING.md,
},
itemIndex: {
width: 32,
height: 32,
borderRadius: BORDER_RADIUS.round,
backgroundColor: COLORS.surfaceLight,
justifyContent: "center",
alignItems: "center",
},
itemIndexText: {
fontSize: 12,
fontWeight: "700",
color: COLORS.textTertiary,
},
itemInfo: {
flex: 1,
},
itemNome: {
fontSize: 15,
fontWeight: "700",
color: COLORS.text,
marginBottom: 2,
},
itemCusto: {
fontSize: 13,
color: COLORS.success,
fontWeight: "600",
},
itemObs: {
fontSize: 11,
color: COLORS.textTertiary,
marginTop: 2,
},
itemActions: {
flexDirection: "row",
gap: SPACING.xs,
},
actionBtn: {
width: 36,
height: 36,
borderRadius: BORDER_RADIUS.md,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
editBtn: {
backgroundColor: COLORS.surfaceLight,
borderColor: COLORS.border,
},
deleteBtn: {
backgroundColor: COLORS.surfaceLight,
borderColor: COLORS.border,
},
actionBtnText: {
fontSize: 15,
},
// Empty
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: SPACING.lg,
},
emptyEmoji: {
fontSize: 48,
marginBottom: SPACING.lg,
},
emptyTitle: {
fontSize: 18,
fontWeight: "700",
color: COLORS.text,
marginBottom: SPACING.sm,
textAlign: "center",
},
emptyText: {
fontSize: 14,
color: COLORS.textTertiary,
textAlign: "center",
lineHeight: 20,
},
// Modal
modalOverlay: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.7)",
},
modalSheet: {
backgroundColor: COLORS.surface,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
borderTopWidth: 1,
borderColor: COLORS.border,
maxHeight: "85%",
},
modalHandle: {
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: COLORS.border,
alignSelf: "center",
marginBottom: SPACING.lg,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: SPACING.xl,
},
modalTitle: {
fontSize: 20,
fontWeight: "800",
color: COLORS.text,
},
modalClose: {
fontSize: 18,
color: COLORS.textTertiary,
padding: SPACING.xs,
},
// Form
formGroup: {
marginBottom: SPACING.lg,
},
formLabel: {
fontSize: 11,
fontWeight: "700",
color: COLORS.textTertiary,
textTransform: "uppercase",
letterSpacing: 0.8,
marginBottom: SPACING.sm,
},
saveButton: {
backgroundColor: COLORS.text,
borderRadius: BORDER_RADIUS.lg,
paddingVertical: SPACING.lg,
alignItems: "center",
marginTop: SPACING.sm,
},
saveButtonDisabled: {
backgroundColor: COLORS.borderLight,
},
saveButtonText: {
fontSize: 15,
fontWeight: "700",
color: COLORS.background,
},
});

View file

@ -1,21 +1,27 @@
import React from "react"; import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { FlatList, ScrollView, StyleSheet, Text, View } from "react-native"; import { useAuth } from "@/contexts/AuthContext";
import { obterCorridas } from "@/services/db";
import { router, useFocusEffect } from "expo-router";
import React, { useCallback, useState } from "react";
import {
FlatList,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
export type Corrida = { type Corrida = {
id: string; id: string;
data: string; data: string;
empresa: string; empresa: string;
km: number; km: number;
custoPorKm: number; custoPorKm: number;
total: number; total: number;
usuario: string;
}; };
interface HistoricoProps {
historico: Corrida[];
}
const HistoricoItem = ({ item, index }: { item: Corrida; index: number }) => ( const HistoricoItem = ({ item, index }: { item: Corrida; index: number }) => (
<View style={styles.item}> <View style={styles.item}>
<View style={styles.itemHeader}> <View style={styles.itemHeader}>
@ -32,41 +38,92 @@ const HistoricoItem = ({ item, index }: { item: Corrida; index: number }) => (
<Text style={styles.detailLabel}>Distância</Text> <Text style={styles.detailLabel}>Distância</Text>
<Text style={styles.detailValue}>{item.km} km</Text> <Text style={styles.detailValue}>{item.km} km</Text>
</View> </View>
<View style={styles.detailBox}> <View style={styles.detailBox}>
<Text style={styles.detailLabel}>Valor/km</Text> <Text style={styles.detailLabel}>Valor/km</Text>
<Text style={styles.detailValue}>R$ {item.custoPorKm.toFixed(2)}</Text> <Text style={styles.detailValue}>R$ {item.custoPorKm.toFixed(2)}</Text>
</View> </View>
<View style={[styles.detailBox, styles.detailBoxHighlight]}>
<View style={styles.detailBox}>
<Text style={styles.detailLabel}>Total</Text> <Text style={styles.detailLabel}>Total</Text>
<Text style={styles.detailValue}>R$ {item.total.toFixed(2)}</Text> <Text style={[styles.detailValue, styles.detailValueHighlight]}>
R$ {item.total.toFixed(2)}
</Text>
</View> </View>
</View> </View>
</View> </View>
); );
export default function Historico({ historico }: HistoricoProps) { export default function HistoricoPage() {
const { user } = useAuth();
const [corridas, setCorridas] = useState<Corrida[]>([]);
const loadCorridas = useCallback(async () => {
if (!user?.id) return;
try {
const data = await obterCorridas(user.id);
setCorridas(
data.map((c) => ({
id: c.id,
data: c.data,
empresa: c.empresa,
km: c.km,
custoPorKm: c.custo_por_km,
total: c.total,
})),
);
} catch (error) {
console.error("Erro ao carregar histórico:", error);
}
}, [user?.id]);
useFocusEffect(useCallback(() => { loadCorridas(); }, [loadCorridas]));
const totalGanhos = corridas.reduce((acc, c) => acc + c.total, 0);
const totalKm = corridas.reduce((acc, c) => acc + c.km, 0);
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Histórico de Corridas</Text> <TouchableOpacity onPress={() => router.back()} style={styles.backButton} activeOpacity={0.7}>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<View style={styles.titleSection}>
<Text style={styles.title}>Histórico</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
{historico.length} corrida{historico.length !== 1 ? "s" : ""} {corridas.length} corrida{corridas.length !== 1 ? "s" : ""} registrada{corridas.length !== 1 ? "s" : ""}
</Text> </Text>
</View> </View>
{historico.length === 0 ? ( {corridas.length > 0 && (
<View style={styles.summaryRow}>
<View style={styles.summaryCard}>
<Text style={styles.summaryValue}>{totalKm.toFixed(0)} km</Text>
<Text style={styles.summaryLabel}>Total Rodado</Text>
</View>
<View style={[styles.summaryCard, styles.summaryCardAccent]}>
<Text style={[styles.summaryValue, styles.summaryValueAccent]}>
R$ {totalGanhos.toFixed(2)}
</Text>
<Text style={styles.summaryLabel}>Total Ganho</Text>
</View>
</View>
)}
{corridas.length === 0 ? (
<ScrollView contentContainerStyle={styles.emptyContainer}> <ScrollView contentContainerStyle={styles.emptyContainer}>
<View style={styles.emptyBox}> <View style={styles.emptyBox}>
<Text style={styles.emptyEmoji}>🚗</Text> <Text style={styles.emptyEmoji}>🚗</Text>
<Text style={styles.emptyTitle}>Nenhuma corrida registrada</Text> <Text style={styles.emptyTitle}>Nenhuma corrida ainda</Text>
<Text style={styles.emptyText}>Suas corridas aparecerão aqui</Text> <Text style={styles.emptyText}>
Registre sua primeira corrida para -la aqui.
</Text>
</View> </View>
</ScrollView> </ScrollView>
) : ( ) : (
<FlatList <FlatList
data={historico} data={corridas}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<HistoricoItem item={item} index={index} /> <HistoricoItem item={item} index={index} />
@ -82,110 +139,170 @@ export default function Historico({ historico }: HistoricoProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#ffffff", backgroundColor: COLORS.background,
}, },
header: { header: {
paddingHorizontal: 16, paddingHorizontal: SPACING.lg,
paddingTop: 16, paddingVertical: SPACING.md,
paddingBottom: 24, borderBottomWidth: 1,
backgroundColor: "#ffffff", borderBottomColor: COLORS.border,
borderBottomWidth: 2, },
borderBottomColor: "#000000", backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
titleSection: {
paddingHorizontal: SPACING.lg,
paddingTop: SPACING.lg,
paddingBottom: SPACING.md,
}, },
title: { title: {
fontSize: 28, fontSize: 28,
fontWeight: "800", fontWeight: "800",
color: "#000000", color: COLORS.text,
marginBottom: 4, marginBottom: 4,
}, },
subtitle: { subtitle: {
fontSize: 14, fontSize: 13,
color: "#333333", color: COLORS.textTertiary,
},
summaryRow: {
flexDirection: "row",
gap: SPACING.sm,
paddingHorizontal: SPACING.lg,
paddingBottom: SPACING.lg,
},
summaryCard: {
flex: 1,
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
borderWidth: 1,
borderColor: COLORS.border,
},
summaryCardAccent: {
borderColor: COLORS.success,
},
summaryValue: {
fontSize: 18,
fontWeight: "800",
color: COLORS.text,
marginBottom: 2,
},
summaryValueAccent: {
color: COLORS.success,
},
summaryLabel: {
fontSize: 11,
color: COLORS.textTertiary,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
}, },
listContent: { listContent: {
padding: 16, padding: SPACING.lg,
paddingBottom: 32, paddingBottom: SPACING.xxl,
}, },
item: { item: {
backgroundColor: "#ffffff", backgroundColor: COLORS.surface,
borderRadius: 12, borderRadius: BORDER_RADIUS.lg,
marginBottom: 12, marginBottom: SPACING.md,
overflow: "hidden", overflow: "hidden",
borderWidth: 1, borderWidth: 1,
borderColor: "#000000", borderColor: COLORS.border,
}, },
itemHeader: { itemHeader: {
flexDirection: "row", flexDirection: "row",
paddingHorizontal: 16, paddingHorizontal: SPACING.lg,
paddingTop: 12, paddingTop: SPACING.md,
paddingBottom: 8, paddingBottom: SPACING.sm,
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: "#e0e0e0", borderBottomColor: COLORS.border,
}, },
itemNumber: { itemNumber: {
fontSize: 12, fontSize: 12,
fontWeight: "700", fontWeight: "700",
color: "#000000", color: COLORS.textTertiary,
letterSpacing: 0.5,
}, },
itemDate: { itemDate: {
fontSize: 12, fontSize: 12,
color: "#666666", color: COLORS.textTertiary,
}, },
itemEmpresa: { itemEmpresa: {
paddingHorizontal: 16, paddingHorizontal: SPACING.lg,
paddingVertical: 10, paddingVertical: SPACING.sm,
backgroundColor: "#f5f5f5", backgroundColor: COLORS.surfaceLight,
}, },
empresaLabel: { empresaLabel: {
fontSize: 14, fontSize: 14,
fontWeight: "600", fontWeight: "700",
color: "#000000", color: COLORS.text,
}, },
itemDetails: { itemDetails: {
flexDirection: "row", flexDirection: "row",
paddingHorizontal: 16, paddingHorizontal: SPACING.lg,
paddingVertical: 12, paddingVertical: SPACING.md,
gap: 8,
}, },
detailBox: { detailBox: {
flex: 1, flex: 1,
paddingVertical: 8, },
detailBoxHighlight: {
alignItems: "flex-end",
}, },
detailLabel: { detailLabel: {
fontSize: 11, fontSize: 10,
color: "#666666", color: COLORS.textTertiary,
fontWeight: "500", fontWeight: "600",
marginBottom: 2, textTransform: "uppercase",
letterSpacing: 0.4,
marginBottom: 3,
}, },
detailValue: { detailValue: {
fontSize: 14, fontSize: 14,
fontWeight: "700", fontWeight: "700",
color: "#000000", color: COLORS.text,
},
detailValueHighlight: {
color: COLORS.success,
fontSize: 16,
}, },
emptyContainer: { emptyContainer: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
padding: 16, padding: SPACING.lg,
}, },
emptyBox: { emptyBox: {
alignItems: "center", alignItems: "center",
}, },
emptyEmoji: { emptyEmoji: {
fontSize: 48, fontSize: 48,
marginBottom: 16, marginBottom: SPACING.lg,
}, },
emptyTitle: { emptyTitle: {
fontSize: 18, fontSize: 18,
fontWeight: "600", fontWeight: "700",
color: "#000000", color: COLORS.text,
marginBottom: 8, marginBottom: SPACING.sm,
}, },
emptyText: { emptyText: {
fontSize: 14, fontSize: 14,
color: "#666666", color: COLORS.textTertiary,
textAlign: "center", textAlign: "center",
lineHeight: 20,
}, },
}); });

View file

@ -101,14 +101,14 @@ export default function Home() {
icon: "🚗", icon: "🚗",
label: "Registrar Corrida", label: "Registrar Corrida",
description: "Lançar nova corrida", description: "Lançar nova corrida",
onPress: () => router.push("/corrida"), onPress: () => router.push("/lancamento"),
highlight: true, highlight: true,
}, },
{ {
icon: "📋", icon: "📋",
label: "Histórico", label: "Histórico",
description: "Ver corridas anteriores", description: "Ver corridas anteriores",
onPress: () => router.push("/corrida"), onPress: () => router.push("/historico"),
}, },
{ {
icon: "📊", icon: "📊",
@ -116,12 +116,24 @@ export default function Home() {
description: "Análise de ganhos", description: "Análise de ganhos",
onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."), onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."),
}, },
{
icon: "👤",
label: "Cadastros",
description: "Cadastros de dados básicos",
onPress: () => router.push("/cadastros"),
},
{ {
icon: "⚙️", icon: "⚙️",
label: "Configurações", label: "Configurações",
description: "Preferências do app", description: "Preferências do app",
onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."), onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."),
}, },
{
icon: "🔄",
label: "Sincronizar",
description: "Sincronizar dados para portal",
onPress: () => router.push("/sincronizar"),
},
]; ];
return ( return (

View file

@ -61,20 +61,20 @@ export default function IndexPage() {
setAuthToken(data.accessToken); setAuthToken(data.accessToken);
// Salvar no SQLite const userName = data.name || emailTrimmed.split("@")[0];
const userId = Date.now().toString();
await salvarUsuario({ // Salvar no SQLite — retorna o ID existente se o usuário já tinha conta
id: userId, const userId = await salvarUsuario({
id: Date.now().toString(),
email: emailTrimmed, email: emailTrimmed,
name: data.name || emailTrimmed.split("@")[0], name: userName,
token: data.accessToken, token: data.accessToken,
}); });
// Fazer login no contexto
await login(emailTrimmed, passwordTrimmed, data.accessToken, { await login(emailTrimmed, passwordTrimmed, data.accessToken, {
id: userId, id: userId,
email: emailTrimmed, email: emailTrimmed,
name: data.name || emailTrimmed.split("@")[0], name: userName,
}); });
} catch (err: any) { } catch (err: any) {
const message = err.response?.data?.error ?? err.message ?? "Erro ao fazer login."; const message = err.response?.data?.error ?? err.message ?? "Erro ao fazer login.";

View file

@ -1,9 +1,11 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Input } from "@/components/Input"; import { Input } from "@/components/Input";
import { Select } from "@/components/Select"; import { Select } from "@/components/Select";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { salvarCorrida } from "@/services/db"; import { EmpresaDB, obterEmpresas, salvarCorrida } from "@/services/db";
import React, { useState } from "react"; import { router, useFocusEffect } from "expo-router";
import React, { useCallback, useState } from "react";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
@ -11,19 +13,11 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
const EMPRESAS = [
{ label: "Selecione uma empresa", value: "" },
{ label: "MULTI B", value: "MULTI_B" },
{ label: "TOP TRANS", value: "TOP_TRANS" },
{ label: "LOGISTICA XYZ", value: "LOGISTICA_XYZ" },
];
const CUSTO_POR_KM = 2.7;
export type Corrida = { export type Corrida = {
id: string; id: string;
data: string; data: string;
@ -34,63 +28,83 @@ export type Corrida = {
usuario: string; usuario: string;
}; };
interface LancamentoProps { export default function LancamentoPage() {
onLancar: (corrida: Corrida) => void;
}
export default function Lancamento({ onLancar }: LancamentoProps) {
const { user } = useAuth(); const { user } = useAuth();
const [empresa, setEmpresa] = useState(""); const [empresasDB, setEmpresasDB] = useState<EmpresaDB[]>([]);
const [empresaId, setEmpresaId] = useState("");
const [custoPorKm, setCustoPorKm] = useState(0);
const [distancia, setDistancia] = useState(""); const [distancia, setDistancia] = useState("");
const [loading, setLoading] = useState(false);
const distanciaNum = parseFloat(distancia) || 0; const distanciaNum = parseFloat(distancia) || 0;
const totalCorrida = distanciaNum * CUSTO_POR_KM; const totalCorrida = distanciaNum * custoPorKm;
useFocusEffect(
useCallback(() => {
obterEmpresas()
.then(setEmpresasDB)
.catch(() => Alert.alert("Erro", "Não foi possível carregar as empresas."));
}, []),
);
const empresasItems = [
{ label: "Selecione uma empresa", value: "" },
...empresasDB.map((e) => ({ label: e.nome, value: e.id })),
];
const handleEmpresaChange = (id: string) => {
setEmpresaId(id);
const found = empresasDB.find((e) => e.id === id);
setCustoPorKm(found?.custo_por_km ?? 0);
};
const handleLancarCorrida = async () => { const handleLancarCorrida = async () => {
if (!empresa) { if (!empresaId) {
Alert.alert("Validação", "Por favor, selecione uma empresa."); Alert.alert("Validação", "Por favor, selecione uma empresa.");
return; return;
} }
if (!distancia || distanciaNum <= 0) { if (!distancia || distanciaNum <= 0) {
Alert.alert("Validação", "Por favor, insira uma distância válida."); Alert.alert("Validação", "Por favor, insira uma distância válida.");
return; return;
} }
const empresaSelecionada = EMPRESAS.find((e) => e.value === empresa); const empresaSelecionada = empresasDB.find((e) => e.id === empresaId);
const novaCorrida: Corrida = {
id: Date.now().toString(),
data: new Date().toLocaleString("pt-BR"),
empresa: empresaSelecionada?.label || empresa,
km: distanciaNum,
custoPorKm: CUSTO_POR_KM,
total: totalCorrida,
usuario: user?.email ?? "",
};
try { try {
setLoading(true);
await salvarCorrida({ await salvarCorrida({
id: novaCorrida.id, id: Date.now().toString(),
usuario_id: user?.id ?? "", usuario_id: user?.id ?? "",
empresa: novaCorrida.empresa, empresa: empresaSelecionada?.nome ?? empresaId,
km: novaCorrida.km, km: distanciaNum,
custo_por_km: novaCorrida.custoPorKm, custo_por_km: custoPorKm,
total: novaCorrida.total, total: totalCorrida,
data: novaCorrida.data, data: new Date().toLocaleString("pt-BR"),
sincronizado: 0, sincronizado: 0,
}); });
setEmpresaId("");
setCustoPorKm(0);
setDistancia("");
Alert.alert("Sucesso", "Corrida registrada com sucesso!", [
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) { } catch (error) {
console.error("Erro ao salvar corrida:", error); console.error("Erro ao salvar corrida:", error);
Alert.alert("Erro", "Não foi possível registrar a corrida.");
} finally {
setLoading(false);
} }
onLancar(novaCorrida);
setEmpresa("");
setDistancia("");
Alert.alert("Sucesso", "Corrida registrada com sucesso!");
}; };
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} activeOpacity={0.7}>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<KeyboardAvoidingView <KeyboardAvoidingView
style={{ flex: 1 }} style={{ flex: 1 }}
behavior={Platform.select({ ios: "padding", android: "height" })} behavior={Platform.select({ ios: "padding", android: "height" })}
@ -99,18 +113,14 @@ export default function Lancamento({ onLancar }: LancamentoProps) {
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={styles.header}> <View style={styles.titleSection}>
<Text style={styles.title}>Nova Corrida</Text> <Text style={styles.title}>Nova Corrida</Text>
<Text style={styles.subtitle}>Preencha os dados da corrida</Text> <Text style={styles.subtitle}>Preencha os dados abaixo</Text>
</View> </View>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardLabel}>Empresa</Text> <Text style={styles.cardLabel}>Empresa</Text>
<Select <Select value={empresaId} onValueChange={handleEmpresaChange} items={empresasItems} />
value={empresa}
onValueChange={setEmpresa}
items={EMPRESAS}
/>
</View> </View>
<View style={styles.card}> <View style={styles.card}>
@ -124,24 +134,22 @@ export default function Lancamento({ onLancar }: LancamentoProps) {
</View> </View>
<View style={styles.card}> <View style={styles.card}>
<View style={styles.valorRow}> <View style={styles.infoRow}>
<Text style={styles.valorLabel}>Custo por km</Text> <Text style={styles.infoLabel}>Custo por km</Text>
<Text style={styles.valorAmount}> <Text style={styles.infoValue}>R$ {custoPorKm.toFixed(2)}</Text>
R$ {CUSTO_POR_KM.toFixed(2)}
</Text>
</View> </View>
</View> </View>
<View style={styles.card}> <View style={[styles.card, styles.totalCard]}>
<View style={styles.totalBox}>
<Text style={styles.totalLabel}>Valor Total</Text> <Text style={styles.totalLabel}>Valor Total</Text>
<Text style={styles.totalAmount}> <Text style={styles.totalAmount}>R$ {totalCorrida.toFixed(2)}</Text>
R$ {totalCorrida.toFixed(2)}
</Text>
</View>
</View> </View>
<Button label="Lançar Corrida" onPress={handleLancarCorrida} /> <Button
label={loading ? "Salvando..." : "Lançar Corrida"}
onPress={handleLancarCorrida}
disabled={loading}
/>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
@ -151,72 +159,95 @@ export default function Lancamento({ onLancar }: LancamentoProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: "#ffffff", backgroundColor: COLORS.background,
},
scrollContent: {
padding: 16,
paddingBottom: 32,
}, },
header: { header: {
marginBottom: 24, paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: {
fontSize: 18,
color: COLORS.text,
},
backLabel: {
fontSize: 15,
color: COLORS.textSecondary,
fontWeight: "500",
},
scrollContent: {
padding: SPACING.lg,
paddingBottom: SPACING.xxl,
},
titleSection: {
marginBottom: SPACING.xl,
marginTop: SPACING.md,
}, },
title: { title: {
fontSize: 28, fontSize: 28,
fontWeight: "800", fontWeight: "800",
color: "#000000", color: COLORS.text,
marginBottom: 4, marginBottom: 4,
}, },
subtitle: { subtitle: {
fontSize: 14, fontSize: 14,
color: "#333333", color: COLORS.textTertiary,
}, },
card: { card: {
backgroundColor: "#ffffff", backgroundColor: COLORS.surface,
borderRadius: 12, borderRadius: BORDER_RADIUS.lg,
padding: 16, padding: SPACING.lg,
marginBottom: 12, marginBottom: SPACING.md,
borderWidth: 1, borderWidth: 1,
borderColor: "#000000", borderColor: COLORS.border,
}, },
cardLabel: { cardLabel: {
fontSize: 12, fontSize: 11,
fontWeight: "600", fontWeight: "700",
color: "#000000", color: COLORS.textTertiary,
marginBottom: 8, marginBottom: SPACING.sm,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: 0.5, letterSpacing: 0.8,
}, },
valorRow: { infoRow: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}, },
valorLabel: { infoLabel: {
fontSize: 14, fontSize: 14,
color: "#000000", color: COLORS.textSecondary,
fontWeight: "500", fontWeight: "500",
}, },
valorAmount: { infoValue: {
fontSize: 16, fontSize: 16,
fontWeight: "700", fontWeight: "700",
color: "#000000", color: COLORS.text,
}, },
totalBox: { totalCard: {
backgroundColor: "#ffffff", borderColor: COLORS.borderLight,
borderLeftWidth: 4, borderLeftWidth: 3,
borderLeftColor: "#000000", borderLeftColor: COLORS.success,
padding: 12, marginBottom: SPACING.xl,
borderRadius: 8,
}, },
totalLabel: { totalLabel: {
fontSize: 12, fontSize: 12,
color: "#000000", color: COLORS.textTertiary,
marginBottom: 4, fontWeight: "600",
fontWeight: "500", textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: SPACING.xs,
}, },
totalAmount: { totalAmount: {
fontSize: 24, fontSize: 32,
fontWeight: "800", fontWeight: "800",
color: "#000000", color: COLORS.success,
}, },
}); });

View file

@ -0,0 +1,500 @@
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext";
import {
marcarCorridaComoSincronizada,
obterCorridasNaoSincronizadas,
obterEmpresas,
obterUsuario,
} from "@/services/db";
import { api } from "@/server/api";
import { router } from "expo-router";
import React, { useState } from "react";
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
type SyncStatus = "pending" | "syncing" | "success" | "error";
type SyncItem = {
key: string;
label: string;
description: string;
status: SyncStatus;
detail: string;
};
const INITIAL_ITEMS: SyncItem[] = [
{
key: "empresas",
label: "Empresas",
description: "Cadastro de empresas e custos por km",
status: "pending",
detail: "",
},
{
key: "usuarios",
label: "Usuários",
description: "Dados do usuário autenticado",
status: "pending",
detail: "",
},
{
key: "corridas",
label: "Corridas",
description: "Histórico de corridas não sincronizadas",
status: "pending",
detail: "",
},
];
export default function SincronizarPage() {
const { user } = useAuth();
const [items, setItems] = useState<SyncItem[]>(INITIAL_ITEMS);
const [syncing, setSyncing] = useState(false);
const [done, setDone] = useState(false);
const update = (key: string, patch: Partial<SyncItem>) =>
setItems((prev) =>
prev.map((i) => (i.key === key ? { ...i, ...patch } : i)),
);
const reset = () => {
setItems(INITIAL_ITEMS);
setDone(false);
};
const handleSync = async () => {
if (!user?.id) return;
setSyncing(true);
setDone(false);
setItems(INITIAL_ITEMS);
// ── Empresas ────────────────────────────────────────────
update("empresas", { status: "syncing" });
try {
const empresas = await obterEmpresas();
await api.post("/sync/empresas", { empresas });
update("empresas", {
status: "success",
detail: `${empresas.length} registro${empresas.length !== 1 ? "s" : ""} enviado${empresas.length !== 1 ? "s" : ""}`,
});
} catch (e: any) {
update("empresas", {
status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
});
}
// ── Usuários ─────────────────────────────────────────────
update("usuarios", { status: "syncing" });
try {
const usuario = await obterUsuario(user.id);
if (usuario) {
await api.post("/sync/usuarios", { usuarios: [usuario] });
update("usuarios", { status: "success", detail: "1 registro enviado" });
} else {
update("usuarios", { status: "error", detail: "Usuário não encontrado" });
}
} catch (e: any) {
update("usuarios", {
status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
});
}
// ── Corridas ─────────────────────────────────────────────
update("corridas", { status: "syncing" });
try {
const corridas = await obterCorridasNaoSincronizadas(user.id);
if (corridas.length === 0) {
update("corridas", { status: "success", detail: "Nenhuma corrida pendente" });
} else {
await api.post("/sync/corridas", { corridas });
for (const c of corridas) {
await marcarCorridaComoSincronizada(c.id);
}
update("corridas", {
status: "success",
detail: `${corridas.length} corrida${corridas.length !== 1 ? "s" : ""} enviada${corridas.length !== 1 ? "s" : ""}`,
});
}
} catch (e: any) {
update("corridas", {
status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
});
}
setSyncing(false);
setDone(true);
};
const allSuccess = items.every((i) => i.status === "success");
const hasError = items.some((i) => i.status === "error");
return (
<SafeAreaView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
<Text style={styles.backIcon}></Text>
<Text style={styles.backLabel}>Voltar</Text>
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.titleSection}>
<Text style={styles.title}>Sincronizar</Text>
<Text style={styles.subtitle}>
Enviar dados locais para o servidor
</Text>
</View>
{/* Status geral após conclusão */}
{done && (
<View
style={[
styles.resultBanner,
allSuccess ? styles.resultBannerSuccess : styles.resultBannerError,
]}
>
<Text style={styles.resultBannerText}>
{allSuccess
? "✓ Todos os dados sincronizados com sucesso"
: "⚠ Sincronização concluída com erros"}
</Text>
</View>
)}
{/* Timeline */}
<View style={styles.timeline}>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<View key={item.key} style={styles.timelineRow}>
{/* Track: linha + círculo */}
<View style={styles.track}>
<NodeCircle status={item.status} />
{!isLast && (
<View
style={[
styles.connector,
item.status === "success" && styles.connectorSuccess,
item.status === "error" && styles.connectorError,
]}
/>
)}
</View>
{/* Conteúdo */}
<View style={styles.itemContent}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{item.label}</Text>
{item.status === "success" && (
<Text style={styles.badgeSuccess}> OK</Text>
)}
{item.status === "error" && (
<Text style={styles.badgeError}> Erro</Text>
)}
</View>
<Text style={styles.itemDescription}>{item.description}</Text>
{item.detail !== "" && (
<Text
style={[
styles.itemDetail,
item.status === "success" && styles.itemDetailSuccess,
item.status === "error" && styles.itemDetailError,
]}
>
{item.detail}
</Text>
)}
</View>
</View>
);
})}
</View>
{/* Botões */}
<View style={styles.actions}>
{done && hasError && (
<TouchableOpacity
style={styles.retryButton}
onPress={reset}
activeOpacity={0.8}
>
<Text style={styles.retryButtonText}>Redefinir</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.syncButton,
syncing && styles.syncButtonDisabled,
]}
onPress={handleSync}
disabled={syncing}
activeOpacity={0.85}
>
{syncing ? (
<ActivityIndicator color={COLORS.background} size="small" />
) : (
<Text style={styles.syncButtonText}>
{done ? "Sincronizar novamente" : "Iniciar sincronização"}
</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}
function NodeCircle({ status }: { status: SyncStatus }) {
if (status === "syncing") {
return (
<View style={[styles.circle, styles.circleSyncing]}>
<ActivityIndicator color={COLORS.warning} size="small" />
</View>
);
}
return (
<View
style={[
styles.circle,
status === "success" && styles.circleSuccess,
status === "error" && styles.circleError,
status === "pending" && styles.circlePending,
]}
>
{status === "success" && <Text style={styles.circleIcon}></Text>}
{status === "error" && <Text style={styles.circleIcon}></Text>}
</View>
);
}
const CIRCLE_SIZE = 36;
const CONNECTOR_WIDTH = 2;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.background,
},
// Header
header: {
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
backButton: {
flexDirection: "row",
alignItems: "center",
gap: SPACING.xs,
alignSelf: "flex-start",
},
backIcon: { fontSize: 18, color: COLORS.text },
backLabel: { fontSize: 15, color: COLORS.textSecondary, fontWeight: "500" },
scrollContent: {
padding: SPACING.lg,
paddingBottom: 48,
},
titleSection: {
marginBottom: SPACING.xl,
marginTop: SPACING.md,
},
title: {
fontSize: 28,
fontWeight: "800",
color: COLORS.text,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: COLORS.textTertiary,
},
// Banner de resultado
resultBanner: {
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
marginBottom: SPACING.xl,
borderWidth: 1,
},
resultBannerSuccess: {
backgroundColor: "#0d2b1f",
borderColor: COLORS.success,
},
resultBannerError: {
backgroundColor: "#2b0d0d",
borderColor: COLORS.error,
},
resultBannerText: {
fontSize: 14,
fontWeight: "600",
color: COLORS.text,
},
// Timeline
timeline: {
marginBottom: SPACING.xl,
},
timelineRow: {
flexDirection: "row",
alignItems: "flex-start",
},
// Track (círculo + conector)
track: {
alignItems: "center",
width: CIRCLE_SIZE + SPACING.lg,
},
circle: {
width: CIRCLE_SIZE,
height: CIRCLE_SIZE,
borderRadius: CIRCLE_SIZE / 2,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
zIndex: 1,
},
circlePending: {
backgroundColor: COLORS.surface,
borderColor: COLORS.border,
},
circleSyncing: {
backgroundColor: COLORS.surface,
borderColor: COLORS.warning,
},
circleSuccess: {
backgroundColor: "#0d2b1f",
borderColor: COLORS.success,
},
circleError: {
backgroundColor: "#2b0d0d",
borderColor: COLORS.error,
},
circleIcon: {
fontSize: 16,
fontWeight: "800",
color: COLORS.text,
},
connector: {
width: CONNECTOR_WIDTH,
flex: 1,
minHeight: 56,
backgroundColor: COLORS.border,
marginTop: 2,
marginBottom: 2,
},
connectorSuccess: {
backgroundColor: COLORS.success,
},
connectorError: {
backgroundColor: COLORS.error,
},
// Item content
itemContent: {
flex: 1,
paddingTop: SPACING.xs,
paddingBottom: SPACING.xl,
paddingLeft: SPACING.md,
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 3,
},
itemLabel: {
fontSize: 16,
fontWeight: "700",
color: COLORS.text,
},
itemDescription: {
fontSize: 12,
color: COLORS.textTertiary,
marginBottom: 4,
},
itemDetail: {
fontSize: 12,
fontWeight: "600",
color: COLORS.textSecondary,
},
itemDetailSuccess: {
color: COLORS.success,
},
itemDetailError: {
color: COLORS.error,
},
// Badges
badgeSuccess: {
fontSize: 12,
fontWeight: "700",
color: COLORS.success,
backgroundColor: "#0d2b1f",
paddingHorizontal: SPACING.sm,
paddingVertical: 2,
borderRadius: BORDER_RADIUS.sm,
},
badgeError: {
fontSize: 12,
fontWeight: "700",
color: COLORS.error,
backgroundColor: "#2b0d0d",
paddingHorizontal: SPACING.sm,
paddingVertical: 2,
borderRadius: BORDER_RADIUS.sm,
},
// Botões
actions: {
gap: SPACING.sm,
},
syncButton: {
backgroundColor: COLORS.text,
borderRadius: BORDER_RADIUS.lg,
paddingVertical: SPACING.lg,
alignItems: "center",
},
syncButtonDisabled: {
backgroundColor: COLORS.borderLight,
},
syncButtonText: {
fontSize: 15,
fontWeight: "700",
color: COLORS.background,
},
retryButton: {
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
paddingVertical: SPACING.md,
alignItems: "center",
borderWidth: 1,
borderColor: COLORS.border,
},
retryButtonText: {
fontSize: 14,
fontWeight: "600",
color: COLORS.textSecondary,
},
});

View file

@ -24,16 +24,17 @@ export type CorridaDB = {
export const initDB = async () => { export const initDB = async () => {
try { try {
await db.execAsync(` await db.execAsync(
CREATE TABLE IF NOT EXISTS usuarios ( `CREATE TABLE IF NOT EXISTS usuarios (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
token TEXT NOT NULL, token TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP created_at TEXT DEFAULT CURRENT_TIMESTAMP
);`,
); );
await db.execAsync(
CREATE TABLE IF NOT EXISTS corridas ( `CREATE TABLE IF NOT EXISTS corridas (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
usuario_id TEXT NOT NULL, usuario_id TEXT NOT NULL,
empresa TEXT NOT NULL, empresa TEXT NOT NULL,
@ -44,13 +45,23 @@ export const initDB = async () => {
sincronizado INTEGER DEFAULT 0, sincronizado INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (usuario_id) REFERENCES usuarios(id) FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
);`,
); );
await db.execAsync(
CREATE TABLE IF NOT EXISTS settings ( `CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
);`,
);
await db.execAsync(
`CREATE TABLE IF NOT EXISTS empresas (
id TEXT PRIMARY KEY,
nome TEXT NOT NULL,
custo_por_km REAL NOT NULL,
observacoes TEXT DEFAULT '',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);`,
); );
`);
} catch (error) { } catch (error) {
console.error("Failed to initialize database:", error); console.error("Failed to initialize database:", error);
throw error; throw error;
@ -93,14 +104,29 @@ export const deleteSetting = async (key: string): Promise<void> => {
}; };
// USUÁRIOS // USUÁRIOS
export const salvarUsuario = async (usuario: Omit<UsuarioDB, "created_at">) => { export const salvarUsuario = async (
usuario: Omit<UsuarioDB, "created_at">,
): Promise<string> => {
try { try {
const result = await db.runAsync( const existing = await db.getFirstAsync<{ id: string }>(
`INSERT OR REPLACE INTO usuarios (id, email, name, token) `SELECT id FROM usuarios WHERE email = ?`,
VALUES (?, ?, ?, ?)`, [usuario.email],
);
if (existing) {
// Preserva o ID original — apenas atualiza nome e token
await db.runAsync(
`UPDATE usuarios SET name = ?, token = ? WHERE email = ?`,
[usuario.name, usuario.token, usuario.email],
);
return existing.id;
}
await db.runAsync(
`INSERT INTO usuarios (id, email, name, token) VALUES (?, ?, ?, ?)`,
[usuario.id, usuario.email, usuario.name, usuario.token], [usuario.id, usuario.email, usuario.name, usuario.token],
); );
return result; return usuario.id;
} catch (error) { } catch (error) {
console.error("Error saving user:", error); console.error("Error saving user:", error);
throw error; throw error;
@ -205,3 +231,62 @@ export const limparBancoDados = async () => {
throw error; throw error;
} }
}; };
// EMPRESAS
export type EmpresaDB = {
id: string;
nome: string;
custo_por_km: number;
observacoes: string;
created_at: string;
};
export const obterEmpresas = async (): Promise<EmpresaDB[]> => {
try {
return (
(await db.getAllAsync<EmpresaDB>(
`SELECT * FROM empresas ORDER BY nome ASC`,
)) ?? []
);
} catch (error) {
console.error("Error fetching empresas:", error);
throw error;
}
};
export const salvarEmpresa = async (
empresa: Omit<EmpresaDB, "created_at">,
): Promise<void> => {
try {
await db.runAsync(
`INSERT INTO empresas (id, nome, custo_por_km, observacoes) VALUES (?, ?, ?, ?)`,
[empresa.id, empresa.nome, empresa.custo_por_km, empresa.observacoes],
);
} catch (error) {
console.error("Error saving empresa:", error);
throw error;
}
};
export const atualizarEmpresa = async (
empresa: Omit<EmpresaDB, "created_at">,
): Promise<void> => {
try {
await db.runAsync(
`UPDATE empresas SET nome = ?, custo_por_km = ?, observacoes = ? WHERE id = ?`,
[empresa.nome, empresa.custo_por_km, empresa.observacoes, empresa.id],
);
} catch (error) {
console.error("Error updating empresa:", error);
throw error;
}
};
export const deletarEmpresa = async (id: string): Promise<void> => {
try {
await db.runAsync(`DELETE FROM empresas WHERE id = ?`, [id]);
} catch (error) {
console.error("Error deleting empresa:", error);
throw error;
}
};