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 +