top-tran/toptran-app/src/app/relatorio.tsx
Rayan Konecny fea50d5064 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.
2026-05-03 14:15:37 -03:00

477 lines
16 KiB
TypeScript

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 },
});