top-tran/toptran-app/src/app/relatorio.tsx

478 lines
16 KiB
TypeScript
Raw Normal View History

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