Refactors data models and sync, adds company/rides fetch APIs

Unifies naming conventions for users, companies, and rides across
backend and mobile app, standardizing field names for consistency
and easier data interchange.

Introduces endpoints to fetch all companies and rides for sync.
Enhances sync logic to support both upload and download of companies,
including first-time population from server.

Updates local database schema and access logic to match backend
structure, improving maintainability and reliability of sync.
This commit is contained in:
Rayan Konecny 2026-05-03 14:15:37 -03:00
parent 3601b7fc0a
commit fea50d5064
20 changed files with 971 additions and 178 deletions

View file

@ -38,3 +38,27 @@ export async function syncRides(req: Request, res: Response): Promise<void> {
res.status(500).json({ error: err.message ?? 'Error syncing rides' }); res.status(500).json({ error: err.message ?? 'Error syncing rides' });
} }
} }
export async function getCompanies(req: Request, res: Response): Promise<void> {
try {
const companies = await syncService.getAllCompanies();
res.json({
success: true,
data: companies,
});
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Error fetching companies' });
}
}
export async function getRides(req: Request, res: Response): Promise<void> {
try {
const rides = await syncService.getAllRides();
res.json({
success: true,
data: rides,
});
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Error fetching rides' });
}
}

View file

@ -40,6 +40,10 @@ export async function listRidesByUserId(user_id: string) {
return prisma.rides.findMany({ where: { user_id } }); return prisma.rides.findMany({ where: { user_id } });
} }
export async function listAllRides() {
return prisma.rides.findMany();
}
export async function listRidesNotSynced(user_id: string) { export async function listRidesNotSynced(user_id: string) {
return prisma.rides.findMany({ return prisma.rides.findMany({
where: { where: {

View file

@ -3,6 +3,11 @@ import * as syncController from '../controllers/sync.controller.js';
const router = Router(); const router = Router();
// GET routes
router.get('/companies', syncController.getCompanies);
router.get('/rides', syncController.getRides);
// POST routes
router.post('/companies', syncController.syncCompanies); router.post('/companies', syncController.syncCompanies);
router.post('/rides', syncController.syncRides); router.post('/rides', syncController.syncRides);

View file

@ -64,3 +64,11 @@ export async function syncRides(
} }
return synced; return synced;
} }
export async function getAllCompanies() {
return companiesRepo.listCompanies();
}
export async function getAllRides() {
return ridesRepo.listAllRides();
}

View file

@ -22,7 +22,8 @@
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-secure-store" "expo-secure-store",
"expo-asset"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View file

@ -7,14 +7,34 @@
-- [EXISTING TABLE — DO NOT RECREATE] -- [EXISTING TABLE — DO NOT RECREATE]
-- CREATE TABLE IF NOT EXISTS users ( -- CREATE TABLE IF NOT EXISTS users (
-- id TEXT PRIMARY KEY, -- id TEXT PRIMARY KEY,
-- name TEXT NOT NULL, -- name TEXT NOT NULL,
-- email TEXT NOT NULL UNIQUE, -- email TEXT NOT NULL UNIQUE,
-- password TEXT NOT NULL, -- password TEXT NOT NULL,
-- "createdAt" TIMESTAMPTZ DEFAULT NOW(), -- "createdAt" TIMESTAMPTZ DEFAULT NOW(),
-- "updatedAt" TIMESTAMPTZ DEFAULT NOW() -- "updatedAt" TIMESTAMPTZ
-- ); -- );
-- ============================================================
-- Enums
-- ============================================================
DO $$ BEGIN
CREATE TYPE "TokenType" AS ENUM ('REFRESH');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ============================================================
-- Tokens
-- ============================================================
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
type "TokenType" NOT NULL,
"userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"expiresAt" TIMESTAMPTZ NOT NULL,
"createdAt" TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================ -- ============================================================
-- Companies -- Companies
-- ============================================================ -- ============================================================
@ -49,3 +69,4 @@ CREATE TABLE IF NOT EXISTS rides (
CREATE INDEX IF NOT EXISTS idx_rides_user_id ON rides(user_id); CREATE INDEX IF NOT EXISTS idx_rides_user_id ON rides(user_id);
CREATE INDEX IF NOT EXISTS idx_rides_synced ON rides(synced); CREATE INDEX IF NOT EXISTS idx_rides_synced ON rides(synced);
CREATE INDEX IF NOT EXISTS idx_companies_name ON companies(name); CREATE INDEX IF NOT EXISTS idx_companies_name ON companies(name);
CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON tokens("userId");

View file

@ -12,11 +12,15 @@
"@react-native-picker/picker": "^2.7.5", "@react-native-picker/picker": "^2.7.5",
"axios": "^1.15.2", "axios": "^1.15.2",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-asset": "~12.0.13",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-print": "~15.0.8",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-sqlite": "^55.0.15", "expo-sqlite": "^55.0.15",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
@ -4357,6 +4361,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-print": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-print/-/expo-print-15.0.8.tgz",
"integrity": "sha512-4O0Qzm0On5AmJIl9d+BT+ieTipFp658nHI4aX7vKEFPfj3dfQxG6rDJJpca+rrc9c4Ha8ZFYGvxJG5+4lFq2Pw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-router": { "node_modules/expo-router": {
"version": "6.0.23", "version": "6.0.23",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
@ -4628,6 +4642,15 @@
"node": ">=20.16.0" "node": ">=20.16.0"
} }
}, },
"node_modules/expo-sharing": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-splash-screen": { "node_modules/expo-splash-screen": {
"version": "31.0.13", "version": "31.0.13",
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",

View file

@ -13,11 +13,15 @@
"@react-native-picker/picker": "^2.7.5", "@react-native-picker/picker": "^2.7.5",
"axios": "^1.15.2", "axios": "^1.15.2",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-asset": "~12.0.13",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-print": "~15.0.8",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-sqlite": "^55.0.15", "expo-sqlite": "^55.0.15",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",

View file

@ -1,7 +1,7 @@
import { Input } from "@/components/Input"; import { Input } from "@/components/Input";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme"; import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { import {
EmpresaDB, CompanyDB,
atualizarEmpresa, atualizarEmpresa,
deletarEmpresa, deletarEmpresa,
obterEmpresas, obterEmpresas,
@ -32,9 +32,9 @@ type FormState = {
const FORM_VAZIO: FormState = { nome: "", custoPorKm: "", observacoes: "" }; const FORM_VAZIO: FormState = { nome: "", custoPorKm: "", observacoes: "" };
export default function EmpresasPage() { export default function EmpresasPage() {
const [empresas, setEmpresas] = useState<EmpresaDB[]>([]); const [empresas, setEmpresas] = useState<CompanyDB[]>([]);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [editando, setEditando] = useState<EmpresaDB | null>(null); const [editando, setEditando] = useState<CompanyDB | null>(null);
const [form, setForm] = useState<FormState>(FORM_VAZIO); const [form, setForm] = useState<FormState>(FORM_VAZIO);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -54,12 +54,12 @@ export default function EmpresasPage() {
setModalVisible(true); setModalVisible(true);
}; };
const abrirEditar = (empresa: EmpresaDB) => { const abrirEditar = (empresa: CompanyDB) => {
setEditando(empresa); setEditando(empresa);
setForm({ setForm({
nome: empresa.nome, nome: empresa.name,
custoPorKm: empresa.custo_por_km.toString(), custoPorKm: empresa.cost_per_km.toString(),
observacoes: empresa.observacoes ?? "", observacoes: empresa.notes ?? "",
}); });
setModalVisible(true); setModalVisible(true);
}; };
@ -88,16 +88,16 @@ export default function EmpresasPage() {
if (editando) { if (editando) {
await atualizarEmpresa({ await atualizarEmpresa({
id: editando.id, id: editando.id,
nome: nomeTrimmed, name: nomeTrimmed,
custo_por_km: custo, cost_per_km: custo,
observacoes: form.observacoes.trim(), notes: form.observacoes.trim(),
}); });
} else { } else {
await salvarEmpresa({ await salvarEmpresa({
id: Date.now().toString(), id: Date.now().toString(),
nome: nomeTrimmed, name: nomeTrimmed,
custo_por_km: custo, cost_per_km: custo,
observacoes: form.observacoes.trim(), notes: form.observacoes.trim(),
}); });
} }
await loadEmpresas(); await loadEmpresas();
@ -109,10 +109,10 @@ export default function EmpresasPage() {
} }
}; };
const handleDeletar = (empresa: EmpresaDB) => { const handleDeletar = (empresa: CompanyDB) => {
Alert.alert( Alert.alert(
"Excluir empresa", "Excluir empresa",
`Deseja excluir "${empresa.nome}"? Esta ação não pode ser desfeita.`, `Deseja excluir "${empresa.name}"? Esta ação não pode ser desfeita.`,
[ [
{ text: "Cancelar", style: "cancel" }, { text: "Cancelar", style: "cancel" },
{ {
@ -131,18 +131,18 @@ export default function EmpresasPage() {
); );
}; };
const renderItem = ({ item, index }: { item: EmpresaDB; index: number }) => ( const renderItem = ({ item, index }: { item: CompanyDB; index: number }) => (
<View style={styles.item}> <View style={styles.item}>
<View style={styles.itemLeft}> <View style={styles.itemLeft}>
<View style={styles.itemIndex}> <View style={styles.itemIndex}>
<Text style={styles.itemIndexText}>{index + 1}</Text> <Text style={styles.itemIndexText}>{index + 1}</Text>
</View> </View>
<View style={styles.itemInfo}> <View style={styles.itemInfo}>
<Text style={styles.itemNome}>{item.nome}</Text> <Text style={styles.itemNome}>{item.name}</Text>
<Text style={styles.itemCusto}>R$ {item.custo_por_km.toFixed(2)} / km</Text> <Text style={styles.itemCusto}>R$ {item.cost_per_km.toFixed(2)} / km</Text>
{item.observacoes ? ( {item.notes ? (
<Text style={styles.itemObs} numberOfLines={1}> <Text style={styles.itemObs} numberOfLines={1}>
{item.observacoes} {item.notes}
</Text> </Text>
) : null} ) : null}
</View> </View>

View file

@ -63,10 +63,10 @@ export default function HistoricoPage() {
setCorridas( setCorridas(
data.map((c) => ({ data.map((c) => ({
id: c.id, id: c.id,
data: c.data, data: c.ride_date,
empresa: c.empresa, empresa: c.company,
km: c.km, km: c.km,
custoPorKm: c.custo_por_km, custoPorKm: c.cost_per_km,
total: c.total, total: c.total,
})), })),
); );

View file

@ -114,7 +114,7 @@ export default function Home() {
icon: "📊", icon: "📊",
label: "Relatório", label: "Relatório",
description: "Análise de ganhos", description: "Análise de ganhos",
onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."), onPress: () => router.push("/relatorio"),
}, },
{ {
icon: "👤", icon: "👤",

View file

@ -17,6 +17,7 @@ import { COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { api, setAuthToken } from "@/server/api"; import { api, setAuthToken } from "@/server/api";
import { salvarUsuario } from "@/services/db"; import { salvarUsuario } from "@/services/db";
import { getUserIdFromToken } from "@/utils/jwt";
import { Link } from "expo-router"; import { Link } from "expo-router";
export default function IndexPage() { export default function IndexPage() {
@ -63,9 +64,8 @@ export default function IndexPage() {
const userName = data.name || emailTrimmed.split("@")[0]; const userName = data.name || emailTrimmed.split("@")[0];
// Salvar no SQLite — retorna o ID existente se o usuário já tinha conta
const userId = await salvarUsuario({ const userId = await salvarUsuario({
id: Date.now().toString(), id: getUserIdFromToken(data.accessToken) ?? data.id,
email: emailTrimmed, email: emailTrimmed,
name: userName, name: userName,
token: data.accessToken, token: data.accessToken,

View file

@ -3,7 +3,7 @@ import { Input } from "@/components/Input";
import { Select } from "@/components/Select"; import { Select } from "@/components/Select";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme"; import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { EmpresaDB, obterEmpresas, salvarCorrida } from "@/services/db"; import { CompanyDB, obterEmpresas, salvarCorrida } from "@/services/db";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { import {
@ -30,7 +30,7 @@ export type Corrida = {
export default function LancamentoPage() { export default function LancamentoPage() {
const { user } = useAuth(); const { user } = useAuth();
const [empresasDB, setEmpresasDB] = useState<EmpresaDB[]>([]); const [empresasDB, setEmpresasDB] = useState<CompanyDB[]>([]);
const [empresaId, setEmpresaId] = useState(""); const [empresaId, setEmpresaId] = useState("");
const [custoPorKm, setCustoPorKm] = useState(0); const [custoPorKm, setCustoPorKm] = useState(0);
const [distancia, setDistancia] = useState(""); const [distancia, setDistancia] = useState("");
@ -49,13 +49,13 @@ export default function LancamentoPage() {
const empresasItems = [ const empresasItems = [
{ label: "Selecione uma empresa", value: "" }, { label: "Selecione uma empresa", value: "" },
...empresasDB.map((e) => ({ label: e.nome, value: e.id })), ...empresasDB.map((e) => ({ label: e.name, value: e.id })),
]; ];
const handleEmpresaChange = (id: string) => { const handleEmpresaChange = (id: string) => {
setEmpresaId(id); setEmpresaId(id);
const found = empresasDB.find((e) => e.id === id); const found = empresasDB.find((e) => e.id === id);
setCustoPorKm(found?.custo_por_km ?? 0); setCustoPorKm(found?.cost_per_km ?? 0);
}; };
const handleLancarCorrida = async () => { const handleLancarCorrida = async () => {
@ -74,13 +74,13 @@ export default function LancamentoPage() {
setLoading(true); setLoading(true);
await salvarCorrida({ await salvarCorrida({
id: Date.now().toString(), id: Date.now().toString(),
usuario_id: user?.id ?? "", user_id: user?.id ?? "",
empresa: empresaSelecionada?.nome ?? empresaId, company: empresaSelecionada?.name ?? empresaId,
km: distanciaNum, km: distanciaNum,
custo_por_km: custoPorKm, cost_per_km: custoPorKm,
total: totalCorrida, total: totalCorrida,
data: new Date().toLocaleString("pt-BR"), ride_date: new Date().toLocaleString("pt-BR"),
sincronizado: 0, synced: 0,
}); });
setEmpresaId(""); setEmpresaId("");
setCustoPorKm(0); setCustoPorKm(0);

View file

@ -0,0 +1,477 @@
import { Select } from "@/components/Select";
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext";
import { RideDB, obterCorridas } from "@/services/db";
import { Asset } from "expo-asset";
import { File } from "expo-file-system";
import * as Print from "expo-print";
import { router, useFocusEffect } from "expo-router";
import * as Sharing from "expo-sharing";
import React, { useCallback, useState } from "react";
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
// ── Month helpers ─────────────────────────────────────────────────────────────
function currentYearMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function getMonthOptions() {
const now = new Date();
return Array.from({ length: 12 }, (_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const value = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const label = d.toLocaleDateString("pt-BR", { month: "long", year: "numeric" });
return { value, label: label.charAt(0).toUpperCase() + label.slice(1) };
});
}
function parseRideDate(dateStr: string): Date | null {
if (!dateStr) return null;
const br = dateStr.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
if (br) return new Date(`${br[3]}-${br[2]}-${br[1]}T00:00:00`);
const iso = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) return new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00`);
return null;
}
function filterByMonth(rides: RideDB[], yearMonth: string): RideDB[] {
const [year, month] = yearMonth.split("-").map(Number);
return rides.filter((r) => {
const d = parseRideDate(r.ride_date) ?? parseRideDate(r.createdAt);
return d ? d.getFullYear() === year && d.getMonth() + 1 === month : false;
});
}
// ── Report calculation ────────────────────────────────────────────────────────
type CompanyReport = { name: string; rides: number; km: number; total: number };
function buildCompanyReports(rides: RideDB[]): CompanyReport[] {
const map = new Map<string, CompanyReport>();
for (const r of rides) {
const prev = map.get(r.company) ?? { name: r.company, rides: 0, km: 0, total: 0 };
map.set(r.company, {
...prev,
rides: prev.rides + 1,
km: prev.km + r.km,
total: prev.total + r.total,
});
}
return Array.from(map.values()).sort((a, b) => b.total - a.total);
}
// ── PDF generation ────────────────────────────────────────────────────────────
async function getLogoBase64(): Promise<string> {
try {
const asset = Asset.fromModule(require("@/assets/toptran.png"));
await asset.downloadAsync();
if (asset.localUri) {
const file = new File(asset.localUri);
const base64 = await file.base64();
return `data:image/png;base64,${base64}`;
}
} catch {
// logo optional — PDF will show text fallback
}
return "";
}
function buildPdfHtml(
monthLabel: string,
userName: string,
companies: CompanyReport[],
totalRides: number,
totalKm: number,
totalEarnings: number,
logoSrc: string,
): string {
const rows = companies
.map(
(c) => `
<tr>
<td>${c.name}</td>
<td class="center">${c.rides}</td>
<td class="right">${c.km.toFixed(1)} km</td>
<td class="right">R$ ${c.total.toFixed(2)}</td>
</tr>`,
)
.join("");
const generated = new Date().toLocaleDateString("pt-BR") +
" às " + new Date().toLocaleTimeString("pt-BR");
return `<!DOCTYPE html>
<html><head>
<meta charset="utf-8"/>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:Arial,sans-serif; color:#1a1a1a; padding:40px; font-size:13px; }
.header { display:flex; align-items:center; justify-content:space-between;
border-bottom:2px solid #1a1a1a; padding-bottom:16px; margin-bottom:28px; }
.logo { height:52px; object-fit:contain; }
.logo-text { font-size:26px; font-weight:900; letter-spacing:1px; }
.header-info { text-align:right; }
.header-info h2 { font-size:18px; font-weight:800; }
.header-info p { color:#666; font-size:12px; margin-top:3px; }
.summary { display:flex; gap:14px; margin-bottom:32px; }
.card { flex:1; border:1px solid #e0e0e0; border-radius:8px; padding:16px; background:#f8f8f8; }
.card .val { font-size:22px; font-weight:800; }
.card .val.green { color:#10b981; }
.card .lbl { font-size:10px; color:#888; text-transform:uppercase; letter-spacing:.5px; margin-top:4px; }
h3 { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:1.2px;
color:#666; margin-bottom:12px; }
table { width:100%; border-collapse:collapse; margin-bottom:28px; }
thead th { background:#1a1a1a; color:#fff; padding:10px 14px; text-align:left; font-size:12px; }
tbody tr:nth-child(even) { background:#f5f5f5; }
tbody td { padding:10px 14px; border-bottom:1px solid #eee; }
.center { text-align:center; }
.right { text-align:right; }
.total-row td { background:#1a1a1a; color:#fff; font-weight:700; padding:12px 14px; }
.footer { margin-top:40px; text-align:center; font-size:11px; color:#aaa;
border-top:1px solid #eee; padding-top:16px; }
</style>
</head>
<body>
<div class="header">
${logoSrc
? `<img class="logo" src="${logoSrc}" alt="TopTran"/>`
: `<span class="logo-text">TopTran</span>`}
<div class="header-info">
<h2>Relatório de Corridas</h2>
<p>${monthLabel}</p>
<p>Motorista: ${userName}</p>
</div>
</div>
<div class="summary">
<div class="card">
<div class="val">${totalRides}</div>
<div class="lbl">Corridas</div>
</div>
<div class="card">
<div class="val">${totalKm.toFixed(1)}</div>
<div class="lbl">Km Rodados</div>
</div>
<div class="card">
<div class="val green">R$ ${totalEarnings.toFixed(2)}</div>
<div class="lbl">Total Ganho</div>
</div>
</div>
<h3>Detalhamento por Empresa</h3>
<table>
<thead>
<tr>
<th>Empresa</th>
<th class="center">Corridas</th>
<th class="right">Km</th>
<th class="right">Total</th>
</tr>
</thead>
<tbody>
${rows}
<tr><td class="total-row" colspan="4" style="height:1px;padding:0;background:#555;"></td></tr>
<tr>
<td class="total-row">TOTAL DO PERÍODO</td>
<td class="total-row center">${totalRides}</td>
<td class="total-row right">${totalKm.toFixed(1)} km</td>
<td class="total-row right">R$ ${totalEarnings.toFixed(2)}</td>
</tr>
</tbody>
</table>
<div class="footer">
Gerado em ${generated} &bull; TopTran Sistema de Gestão
</div>
</body></html>`;
}
// ── Screen ────────────────────────────────────────────────────────────────────
const MONTH_OPTIONS = getMonthOptions();
export default function RelatorioPage() {
const { user } = useAuth();
const [selectedMonth, setSelectedMonth] = useState(currentYearMonth());
const [allRides, setAllRides] = useState<RideDB[]>([]);
const [exporting, setExporting] = useState(false);
useFocusEffect(
useCallback(() => {
if (!user?.id) return;
obterCorridas(user.id).then(setAllRides).catch(console.error);
}, [user?.id]),
);
const rides = filterByMonth(allRides, selectedMonth);
const companies = buildCompanyReports(rides);
const totalKm = rides.reduce((s, r) => s + r.km, 0);
const totalEarnings = rides.reduce((s, r) => s + r.total, 0);
const monthLabel = MONTH_OPTIONS.find((o) => o.value === selectedMonth)?.label ?? selectedMonth;
const handleExport = async () => {
if (rides.length === 0) {
Alert.alert("Sem dados", "Não há corridas registradas em " + monthLabel + ".");
return;
}
try {
setExporting(true);
const logoSrc = await getLogoBase64();
const html = buildPdfHtml(
monthLabel,
user?.name ?? "Motorista",
companies,
rides.length,
totalKm,
totalEarnings,
logoSrc,
);
const { uri } = await Print.printToFileAsync({ html });
await Sharing.shareAsync(uri, {
mimeType: "application/pdf",
dialogTitle: `Relatório ${monthLabel}`,
});
} catch (e: any) {
Alert.alert("Erro", e?.message ?? "Não foi possível gerar o PDF.");
} finally {
setExporting(false);
}
};
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.scroll} showsVerticalScrollIndicator={false}>
<View style={styles.titleSection}>
<Text style={styles.title}>Relatório</Text>
<Text style={styles.subtitle}>Análise de ganhos por período</Text>
</View>
{/* Filtro de mês */}
<View style={styles.filterCard}>
<Text style={styles.cardLabel}>Período</Text>
<Select value={selectedMonth} onValueChange={setSelectedMonth} items={MONTH_OPTIONS} />
</View>
{/* Cards de resumo */}
<View style={styles.summaryRow}>
<View style={styles.summaryCard}>
<Text style={styles.summaryValue}>{rides.length}</Text>
<Text style={styles.summaryLabel}>Corridas</Text>
</View>
<View style={styles.summaryCard}>
<Text style={styles.summaryValue}>{totalKm.toFixed(1)}</Text>
<Text style={styles.summaryLabel}>Km</Text>
</View>
<View style={[styles.summaryCard, styles.summaryCardAccent]}>
<Text style={[styles.summaryValue, styles.summaryValueAccent]}>
R$ {totalEarnings.toFixed(2)}
</Text>
<Text style={styles.summaryLabel}>Total</Text>
</View>
</View>
{/* Tabela por empresa */}
<Text style={styles.sectionTitle}>Por Empresa</Text>
{companies.length === 0 ? (
<View style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhuma corrida em {monthLabel}.</Text>
</View>
) : (
<>
{companies.map((c) => (
<View key={c.name} style={styles.companyCard}>
<View style={styles.companyHeader}>
<Text style={styles.companyName}>{c.name}</Text>
<Text style={styles.companyBadge}>
{c.rides} corrida{c.rides !== 1 ? "s" : ""}
</Text>
</View>
<View style={styles.companyStats}>
<Text style={styles.companyKm}>{c.km.toFixed(1)} km</Text>
<Text style={styles.companyTotal}>R$ {c.total.toFixed(2)}</Text>
</View>
</View>
))}
<View style={styles.totalCard}>
<View style={styles.companyHeader}>
<Text style={styles.totalLabel}>Total do Período</Text>
</View>
<View style={styles.companyStats}>
<Text style={styles.companyKm}>{totalKm.toFixed(1)} km</Text>
<Text style={styles.totalAmount}>R$ {totalEarnings.toFixed(2)}</Text>
</View>
</View>
</>
)}
{/* Botão exportar */}
<TouchableOpacity
style={[styles.exportButton, exporting && styles.exportButtonDisabled]}
onPress={handleExport}
disabled={exporting}
activeOpacity={0.85}
>
{exporting ? (
<ActivityIndicator color={COLORS.background} size="small" />
) : (
<Text style={styles.exportButtonText}>Exportar PDF</Text>
)}
</TouchableOpacity>
</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" },
scroll: { padding: SPACING.lg, paddingBottom: 48 },
titleSection: { marginTop: SPACING.md, marginBottom: SPACING.xl },
title: { fontSize: 28, fontWeight: "800", color: COLORS.text, marginBottom: 4 },
subtitle: { fontSize: 14, color: COLORS.textTertiary },
filterCard: {
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.lg,
borderWidth: 1,
borderColor: COLORS.border,
marginBottom: SPACING.lg,
},
cardLabel: {
fontSize: 11,
fontWeight: "700",
color: COLORS.textTertiary,
textTransform: "uppercase",
letterSpacing: 0.8,
marginBottom: SPACING.sm,
},
summaryRow: { flexDirection: "row", gap: SPACING.sm, marginBottom: SPACING.xl },
summaryCard: {
flex: 1,
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.md,
borderWidth: 1,
borderColor: COLORS.border,
alignItems: "center",
},
summaryCardAccent: { borderColor: COLORS.success },
summaryValue: { fontSize: 20, fontWeight: "800", color: COLORS.text, marginBottom: 2 },
summaryValueAccent: { color: COLORS.success },
summaryLabel: {
fontSize: 10,
color: COLORS.textTertiary,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
sectionTitle: {
fontSize: 11,
fontWeight: "700",
color: COLORS.textTertiary,
textTransform: "uppercase",
letterSpacing: 1.2,
marginBottom: SPACING.md,
},
emptyCard: {
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.xl,
borderWidth: 1,
borderColor: COLORS.border,
alignItems: "center",
marginBottom: SPACING.xl,
},
emptyText: { color: COLORS.textTertiary, fontSize: 14 },
companyCard: {
backgroundColor: COLORS.surface,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.lg,
borderWidth: 1,
borderColor: COLORS.border,
marginBottom: SPACING.sm,
},
companyHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: SPACING.sm,
},
companyName: { fontSize: 15, fontWeight: "700", color: COLORS.text, flex: 1 },
companyBadge: {
fontSize: 12,
color: COLORS.textTertiary,
backgroundColor: COLORS.surfaceLight,
paddingHorizontal: SPACING.sm,
paddingVertical: 3,
borderRadius: BORDER_RADIUS.sm,
overflow: "hidden",
},
companyStats: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
companyKm: { fontSize: 13, color: COLORS.textSecondary },
companyTotal: { fontSize: 18, fontWeight: "800", color: COLORS.text },
totalCard: {
backgroundColor: COLORS.surfaceLight,
borderRadius: BORDER_RADIUS.lg,
padding: SPACING.lg,
borderWidth: 1,
borderColor: COLORS.borderLight,
marginBottom: SPACING.xl,
borderLeftWidth: 3,
borderLeftColor: COLORS.success,
},
totalLabel: { fontSize: 13, fontWeight: "700", color: COLORS.text },
totalAmount: { fontSize: 22, fontWeight: "800", color: COLORS.success },
exportButton: {
backgroundColor: COLORS.text,
borderRadius: BORDER_RADIUS.lg,
paddingVertical: SPACING.lg,
alignItems: "center",
},
exportButtonDisabled: { backgroundColor: COLORS.borderLight },
exportButtonText: { fontSize: 15, fontWeight: "700", color: COLORS.background },
});

View file

@ -16,6 +16,7 @@ import { Input } from "@/components/Input";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { api, setAuthToken } from "@/server/api"; import { api, setAuthToken } from "@/server/api";
import { salvarUsuario } from "@/services/db"; import { salvarUsuario } from "@/services/db";
import { getUserIdFromToken } from "@/utils/jwt";
import { Link } from "expo-router"; import { Link } from "expo-router";
function isValidEmail(emailToCheck: string): boolean { function isValidEmail(emailToCheck: string): boolean {
@ -64,7 +65,8 @@ export default function Signup() {
setAuthToken(data.accessToken); setAuthToken(data.accessToken);
const userId = Date.now().toString(); const userId = getUserIdFromToken(data.accessToken) ?? data.id;
await salvarUsuario({ await salvarUsuario({
id: userId, id: userId,
email: emailTrimmed, email: emailTrimmed,
@ -72,7 +74,7 @@ export default function Signup() {
token: data.accessToken, token: data.accessToken,
}); });
await signup(nameTrimmed, emailTrimmed, password, data.accessToken); await signup(nameTrimmed, emailTrimmed, userId, data.accessToken);
} catch (err: any) { } catch (err: any) {
const message = err.response?.data?.error ?? err.message ?? "Erro ao criar conta."; const message = err.response?.data?.error ?? err.message ?? "Erro ao criar conta.";
Alert.alert("Erro", message); Alert.alert("Erro", message);

View file

@ -1,9 +1,11 @@
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme"; import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { import {
CompanyDB,
marcarCorridaComoSincronizada, marcarCorridaComoSincronizada,
obterCorridasNaoSincronizadas, obterCorridasNaoSincronizadas,
obterEmpresas, obterEmpresas,
upsertEmpresaLocal,
} from "@/services/db"; } from "@/services/db";
import { api } from "@/server/api"; import { api } from "@/server/api";
import { router } from "expo-router"; import { router } from "expo-router";
@ -30,16 +32,23 @@ type SyncItem = {
const INITIAL_ITEMS: SyncItem[] = [ const INITIAL_ITEMS: SyncItem[] = [
{ {
key: "empresas", key: "upload_empresas",
label: "Empresas", label: "Empresas → Servidor",
description: "Cadastro de empresas e custos por km", description: "Enviar cadastro de empresas ao servidor",
status: "pending", status: "pending",
detail: "", detail: "",
}, },
{ {
key: "corridas", key: "download_empresas",
label: "Corridas", label: "Servidor → Empresas",
description: "Histórico de corridas não sincronizadas", description: "Baixar empresas se cadastro estiver vazio",
status: "pending",
detail: "",
},
{
key: "upload_corridas",
label: "Corridas → Servidor",
description: "Enviar corridas pendentes ao servidor",
status: "pending", status: "pending",
detail: "", detail: "",
}, },
@ -67,40 +76,73 @@ export default function SincronizarPage() {
setDone(false); setDone(false);
setItems(INITIAL_ITEMS); setItems(INITIAL_ITEMS);
// ── Companies ──────────────────────────────────────────── // ── 1. Upload Companies (sempre) ───────────────────────────
update("empresas", { status: "syncing" }); update("upload_empresas", { status: "syncing" });
let localCount = 0;
try { try {
const companies = await obterEmpresas(); const localEmpresas = await obterEmpresas();
await api.post("/sync/companies", { companies }); localCount = localEmpresas.length;
update("empresas", { await api.post("/sync/companies", { companies: localEmpresas });
update("upload_empresas", {
status: "success", status: "success",
detail: `${companies.length} registro${companies.length !== 1 ? "s" : ""} enviado${companies.length !== 1 ? "s" : ""}`, detail: `${localCount} empresa${localCount !== 1 ? "s" : ""} enviada${localCount !== 1 ? "s" : ""}`,
}); });
} catch (e: any) { } catch (e: any) {
update("empresas", { update("upload_empresas", {
status: "error", status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão", detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
}); });
} }
// ── Rides ───────────────────────────────────────────────── // ── 2. Download Companies (só se local estiver vazio) ───────
update("corridas", { status: "syncing" }); update("download_empresas", { status: "syncing" });
try { try {
const rides = await obterCorridasNaoSincronizadas(user.id); if (localCount === 0) {
if (rides.length === 0) { const { data: resEmpresas } = await api.get("/sync/companies");
update("corridas", { status: "success", detail: "Nenhuma corrida pendente" }); const rawEmpresas: CompanyDB[] = resEmpresas.data ?? [];
} else { for (const c of rawEmpresas) {
await api.post("/sync/rides", { rides }); await upsertEmpresaLocal({
for (const ride of rides) { id: c.id,
await marcarCorridaComoSincronizada(ride.id); name: c.name,
cost_per_km: Number(c.cost_per_km),
notes: c.notes ?? "",
});
} }
update("corridas", { update("download_empresas", {
status: "success", status: "success",
detail: `${rides.length} corrida${rides.length !== 1 ? "s" : ""} enviada${rides.length !== 1 ? "s" : ""}`, detail: `${rawEmpresas.length} empresa${rawEmpresas.length !== 1 ? "s" : ""} baixada${rawEmpresas.length !== 1 ? "s" : ""}`,
});
} else {
update("download_empresas", {
status: "success",
detail: "Cadastro local já possui dados",
}); });
} }
} catch (e: any) { } catch (e: any) {
update("corridas", { update("download_empresas", {
status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
});
}
// ── 3. Upload Unsynced Rides ────────────────────────────────
update("upload_corridas", { status: "syncing" });
try {
const pendingRides = await obterCorridasNaoSincronizadas(user.id);
if (pendingRides.length === 0) {
update("upload_corridas", { status: "success", detail: "Nenhuma corrida pendente" });
} else {
await api.post("/sync/rides", { rides: pendingRides });
for (const ride of pendingRides) {
await marcarCorridaComoSincronizada(ride.id);
}
update("upload_corridas", {
status: "success",
detail: `${pendingRides.length} corrida${pendingRides.length !== 1 ? "s" : ""} enviada${pendingRides.length !== 1 ? "s" : ""}`,
});
}
} catch (e: any) {
update("upload_corridas", {
status: "error", status: "error",
detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão", detail: e?.response?.data?.error ?? e?.message ?? "Falha na conexão",
}); });
@ -134,7 +176,7 @@ export default function SincronizarPage() {
<View style={styles.titleSection}> <View style={styles.titleSection}>
<Text style={styles.title}>Sincronizar</Text> <Text style={styles.title}>Sincronizar</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Enviar dados locais para o servidor Sincronizar dados com o servidor
</Text> </Text>
</View> </View>

View file

@ -1,7 +1,14 @@
import { Picker } from "@react-native-picker/picker"; import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
import React from "react"; import React, { useState } from "react";
import { StyleSheet, Text, View } from "react-native"; import {
import { COLORS, BORDER_RADIUS, SPACING } from "@/constants/theme"; FlatList,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
type SelectProps = { type SelectProps = {
label?: string; label?: string;
@ -11,24 +18,72 @@ type SelectProps = {
}; };
export function Select({ label, value, onValueChange, items }: SelectProps) { export function Select({ label, value, onValueChange, items }: SelectProps) {
const [open, setOpen] = useState(false);
const selected = items.find((i) => i.value === value);
return ( return (
<View style={styles.container}> <View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>} {label && <Text style={styles.label}>{label}</Text>}
<View style={styles.pickerContainer}>
<Picker <TouchableOpacity
selectedValue={value} style={styles.trigger}
onValueChange={onValueChange} onPress={() => setOpen(true)}
style={styles.picker} activeOpacity={0.7}
>
<Text
style={[
styles.triggerText,
!selected?.value && styles.triggerPlaceholder,
]}
numberOfLines={1}
> >
{items.map((item) => ( {selected?.label ?? "Selecione"}
<Picker.Item </Text>
key={item.value} <Text style={styles.arrow}></Text>
label={item.label} </TouchableOpacity>
value={item.value}
<Modal visible={open} transparent animationType="fade">
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={() => setOpen(false)}
>
<SafeAreaView style={styles.sheet} onStartShouldSetResponder={() => true}>
<View style={styles.sheetHandle} />
<FlatList
data={items}
keyExtractor={(item) => item.value}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.option,
item.value === value && styles.optionSelected,
]}
onPress={() => {
onValueChange(item.value);
setOpen(false);
}}
activeOpacity={0.7}
>
<Text
style={[
styles.optionText,
item.value === value && styles.optionTextSelected,
!item.value && styles.optionPlaceholder,
]}
>
{item.label}
</Text>
{item.value === value && (
<Text style={styles.checkmark}></Text>
)}
</TouchableOpacity>
)}
/> />
))} </SafeAreaView>
</Picker> </TouchableOpacity>
</View> </Modal>
</View> </View>
); );
} }
@ -43,15 +98,80 @@ const styles = StyleSheet.create({
fontWeight: "600", fontWeight: "600",
marginBottom: SPACING.sm, marginBottom: SPACING.sm,
}, },
pickerContainer: { trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: 48,
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.inputBorder, borderColor: COLORS.inputBorder,
backgroundColor: COLORS.inputBackground, backgroundColor: COLORS.inputBackground,
borderRadius: BORDER_RADIUS.md, borderRadius: BORDER_RADIUS.md,
overflow: "hidden", paddingHorizontal: SPACING.md,
}, },
picker: { triggerText: {
height: 48, flex: 1,
fontSize: 15,
color: COLORS.inputText, color: COLORS.inputText,
}, },
triggerPlaceholder: {
color: COLORS.inputPlaceholder,
},
arrow: {
fontSize: 14,
color: COLORS.textSecondary,
marginLeft: SPACING.sm,
},
// Modal
backdrop: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "flex-end",
},
sheet: {
backgroundColor: COLORS.surface,
borderTopLeftRadius: BORDER_RADIUS.xl,
borderTopRightRadius: BORDER_RADIUS.xl,
maxHeight: "60%",
paddingBottom: SPACING.lg,
},
sheetHandle: {
width: 40,
height: 4,
backgroundColor: COLORS.border,
borderRadius: 2,
alignSelf: "center",
marginTop: SPACING.md,
marginBottom: SPACING.sm,
},
option: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.lg,
borderBottomWidth: 1,
borderBottomColor: COLORS.border,
},
optionSelected: {
backgroundColor: COLORS.surfaceLight,
},
optionText: {
fontSize: 15,
color: COLORS.text,
flex: 1,
},
optionTextSelected: {
fontWeight: "700",
color: COLORS.success,
},
optionPlaceholder: {
color: COLORS.textTertiary,
},
checkmark: {
fontSize: 16,
color: COLORS.success,
fontWeight: "700",
},
}); });

View file

@ -4,8 +4,10 @@ import {
deleteSetting, deleteSetting,
getSetting, getSetting,
initDB, initDB,
salvarUsuario,
setSetting, setSetting,
} from "@/services/db"; } from "@/services/db";
import { getUserIdFromToken } from "@/utils/jwt";
type User = { type User = {
id: string; id: string;
@ -26,7 +28,7 @@ type AuthContextType = {
signup: ( signup: (
name: string, name: string,
email: string, email: string,
password: string, userId: string,
token: string, token: string,
) => Promise<void>; ) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
@ -52,8 +54,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const storedUser = await getSetting("user"); const storedUser = await getSetting("user");
if (storedToken && storedUser) { if (storedToken && storedUser) {
let parsedUser: User = JSON.parse(storedUser);
const realId = getUserIdFromToken(storedToken);
if (realId && realId !== parsedUser.id) {
await salvarUsuario({
id: realId,
email: parsedUser.email,
name: parsedUser.name,
token: storedToken,
});
parsedUser = { ...parsedUser, id: realId };
await setSetting("user", JSON.stringify(parsedUser));
}
setToken(storedToken); setToken(storedToken);
setUser(JSON.parse(storedUser)); setUser(parsedUser);
router.replace("/home"); router.replace("/home");
} else { } else {
setToken(null); setToken(null);
@ -92,12 +108,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
signup: async ( signup: async (
name: string, name: string,
email: string, email: string,
password: string, userId: string,
authToken: string, authToken: string,
) => { ) => {
try { try {
const newUser: User = { const newUser: User = {
id: Date.now().toString(), id: userId,
email, email,
name, name,
}; };

View file

@ -2,49 +2,57 @@ import * as SQLite from "expo-sqlite";
const db = SQLite.openDatabaseSync("toptran.db"); const db = SQLite.openDatabaseSync("toptran.db");
export type UsuarioDB = { export type UserDB = {
id: string; id: string;
email: string; email: string;
name: string; name: string;
token: string; token: string;
created_at: string; createdAt: string;
}; };
export type CorridaDB = { export type RideDB = {
id: string; id: string;
usuario_id: string; user_id: string;
empresa: string; company: string;
km: number; km: number;
custo_por_km: number; cost_per_km: number;
total: number; total: number;
data: string; ride_date: string;
sincronizado: 0 | 1; synced: 0 | 1;
created_at: string; createdAt: string;
};
export type CompanyDB = {
id: string;
name: string;
cost_per_km: number;
notes: string;
createdAt: string;
}; };
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 users (
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 createdAt TEXT DEFAULT CURRENT_TIMESTAMP
);`, );`,
); );
await db.execAsync( await db.execAsync(
`CREATE TABLE IF NOT EXISTS corridas ( `CREATE TABLE IF NOT EXISTS rides (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
usuario_id TEXT NOT NULL, user_id TEXT NOT NULL,
empresa TEXT NOT NULL, company TEXT NOT NULL,
km REAL NOT NULL, km REAL NOT NULL,
custo_por_km REAL NOT NULL, cost_per_km REAL NOT NULL,
total REAL NOT NULL, total REAL NOT NULL,
data TEXT NOT NULL, ride_date TEXT NOT NULL,
sincronizado INTEGER DEFAULT 0, synced INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP, createdAt TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (usuario_id) REFERENCES usuarios(id) FOREIGN KEY (user_id) REFERENCES users(id)
);`, );`,
); );
await db.execAsync( await db.execAsync(
@ -54,12 +62,12 @@ export const initDB = async () => {
);`, );`,
); );
await db.execAsync( await db.execAsync(
`CREATE TABLE IF NOT EXISTS empresas ( `CREATE TABLE IF NOT EXISTS companies (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
nome TEXT NOT NULL, name TEXT NOT NULL,
custo_por_km REAL NOT NULL, cost_per_km REAL NOT NULL,
observacoes TEXT DEFAULT '', notes TEXT DEFAULT '',
created_at TEXT DEFAULT CURRENT_TIMESTAMP createdAt TEXT DEFAULT CURRENT_TIMESTAMP
);`, );`,
); );
} catch (error) { } catch (error) {
@ -103,27 +111,38 @@ export const deleteSetting = async (key: string): Promise<void> => {
} }
}; };
// USUÁRIOS // USERS
export const salvarUsuario = async ( export const salvarUsuario = async (
usuario: Omit<UsuarioDB, "created_at">, usuario: Omit<UserDB, "createdAt">,
): Promise<string> => { ): Promise<string> => {
try { try {
const existing = await db.getFirstAsync<{ id: string }>( const existing = await db.getFirstAsync<{ id: string }>(
`SELECT id FROM usuarios WHERE email = ?`, `SELECT id FROM users WHERE email = ?`,
[usuario.email], [usuario.email],
); );
if (existing) { if (existing) {
// Preserva o ID original — apenas atualiza nome e token if (existing.id !== usuario.id) {
await db.runAsync( // Migra rides do ID local antigo para o ID real do backend
`UPDATE usuarios SET name = ?, token = ? WHERE email = ?`, await db.runAsync(
[usuario.name, usuario.token, usuario.email], `UPDATE rides SET user_id = ? WHERE user_id = ?`,
); [usuario.id, existing.id],
return existing.id; );
await db.runAsync(
`UPDATE users SET id = ?, name = ?, token = ? WHERE email = ?`,
[usuario.id, usuario.name, usuario.token, usuario.email],
);
} else {
await db.runAsync(
`UPDATE users SET name = ?, token = ? WHERE email = ?`,
[usuario.name, usuario.token, usuario.email],
);
}
return usuario.id;
} }
await db.runAsync( await db.runAsync(
`INSERT INTO usuarios (id, email, name, token) VALUES (?, ?, ?, ?)`, `INSERT INTO users (id, email, name, token) VALUES (?, ?, ?, ?)`,
[usuario.id, usuario.email, usuario.name, usuario.token], [usuario.id, usuario.email, usuario.name, usuario.token],
); );
return usuario.id; return usuario.id;
@ -135,10 +154,10 @@ export const salvarUsuario = async (
export const obterUsuario = async ( export const obterUsuario = async (
usuarioId: string, usuarioId: string,
): Promise<UsuarioDB | null> => { ): Promise<UserDB | null> => {
try { try {
const result = await db.getFirstAsync<UsuarioDB>( const result = await db.getFirstAsync<UserDB>(
`SELECT * FROM usuarios WHERE id = ?`, `SELECT * FROM users WHERE id = ?`,
[usuarioId], [usuarioId],
); );
return result || null; return result || null;
@ -148,83 +167,81 @@ export const obterUsuario = async (
} }
}; };
// CORRIDAS // RIDES
export const salvarCorrida = async (corrida: Omit<CorridaDB, "created_at">) => { export const salvarCorrida = async (corrida: Omit<RideDB, "createdAt">) => {
try { try {
const result = await db.runAsync( const result = await db.runAsync(
`INSERT INTO corridas (id, usuario_id, empresa, km, custo_por_km, total, data, sincronizado) `INSERT INTO rides (id, user_id, company, km, cost_per_km, total, ride_date, synced)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
corrida.id, corrida.id,
corrida.usuario_id, corrida.user_id,
corrida.empresa, corrida.company,
corrida.km, corrida.km,
corrida.custo_por_km, corrida.cost_per_km,
corrida.total, corrida.total,
corrida.data, corrida.ride_date,
corrida.sincronizado, corrida.synced,
], ],
); );
return result; return result;
} catch (error) { } catch (error) {
console.error("Error saving corrida:", error); console.error("Error saving ride:", error);
throw error; throw error;
} }
}; };
export const obterCorridas = async ( export const obterCorridas = async (
usuarioId: string, usuarioId: string,
): Promise<CorridaDB[]> => { ): Promise<RideDB[]> => {
try { try {
const result = await db.getAllAsync<CorridaDB>( const result = await db.getAllAsync<RideDB>(
`SELECT * FROM corridas WHERE usuario_id = ? ORDER BY created_at DESC`, `SELECT * FROM rides WHERE user_id = ? ORDER BY createdAt DESC`,
[usuarioId], [usuarioId],
); );
return result || []; return result || [];
} catch (error) { } catch (error) {
console.error("Error fetching corridas:", error); console.error("Error fetching rides:", error);
throw error; throw error;
} }
}; };
export const obterCorridasNaoSincronizadas = async ( export const obterCorridasNaoSincronizadas = async (
usuarioId: string, usuarioId: string,
): Promise<CorridaDB[]> => { ): Promise<RideDB[]> => {
try { try {
const result = await db.getAllAsync<CorridaDB>( const result = await db.getAllAsync<RideDB>(
`SELECT * FROM corridas WHERE usuario_id = ? AND sincronizado = 0`, `SELECT * FROM rides WHERE user_id = ? AND synced = 0`,
[usuarioId], [usuarioId],
); );
return result || []; return result || [];
} catch (error) { } catch (error) {
console.error("Error fetching unsync corridas:", error); console.error("Error fetching unsynced rides:", error);
throw error; throw error;
} }
}; };
export const marcarCorridaComoSincronizada = async (corridaId: string) => { export const marcarCorridaComoSincronizada = async (corridaId: string) => {
try { try {
await db.runAsync(`UPDATE corridas SET sincronizado = 1 WHERE id = ?`, [ await db.runAsync(`UPDATE rides SET synced = 1 WHERE id = ?`, [corridaId]);
corridaId,
]);
} catch (error) { } catch (error) {
console.error("Error marking corrida as synced:", error); console.error("Error marking ride as synced:", error);
throw error; throw error;
} }
}; };
export const deletarCorrida = async (corridaId: string) => { export const deletarCorrida = async (corridaId: string) => {
try { try {
await db.runAsync(`DELETE FROM corridas WHERE id = ?`, [corridaId]); await db.runAsync(`DELETE FROM rides WHERE id = ?`, [corridaId]);
} catch (error) { } catch (error) {
console.error("Error deleting corrida:", error); console.error("Error deleting ride:", error);
throw error; throw error;
} }
}; };
export const limparBancoDados = async () => { export const limparBancoDados = async () => {
try { try {
await db.execAsync(`DELETE FROM corridas; DELETE FROM usuarios;`); await db.execAsync(`DELETE FROM rides; DELETE FROM users;`);
console.log("Database cleared"); console.log("Database cleared");
} catch (error) { } catch (error) {
console.error("Error clearing database:", error); console.error("Error clearing database:", error);
@ -232,61 +249,82 @@ export const limparBancoDados = async () => {
} }
}; };
// EMPRESAS // COMPANIES
export type EmpresaDB = { export const obterEmpresas = async (): Promise<CompanyDB[]> => {
id: string;
nome: string;
custo_por_km: number;
observacoes: string;
created_at: string;
};
export const obterEmpresas = async (): Promise<EmpresaDB[]> => {
try { try {
return ( return (
(await db.getAllAsync<EmpresaDB>( (await db.getAllAsync<CompanyDB>(
`SELECT * FROM empresas ORDER BY nome ASC`, `SELECT * FROM companies ORDER BY name ASC`,
)) ?? [] )) ?? []
); );
} catch (error) { } catch (error) {
console.error("Error fetching empresas:", error); console.error("Error fetching companies:", error);
throw error; throw error;
} }
}; };
export const salvarEmpresa = async ( export const salvarEmpresa = async (
empresa: Omit<EmpresaDB, "created_at">, empresa: Omit<CompanyDB, "createdAt">,
): Promise<void> => { ): Promise<void> => {
try { try {
await db.runAsync( await db.runAsync(
`INSERT INTO empresas (id, nome, custo_por_km, observacoes) VALUES (?, ?, ?, ?)`, `INSERT INTO companies (id, name, cost_per_km, notes) VALUES (?, ?, ?, ?)`,
[empresa.id, empresa.nome, empresa.custo_por_km, empresa.observacoes], [empresa.id, empresa.name, empresa.cost_per_km, empresa.notes],
); );
} catch (error) { } catch (error) {
console.error("Error saving empresa:", error); console.error("Error saving company:", error);
throw error;
}
};
export const upsertEmpresaLocal = async (
empresa: Omit<CompanyDB, "createdAt">,
): Promise<void> => {
try {
await db.runAsync(
`INSERT OR REPLACE INTO companies (id, name, cost_per_km, notes) VALUES (?, ?, ?, ?)`,
[empresa.id, empresa.name, empresa.cost_per_km, empresa.notes],
);
} catch (error) {
console.error("Error upserting company:", error);
throw error;
}
};
export const upsertCorridaLocal = async (
corrida: Omit<RideDB, "createdAt">,
): Promise<void> => {
try {
await db.runAsync(
`INSERT OR REPLACE INTO rides (id, user_id, company, km, cost_per_km, total, ride_date, synced)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
[corrida.id, corrida.user_id, corrida.company, corrida.km, corrida.cost_per_km, corrida.total, corrida.ride_date],
);
} catch (error) {
console.error("Error upserting ride:", error);
throw error; throw error;
} }
}; };
export const atualizarEmpresa = async ( export const atualizarEmpresa = async (
empresa: Omit<EmpresaDB, "created_at">, empresa: Omit<CompanyDB, "createdAt">,
): Promise<void> => { ): Promise<void> => {
try { try {
await db.runAsync( await db.runAsync(
`UPDATE empresas SET nome = ?, custo_por_km = ?, observacoes = ? WHERE id = ?`, `UPDATE companies SET name = ?, cost_per_km = ?, notes = ? WHERE id = ?`,
[empresa.nome, empresa.custo_por_km, empresa.observacoes, empresa.id], [empresa.name, empresa.cost_per_km, empresa.notes, empresa.id],
); );
} catch (error) { } catch (error) {
console.error("Error updating empresa:", error); console.error("Error updating company:", error);
throw error; throw error;
} }
}; };
export const deletarEmpresa = async (id: string): Promise<void> => { export const deletarEmpresa = async (id: string): Promise<void> => {
try { try {
await db.runAsync(`DELETE FROM empresas WHERE id = ?`, [id]); await db.runAsync(`DELETE FROM companies WHERE id = ?`, [id]);
} catch (error) { } catch (error) {
console.error("Error deleting empresa:", error); console.error("Error deleting company:", error);
throw error; throw error;
} }
}; };

View file

@ -0,0 +1,8 @@
export function getUserIdFromToken(token: string): string | null {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.sub ?? payload.id ?? payload.userId ?? null;
} catch {
return null;
}
}