From fea50d5064073d5351e1ef602cdfb7072f222d9f Mon Sep 17 00:00:00 2001 From: Rayan Konecny Date: Sun, 3 May 2026 14:15:37 -0300 Subject: [PATCH] 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. --- backend/src/controllers/sync.controller.ts | 24 + backend/src/repositories/rides.repository.ts | 4 + backend/src/routes/sync.routes.ts | 5 + backend/src/services/sync.service.ts | 8 + toptran-app/app.json | 3 +- toptran-app/database/create_tables.sql | 31 +- toptran-app/package-lock.json | 23 + toptran-app/package.json | 4 + toptran-app/src/app/empresas.tsx | 40 +- toptran-app/src/app/historico.tsx | 6 +- toptran-app/src/app/home.tsx | 2 +- toptran-app/src/app/index.tsx | 4 +- toptran-app/src/app/lancamento.tsx | 18 +- toptran-app/src/app/relatorio.tsx | 477 +++++++++++++++++++ toptran-app/src/app/signup.tsx | 6 +- toptran-app/src/app/sincronizar.tsx | 94 +++- toptran-app/src/components/Select.tsx | 162 ++++++- toptran-app/src/contexts/AuthContext.tsx | 24 +- toptran-app/src/services/db.ts | 206 ++++---- toptran-app/src/utils/jwt.ts | 8 + 20 files changed, 971 insertions(+), 178 deletions(-) create mode 100644 toptran-app/src/app/relatorio.tsx create mode 100644 toptran-app/src/utils/jwt.ts diff --git a/backend/src/controllers/sync.controller.ts b/backend/src/controllers/sync.controller.ts index bff26ca..2aae4a3 100644 --- a/backend/src/controllers/sync.controller.ts +++ b/backend/src/controllers/sync.controller.ts @@ -38,3 +38,27 @@ export async function syncRides(req: Request, res: Response): Promise { res.status(500).json({ error: err.message ?? 'Error syncing rides' }); } } + +export async function getCompanies(req: Request, res: Response): Promise { + 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 { + 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' }); + } +} diff --git a/backend/src/repositories/rides.repository.ts b/backend/src/repositories/rides.repository.ts index b07dba9..9cf271e 100644 --- a/backend/src/repositories/rides.repository.ts +++ b/backend/src/repositories/rides.repository.ts @@ -40,6 +40,10 @@ export async function listRidesByUserId(user_id: string) { return prisma.rides.findMany({ where: { user_id } }); } +export async function listAllRides() { + return prisma.rides.findMany(); +} + export async function listRidesNotSynced(user_id: string) { return prisma.rides.findMany({ where: { diff --git a/backend/src/routes/sync.routes.ts b/backend/src/routes/sync.routes.ts index 057e229..7495032 100644 --- a/backend/src/routes/sync.routes.ts +++ b/backend/src/routes/sync.routes.ts @@ -3,6 +3,11 @@ import * as syncController from '../controllers/sync.controller.js'; const router = Router(); +// GET routes +router.get('/companies', syncController.getCompanies); +router.get('/rides', syncController.getRides); + +// POST routes router.post('/companies', syncController.syncCompanies); router.post('/rides', syncController.syncRides); diff --git a/backend/src/services/sync.service.ts b/backend/src/services/sync.service.ts index 462052a..b4ecd61 100644 --- a/backend/src/services/sync.service.ts +++ b/backend/src/services/sync.service.ts @@ -64,3 +64,11 @@ export async function syncRides( } return synced; } + +export async function getAllCompanies() { + return companiesRepo.listCompanies(); +} + +export async function getAllRides() { + return ridesRepo.listAllRides(); +} diff --git a/toptran-app/app.json b/toptran-app/app.json index 048fb2b..c74133f 100644 --- a/toptran-app/app.json +++ b/toptran-app/app.json @@ -22,7 +22,8 @@ }, "plugins": [ "expo-router", - "expo-secure-store" + "expo-secure-store", + "expo-asset" ], "experiments": { "typedRoutes": true diff --git a/toptran-app/database/create_tables.sql b/toptran-app/database/create_tables.sql index abe3255..d407f2f 100644 --- a/toptran-app/database/create_tables.sql +++ b/toptran-app/database/create_tables.sql @@ -7,14 +7,34 @@ -- [EXISTING TABLE — DO NOT RECREATE] -- CREATE TABLE IF NOT EXISTS users ( --- id TEXT PRIMARY KEY, --- name TEXT NOT NULL, --- email TEXT NOT NULL UNIQUE, --- password TEXT NOT NULL, +-- id TEXT PRIMARY KEY, +-- name TEXT NOT NULL, +-- email TEXT NOT NULL UNIQUE, +-- password TEXT NOT NULL, -- "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 -- ============================================================ @@ -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_synced ON rides(synced); CREATE INDEX IF NOT EXISTS idx_companies_name ON companies(name); +CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON tokens("userId"); diff --git a/toptran-app/package-lock.json b/toptran-app/package-lock.json index 4b1acb4..26d8f38 100644 --- a/toptran-app/package-lock.json +++ b/toptran-app/package-lock.json @@ -12,11 +12,15 @@ "@react-native-picker/picker": "^2.7.5", "axios": "^1.15.2", "expo": "~54.0.33", + "expo-asset": "~12.0.13", "expo-constants": "~18.0.13", + "expo-file-system": "~19.0.22", "expo-font": "~14.0.11", "expo-linking": "~8.0.11", + "expo-print": "~15.0.8", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "^55.0.15", "expo-status-bar": "~3.0.9", @@ -4357,6 +4361,16 @@ "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": { "version": "6.0.23", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", @@ -4628,6 +4642,15 @@ "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": { "version": "31.0.13", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", diff --git a/toptran-app/package.json b/toptran-app/package.json index e84b408..a4d7b2a 100644 --- a/toptran-app/package.json +++ b/toptran-app/package.json @@ -13,11 +13,15 @@ "@react-native-picker/picker": "^2.7.5", "axios": "^1.15.2", "expo": "~54.0.33", + "expo-asset": "~12.0.13", "expo-constants": "~18.0.13", + "expo-file-system": "~19.0.22", "expo-font": "~14.0.11", "expo-linking": "~8.0.11", + "expo-print": "~15.0.8", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "^55.0.15", "expo-status-bar": "~3.0.9", diff --git a/toptran-app/src/app/empresas.tsx b/toptran-app/src/app/empresas.tsx index ee271e3..123f3dc 100644 --- a/toptran-app/src/app/empresas.tsx +++ b/toptran-app/src/app/empresas.tsx @@ -1,7 +1,7 @@ import { Input } from "@/components/Input"; import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme"; import { - EmpresaDB, + CompanyDB, atualizarEmpresa, deletarEmpresa, obterEmpresas, @@ -32,9 +32,9 @@ type FormState = { const FORM_VAZIO: FormState = { nome: "", custoPorKm: "", observacoes: "" }; export default function EmpresasPage() { - const [empresas, setEmpresas] = useState([]); + const [empresas, setEmpresas] = useState([]); const [modalVisible, setModalVisible] = useState(false); - const [editando, setEditando] = useState(null); + const [editando, setEditando] = useState(null); const [form, setForm] = useState(FORM_VAZIO); const [loading, setLoading] = useState(false); @@ -54,12 +54,12 @@ export default function EmpresasPage() { setModalVisible(true); }; - const abrirEditar = (empresa: EmpresaDB) => { + const abrirEditar = (empresa: CompanyDB) => { setEditando(empresa); setForm({ - nome: empresa.nome, - custoPorKm: empresa.custo_por_km.toString(), - observacoes: empresa.observacoes ?? "", + nome: empresa.name, + custoPorKm: empresa.cost_per_km.toString(), + observacoes: empresa.notes ?? "", }); setModalVisible(true); }; @@ -88,16 +88,16 @@ export default function EmpresasPage() { if (editando) { await atualizarEmpresa({ id: editando.id, - nome: nomeTrimmed, - custo_por_km: custo, - observacoes: form.observacoes.trim(), + name: nomeTrimmed, + cost_per_km: custo, + notes: form.observacoes.trim(), }); } else { await salvarEmpresa({ id: Date.now().toString(), - nome: nomeTrimmed, - custo_por_km: custo, - observacoes: form.observacoes.trim(), + name: nomeTrimmed, + cost_per_km: custo, + notes: form.observacoes.trim(), }); } await loadEmpresas(); @@ -109,10 +109,10 @@ export default function EmpresasPage() { } }; - const handleDeletar = (empresa: EmpresaDB) => { + const handleDeletar = (empresa: CompanyDB) => { Alert.alert( "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" }, { @@ -131,18 +131,18 @@ export default function EmpresasPage() { ); }; - const renderItem = ({ item, index }: { item: EmpresaDB; index: number }) => ( + const renderItem = ({ item, index }: { item: CompanyDB; index: number }) => ( {index + 1} - {item.nome} - R$ {item.custo_por_km.toFixed(2)} / km - {item.observacoes ? ( + {item.name} + R$ {item.cost_per_km.toFixed(2)} / km + {item.notes ? ( - {item.observacoes} + {item.notes} ) : null} diff --git a/toptran-app/src/app/historico.tsx b/toptran-app/src/app/historico.tsx index fa6995c..9d33bee 100644 --- a/toptran-app/src/app/historico.tsx +++ b/toptran-app/src/app/historico.tsx @@ -63,10 +63,10 @@ export default function HistoricoPage() { setCorridas( data.map((c) => ({ id: c.id, - data: c.data, - empresa: c.empresa, + data: c.ride_date, + empresa: c.company, km: c.km, - custoPorKm: c.custo_por_km, + custoPorKm: c.cost_per_km, total: c.total, })), ); diff --git a/toptran-app/src/app/home.tsx b/toptran-app/src/app/home.tsx index 4da0efd..1f37ac2 100644 --- a/toptran-app/src/app/home.tsx +++ b/toptran-app/src/app/home.tsx @@ -114,7 +114,7 @@ export default function Home() { icon: "📊", label: "Relatório", description: "Análise de ganhos", - onPress: () => Alert.alert("Em breve", "Funcionalidade em desenvolvimento."), + onPress: () => router.push("/relatorio"), }, { icon: "👤", diff --git a/toptran-app/src/app/index.tsx b/toptran-app/src/app/index.tsx index fc43244..fb3b5a5 100644 --- a/toptran-app/src/app/index.tsx +++ b/toptran-app/src/app/index.tsx @@ -17,6 +17,7 @@ import { COLORS, SPACING } from "@/constants/theme"; import { useAuth } from "@/contexts/AuthContext"; import { api, setAuthToken } from "@/server/api"; import { salvarUsuario } from "@/services/db"; +import { getUserIdFromToken } from "@/utils/jwt"; import { Link } from "expo-router"; export default function IndexPage() { @@ -63,9 +64,8 @@ export default function IndexPage() { 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({ - id: Date.now().toString(), + id: getUserIdFromToken(data.accessToken) ?? data.id, email: emailTrimmed, name: userName, token: data.accessToken, diff --git a/toptran-app/src/app/lancamento.tsx b/toptran-app/src/app/lancamento.tsx index 1b7f854..a492285 100644 --- a/toptran-app/src/app/lancamento.tsx +++ b/toptran-app/src/app/lancamento.tsx @@ -3,7 +3,7 @@ import { Input } from "@/components/Input"; import { Select } from "@/components/Select"; import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme"; 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 React, { useCallback, useState } from "react"; import { @@ -30,7 +30,7 @@ export type Corrida = { export default function LancamentoPage() { const { user } = useAuth(); - const [empresasDB, setEmpresasDB] = useState([]); + const [empresasDB, setEmpresasDB] = useState([]); const [empresaId, setEmpresaId] = useState(""); const [custoPorKm, setCustoPorKm] = useState(0); const [distancia, setDistancia] = useState(""); @@ -49,13 +49,13 @@ export default function LancamentoPage() { const empresasItems = [ { 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) => { setEmpresaId(id); const found = empresasDB.find((e) => e.id === id); - setCustoPorKm(found?.custo_por_km ?? 0); + setCustoPorKm(found?.cost_per_km ?? 0); }; const handleLancarCorrida = async () => { @@ -74,13 +74,13 @@ export default function LancamentoPage() { setLoading(true); await salvarCorrida({ id: Date.now().toString(), - usuario_id: user?.id ?? "", - empresa: empresaSelecionada?.nome ?? empresaId, + user_id: user?.id ?? "", + company: empresaSelecionada?.name ?? empresaId, km: distanciaNum, - custo_por_km: custoPorKm, + cost_per_km: custoPorKm, total: totalCorrida, - data: new Date().toLocaleString("pt-BR"), - sincronizado: 0, + ride_date: new Date().toLocaleString("pt-BR"), + synced: 0, }); setEmpresaId(""); setCustoPorKm(0); diff --git a/toptran-app/src/app/relatorio.tsx b/toptran-app/src/app/relatorio.tsx new file mode 100644 index 0000000..a3991bc --- /dev/null +++ b/toptran-app/src/app/relatorio.tsx @@ -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(); + 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 { + 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) => ` + + ${c.name} + ${c.rides} + ${c.km.toFixed(1)} km + R$ ${c.total.toFixed(2)} + `, + ) + .join(""); + + const generated = new Date().toLocaleDateString("pt-BR") + + " às " + new Date().toLocaleTimeString("pt-BR"); + + return ` + + + + + +
+ ${logoSrc + ? `` + : `TopTran`} +
+

Relatório de Corridas

+

${monthLabel}

+

Motorista: ${userName}

+
+
+ +
+
+
${totalRides}
+
Corridas
+
+
+
${totalKm.toFixed(1)}
+
Km Rodados
+
+
+
R$ ${totalEarnings.toFixed(2)}
+
Total Ganho
+
+
+ +

Detalhamento por Empresa

+ + + + + + + + + + + ${rows} + + + + + + + + +
EmpresaCorridasKmTotal
TOTAL DO PERÍODO${totalRides}${totalKm.toFixed(1)} kmR$ ${totalEarnings.toFixed(2)}
+ + +`; +} + +// ── Screen ──────────────────────────────────────────────────────────────────── + +const MONTH_OPTIONS = getMonthOptions(); + +export default function RelatorioPage() { + const { user } = useAuth(); + const [selectedMonth, setSelectedMonth] = useState(currentYearMonth()); + const [allRides, setAllRides] = useState([]); + 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 ( + + + router.back()} style={styles.backButton} activeOpacity={0.7}> + + Voltar + + + + + + Relatório + Análise de ganhos por período + + + {/* Filtro de mês */} + + Período +