chore: build

This commit is contained in:
2026-06-14 18:09:44 +02:00
parent 3a0f725159
commit 6d4e0862ff
45 changed files with 3745 additions and 873 deletions
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
clientUsername?: string;
clientNom?: string;
clientPrenom?: string;
items?: Array<{ produit: string; quantite: number; prix: number }>;
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean }>;
}
export default function DashboardScreen() {
@@ -115,9 +115,9 @@ export default function DashboardScreen() {
useState<EnrichedDelivery | null>(null);
// Telegram
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const [cancelModal, setCancelModal] = useState<{
visible: boolean;
deliveryId: number | null;
@@ -125,6 +125,12 @@ export default function DashboardScreen() {
description: string;
}>({ visible: false, deliveryId: null, issueType: null, description: "" });
const ABSENT_TIMEOUT_SECS = 300; // 5 minutes
const arrivedAtRef = useRef<Record<number, number>>({});
const [elapsedSeconds, setElapsedSeconds] = useState<
Record<number, number>
>({});
const STATUS_COLORS: Record<string, string> = useMemo(
() => ({
available: colors.success,
@@ -276,11 +282,21 @@ export default function DashboardScreen() {
setDeliveries(enriched);
// Enregistrer le timestamp d'arrivée pour les livraisons "arrived"
for (const d of enriched) {
if (
d.status === "arrived" ||
(d.status === "livre" && !arrivedAtRef.current[d.id])
) {
arrivedAtRef.current[d.id] = Date.now();
} else if (d.status !== "arrived" && d.status !== "livre") {
delete arrivedAtRef.current[d.id];
}
}
const activeDelivery =
enriched.find(
(d) =>
d.status === "en_route",
) || enriched.find((d) => d.status === "assigned");
enriched.find((d) => d.status === "en_route") ||
enriched.find((d) => d.status === "assigned");
if (activeDelivery && activeDelivery.adresse) {
calcRoute(activeDelivery.adresse);
}
@@ -292,7 +308,10 @@ export default function DashboardScreen() {
useEffect(() => {
loadData();
getLivreurTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
getLivreurTelegramStatus().then((s) => {
setTgLinked(s.linked);
setTgEnabled(s.enabled);
});
}, [loadData]);
useEffect(() => {
@@ -302,6 +321,18 @@ export default function DashboardScreen() {
return () => clearInterval(interval);
}, [loadData]);
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
const updated: Record<number, number> = {};
for (const [id, ts] of Object.entries(arrivedAtRef.current)) {
updated[Number(id)] = Math.floor((now - ts) / 1000);
}
setElapsedSeconds(updated);
}, 1000);
return () => clearInterval(interval);
}, []);
// Quand GPS devient disponible, rejouer la route en attente
useEffect(() => {
if (lastCoords && pendingRouteAddress.current) {
@@ -524,7 +555,12 @@ export default function DashboardScreen() {
cancelModal.description,
);
}
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" });
setCancelModal({
visible: false,
deliveryId: null,
issueType: null,
description: "",
});
if (res.success) {
showSuccess("Succès", "Livraison annulée");
loadData();
@@ -533,11 +569,36 @@ export default function DashboardScreen() {
}
};
const handleClientAbsent = async (deliveryId: number) => {
const lat = lastCoords?.lat || 0;
const lng = lastCoords?.lng || 0;
const res = await updateDeliveryStatus(
deliveryId,
"cancelled",
lat,
lng,
"Client absent",
);
if (res.success) {
await reportDeliveryIssue(
deliveryId,
"client_absent",
"Client non présent après attente",
);
delete arrivedAtRef.current[deliveryId];
showSuccess("Commande annulée", "Une amende a été appliquée au client");
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const openNavigation = async (deliveryId: number, address: string) => {
const res = await getDeliveryNavLink(deliveryId);
const link = res.success && res.waze_app
? res.waze_app
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
const link =
res.success && res.waze_app
? res.waze_app
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
Linking.openURL(link);
};
@@ -638,18 +699,32 @@ export default function DashboardScreen() {
Produits ({item.items.length})
</Text>
</View>
{item.items.some(p => p.is_reward) && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 6, padding: 6, marginBottom: 6 }}>
<Ionicons name="gift-outline" size={15} color="#f59e0b" />
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
Cette commande contient un article offert (récompense client)
</Text>
</View>
)}
{item.items.map((prod, idx) => (
<View key={idx} style={styles.itemRow}>
<View style={{ flex: 1 }}>
<Text style={styles.itemName}>
{prod.produit}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Text style={styles.itemName}>{prod.produit}</Text>
{prod.is_reward && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="gift-outline" size={10} color="#f59e0b" />
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
</View>
)}
</View>
<Text style={styles.itemQty}>
Quantité: {prod.quantite}
Quantité: {prod.quantite}{prod.unit || ""}
</Text>
</View>
<Text style={styles.itemPrice}>
{(prod.prix ?? 0).toFixed(2)}
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
</View>
))}
@@ -660,14 +735,34 @@ export default function DashboardScreen() {
</Text>
</View>
{(item.referral_used ?? 0) > 0 && (
<View style={[styles.totalRow, { marginTop: 2 }]}>
<Text style={[styles.totalLabel, { color: colors.success }]}>
Parrainage client
</Text>
<Text style={[styles.totalValue, { color: colors.success }]}>
-{(item.referral_used ?? 0).toFixed(2)}
</Text>
</View>
<>
<View style={[styles.totalRow, { marginTop: 2 }]}>
<Text
style={[
styles.totalLabel,
{ color: colors.success },
]}
>
Parrainage client
</Text>
<Text
style={[
styles.totalValue,
{ color: colors.success },
]}
>
-{(item.referral_used ?? 0).toFixed(2)}
</Text>
</View>
<View style={[styles.totalRow, { marginTop: 2 }]}>
<Text style={[styles.totalLabel, { fontWeight: "700" }]}>
Net à encaisser
</Text>
<Text style={[styles.totalValue, { fontWeight: "700" }]}>
{((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}
</Text>
</View>
</>
)}
</View>
)}
@@ -675,12 +770,28 @@ export default function DashboardScreen() {
{(!item.items || item.items.length === 0) && (
<>
<Text style={styles.priceOnly}>
{item.total_prix ?? 0}
{(item.referral_used ?? 0) > 0 ? "Brut : " : ""}
{(item.total_prix ?? 0)}
</Text>
{(item.referral_used ?? 0) > 0 && (
<Text style={[styles.priceOnly, { color: colors.success, marginTop: 2 }]}>
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}
</Text>
<>
<Text
style={[
styles.priceOnly,
{ color: colors.success, marginTop: 2 },
]}
>
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}
</Text>
<Text
style={[
styles.priceOnly,
{ fontWeight: "700", marginTop: 2 },
]}
>
Net à encaisser: {((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}
</Text>
</>
)}
</>
)}
@@ -735,27 +846,52 @@ export default function DashboardScreen() {
}}
/>
)}
{item.status === "arrived" && (
<View style={{ flexDirection: "row", gap: spacing.s, marginTop: spacing.s }}>
{item.status === "arrived" && (() => {
const elapsed = elapsedSeconds[item.id] || 0;
const remaining = Math.max(0, ABSENT_TIMEOUT_SECS - elapsed);
const showAbsent = elapsed >= ABSENT_TIMEOUT_SECS;
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
const ss = String(remaining % 60).padStart(2, "0");
return (
<View style={{ marginTop: spacing.s, gap: spacing.s }}>
<View style={{ flexDirection: "row", gap: spacing.s }}>
<Button
title="Terminer"
onPress={() => handleCompleteDelivery(item.id)}
style={{ flex: 1, backgroundColor: colors.accent }}
/>
<Button
title="Annuler"
onPress={() =>
setCancelModal({
visible: true,
deliveryId: item.id,
issueType: null,
description: "",
})
}
style={{ flex: 1, backgroundColor: colors.danger }}
/>
</View>
<Button
title="Terminer"
onPress={() => handleCompleteDelivery(item.id)}
style={{ flex: 1, backgroundColor: colors.accent }}
/>
<Button
title="Annuler"
onPress={() =>
setCancelModal({
visible: true,
deliveryId: item.id,
issueType: null,
description: "",
})
}
style={{ flex: 1, backgroundColor: colors.danger }}
title="Client pas là"
onPress={() => handleClientAbsent(item.id)}
style={{ backgroundColor: colors.warning }}
/>
{showAbsent ? (
<Button
title="Client absent (5 min écoulées)"
onPress={() => handleClientAbsent(item.id)}
style={{ backgroundColor: colors.danger }}
/>
) : (
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center" }}>
Client absent dans {mm}:{ss}
</Text>
)}
</View>
)}
);
})()}
</Card>
);
};
@@ -777,7 +913,10 @@ export default function DashboardScreen() {
const handleUnlinkTelegram = async () => {
await unlinkLivreurTelegram();
setTgLinked(false);
showSuccess("Telegram délié", "Vous ne recevrez plus de notifications Telegram.");
showSuccess(
"Telegram délié",
"Vous ne recevrez plus de notifications Telegram.",
);
};
const renderHeader = () => (
@@ -904,26 +1043,119 @@ export default function DashboardScreen() {
{/* Carte Telegram */}
{tgEnabled && (
<View style={{ marginHorizontal: spacing.l, marginBottom: spacing.m, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
<View
style={{
marginHorizontal: spacing.l,
marginBottom: spacing.m,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
}}
>
<Ionicons
name="paper-plane-outline"
size={18}
color="#2AABEE"
/>
<Text
style={{
color: colors.textPrimary,
fontSize: fontSize.md,
fontWeight: "600",
}}
>
Notifications Telegram
</Text>
</View>
{tgLinked ? (
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte lié</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
}}
>
<Ionicons
name="checkmark-circle"
size={14}
color={colors.success}
/>
<Text
style={{
color: colors.success,
fontSize: fontSize.sm,
}}
>
Compte lié
</Text>
</View>
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}>
<Ionicons name="unlink-outline" size={14} color={colors.danger} />
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier</Text>
<TouchableOpacity
onPress={handleUnlinkTelegram}
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
padding: spacing.s,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.danger + "66",
}}
>
<Ionicons
name="unlink-outline"
size={14}
color={colors.danger}
/>
<Text
style={{
color: colors.danger,
fontSize: fontSize.sm,
}}
>
Délier
</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}>
<Ionicons name="paper-plane-outline" size={14} color="#fff" />
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text>
<TouchableOpacity
onPress={handleLinkTelegram}
disabled={tgLoading}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
padding: spacing.m,
borderRadius: borderRadius.sm,
backgroundColor: "#2AABEE",
}}
>
<Ionicons
name="paper-plane-outline"
size={14}
color="#fff"
/>
<Text
style={{
color: "#fff",
fontSize: fontSize.sm,
fontWeight: "600",
}}
>
{tgLoading ? "Génération..." : "Lier Telegram"}
</Text>
</TouchableOpacity>
)}
</View>
@@ -1769,21 +2001,37 @@ export default function DashboardScreen() {
</Text>
{detailsDelivery?.items &&
detailsDelivery.items.length > 0 ? (
detailsDelivery.items.map((prod, idx) => (
<View key={idx} style={styles.detailProductRow}>
<View style={{ flex: 1 }}>
<Text style={styles.detailProductName}>
{prod.produit}
</Text>
<Text style={styles.detailProductQty}>
Quantité : {prod.quantite}
<>
{detailsDelivery.items.some(p => p.is_reward) && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 8, padding: 8, marginBottom: 8 }}>
<Ionicons name="gift-outline" size={16} color="#f59e0b" />
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
Cette commande contient un article offert (récompense client)
</Text>
</View>
<Text style={styles.detailProductPrice}>
{(prod.prix ?? 0).toFixed(2)}
</Text>
</View>
))
)}
{detailsDelivery.items.map((prod, idx) => (
<View key={idx} style={styles.detailProductRow}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Text style={styles.detailProductName}>{prod.produit}</Text>
{prod.is_reward && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="gift-outline" size={11} color="#f59e0b" />
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
</View>
)}
</View>
<Text style={styles.detailProductQty}>
Quantité : {prod.quantite}{prod.unit || ""}
</Text>
</View>
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
</View>
))}
</>
) : (
<Text style={styles.detailEmpty}>Aucun produit</Text>
)}
@@ -1796,14 +2044,34 @@ export default function DashboardScreen() {
</Text>
</View>
{(detailsDelivery?.referral_used ?? 0) > 0 && (
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text style={[styles.detailTotalLabel, { color: colors.success }]}>
Parrainage client
</Text>
<Text style={[styles.detailTotalValue, { color: colors.success }]}>
-{detailsDelivery?.referral_used?.toFixed(2)}
</Text>
</View>
<>
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text
style={[
styles.detailTotalLabel,
{ color: colors.success },
]}
>
Parrainage client
</Text>
<Text
style={[
styles.detailTotalValue,
{ color: colors.success },
]}
>
-{detailsDelivery?.referral_used?.toFixed(2)}
</Text>
</View>
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text style={[styles.detailTotalLabel, { fontWeight: "700" }]}>
Net à encaisser
</Text>
<Text style={[styles.detailTotalValue, { fontWeight: "700" }]}>
{((detailsDelivery?.total_prix ?? 0) - (detailsDelivery?.referral_used ?? 0)).toFixed(2)}
</Text>
</View>
</>
)}
</ScrollView>
</DetailsModal>
@@ -1873,31 +2141,71 @@ export default function DashboardScreen() {
<DetailsModal
visible={cancelModal.visible}
onClose={() =>
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" })
setCancelModal({
visible: false,
deliveryId: null,
issueType: null,
description: "",
})
}
title="Motif de non-livraison"
icon="close-circle-outline"
>
<Text style={[styles.cancelInput, { color: colors.textSecondary, fontSize: 13, marginBottom: spacing.s, backgroundColor: "transparent", borderWidth: 0, padding: 0 }]}>
<Text
style={[
styles.cancelInput,
{
color: colors.textSecondary,
fontSize: 13,
marginBottom: spacing.s,
backgroundColor: "transparent",
borderWidth: 0,
padding: 0,
},
]}
>
Sélectionnez un motif
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s, marginBottom: spacing.m }}>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.s,
marginBottom: spacing.m,
}}
>
{(Object.keys(ISSUE_LABELS) as IssueType[]).map((type) => {
const selected = cancelModal.issueType === type;
return (
<TouchableOpacity
key={type}
onPress={() => setCancelModal((prev) => ({ ...prev, issueType: type }))}
onPress={() =>
setCancelModal((prev) => ({
...prev,
issueType: type,
}))
}
style={{
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
borderRadius: 20,
borderWidth: 1,
borderColor: selected ? colors.danger : colors.border,
backgroundColor: selected ? colors.danger + "22" : colors.bgCard,
borderColor: selected
? colors.danger
: colors.border,
backgroundColor: selected
? colors.danger + "22"
: colors.bgCard,
}}
>
<Text style={{ color: selected ? colors.danger : colors.textSecondary, fontSize: 13 }}>
<Text
style={{
color: selected
? colors.danger
: colors.textSecondary,
fontSize: 13,
}}
>
{ISSUE_LABELS[type]}
</Text>
</TouchableOpacity>
@@ -1915,11 +2223,16 @@ export default function DashboardScreen() {
multiline
/>
<TouchableOpacity
style={[styles.cancelConfirmBtn, !cancelModal.issueType && { opacity: 0.4 }]}
style={[
styles.cancelConfirmBtn,
!cancelModal.issueType && { opacity: 0.4 },
]}
onPress={handleCancelDelivery}
disabled={!cancelModal.issueType}
>
<Text style={styles.cancelConfirmText}>Confirmer l'annulation</Text>
<Text style={styles.cancelConfirmText}>
Confirmer l'annulation
</Text>
</TouchableOpacity>
</DetailsModal>
@@ -5,34 +5,114 @@ import {
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getMyDeliveries, getMyStatus } from "../../api/api_delivery";
import type { DeliveryItem } from "../../api/types";
import { getMyDeliveries, getMyStats } from "../../api/api_delivery";
import type { DeliveryItem, } from "../../api/types";
import type { StatPoint } from "../../api/api_delivery";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
type Period = "day" | "week" | "month";
const BAR_MAX_HEIGHT = 110;
const BAR_WIDTH = 36;
const BAR_GAP = 8;
function BarChart({ data, colors }: { data: StatPoint[]; colors: any }) {
const maxVal = Math.max(...data.map((d) => d.count), 1);
if (data.length === 0) {
return (
<View style={{ alignItems: "center", paddingVertical: spacing.xl }}>
<Ionicons name="bar-chart-outline" size={36} color={colors.textMuted} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, marginTop: spacing.s }}>
Aucune donnée sur cette période
</Text>
</View>
);
}
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginTop: spacing.m }}>
<View style={{ flexDirection: "row", alignItems: "flex-end", paddingBottom: spacing.s, paddingHorizontal: 4 }}>
{data.map((point, i) => {
const val = point.count;
const barH = Math.max(4, (val / maxVal) * BAR_MAX_HEIGHT);
const isLast = i === data.length - 1;
return (
<View
key={i}
style={{
alignItems: "center",
marginRight: isLast ? 0 : BAR_GAP,
width: BAR_WIDTH,
}}
>
<Text style={{ color: colors.textMuted, fontSize: 9, marginBottom: 3 }}>
{val > 0 ? String(val) : ""}
</Text>
<View
style={{
width: BAR_WIDTH - 6,
height: barH,
backgroundColor: val > 0 ? colors.accent : colors.border,
borderRadius: 5,
opacity: val > 0 ? 1 : 0.3,
}}
/>
<Text
style={{
color: colors.textMuted,
fontSize: 9,
marginTop: 4,
textAlign: "center",
}}
numberOfLines={1}
>
{point.label}
</Text>
</View>
);
})}
</View>
</ScrollView>
);
}
export default function StatsScreen() {
const { colors } = useTheme();
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
const [byDay, setByDay] = useState<StatPoint[]>([]);
const [byWeek, setByWeek] = useState<StatPoint[]>([]);
const [byMonth, setByMonth] = useState<StatPoint[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [period, setPeriod] = useState<Period>("week");
const loadData = useCallback(async () => {
try {
const res = await getMyDeliveries();
if (res.success && res.deliveries) setDeliveries(res.deliveries);
const [delivRes, statsRes] = await Promise.all([
getMyDeliveries(),
getMyStats(),
]);
if (delivRes.success && delivRes.deliveries) setDeliveries(delivRes.deliveries);
if (statsRes.success) {
setByDay(statsRes.by_day ?? []);
setByWeek(statsRes.by_week ?? []);
setByMonth(statsRes.by_month ?? []);
}
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => { loadData(); }, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
@@ -40,39 +120,22 @@ export default function StatsScreen() {
};
const total = deliveries.length;
const completed = deliveries.filter((d) => d.status === "livre").length;
const completed = deliveries.filter((d) => d.status === "livre" || d.status === "approved").length;
const inProgress = deliveries.filter((d) => d.status === "en_route").length;
const pending = deliveries.filter((d) => d.status === "assigned").length;
const totalRevenue = deliveries
.filter((d) => d.status === "livre")
.reduce((s, d) => s + d.total_prix, 0);
const stats = [
{
label: "Total livraisons",
value: total.toString(),
icon: "cube-outline" as const,
color: colors.accent,
},
{
label: "Complétées",
value: completed.toString(),
icon: "checkmark-circle-outline" as const,
color: colors.success,
},
{
label: "En cours",
value: inProgress.toString(),
icon: "time-outline" as const,
color: colors.warning,
},
{
label: "En attente",
value: pending.toString(),
icon: "hourglass-outline" as const,
color: colors.info,
},
];
const chartData = period === "day" ? byDay : period === "week" ? byWeek : byMonth;
const periodTotal = useMemo(
() => chartData.reduce((acc, p) => acc + p.count, 0),
[chartData],
);
const periodLabels: Record<Period, string> = {
day: "30 derniers jours",
week: "12 dernières semaines",
month: "12 derniers mois",
};
const styles = useMemo(
() =>
@@ -84,57 +147,123 @@ export default function StatsScreen() {
fontWeight: "700",
marginBottom: spacing.l,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.m,
},
statCard: {
width: "47%",
alignItems: "center",
paddingVertical: spacing.l,
},
statValue: {
fontSize: fontSize.xxl,
fontWeight: "700",
marginTop: spacing.s,
},
grid: { flexDirection: "row", flexWrap: "wrap", gap: spacing.m },
statCard: { width: "47%", alignItems: "center", paddingVertical: spacing.l },
statValue: { fontSize: fontSize.xxl, fontWeight: "700", marginTop: spacing.s },
statLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.xs,
textAlign: "center",
},
sectionTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
marginBottom: spacing.m,
marginTop: spacing.xl,
},
periodRow: {
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.m,
},
periodBtn: {
flex: 1,
paddingVertical: spacing.s,
borderRadius: 8,
alignItems: "center",
backgroundColor: colors.bgCard,
borderWidth: 1,
borderColor: colors.border,
},
periodBtnActive: {
backgroundColor: colors.accent + "22",
borderColor: colors.accent,
},
periodBtnText: {
fontSize: fontSize.sm,
fontWeight: "600",
color: colors.textMuted,
},
periodBtnTextActive: { color: colors.accent },
chartCard: { paddingBottom: spacing.s },
summaryRow: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: spacing.s,
paddingTop: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.border,
},
summaryItem: { alignItems: "center", flex: 1 },
summaryValue: { color: colors.textWhite, fontSize: fontSize.lg, fontWeight: "700" },
summaryLabel: { color: colors.textMuted, fontSize: fontSize.xs, marginTop: 2 },
periodHint: { color: colors.textMuted, fontSize: fontSize.xs, marginBottom: spacing.s },
}),
[colors],
);
const summaryCards = [
{ label: "Total livraisons", value: total.toString(), icon: "cube-outline" as const, color: colors.accent },
{ label: "Complétées", value: completed.toString(), icon: "checkmark-circle-outline" as const, color: colors.success },
{ label: "En cours", value: inProgress.toString(), icon: "time-outline" as const, color: colors.warning },
{ label: "En attente", value: pending.toString(), icon: "hourglass-outline" as const, color: colors.info },
];
if (loading) return <LoadingSpinner message="Chargement stats..." />;
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ padding: spacing.l }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.success}
/>
}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.success} />}
>
<Text style={styles.title}>Mes performances</Text>
{/* Cartes résumé */}
<View style={styles.grid}>
{stats.map((s, i) => (
{summaryCards.map((s, i) => (
<Card key={i} style={styles.statCard}>
<Ionicons name={s.icon} size={28} color={s.color} />
<Text style={[styles.statValue, { color: s.color }]}>
{s.value}
</Text>
<Text style={[styles.statValue, { color: s.color }]}>{s.value}</Text>
<Text style={styles.statLabel}>{s.label}</Text>
</Card>
))}
</View>
{/* Section graphiques */}
<Text style={styles.sectionTitle}>Évolution</Text>
{/* Sélecteur période */}
<View style={styles.periodRow}>
{(["day", "week", "month"] as Period[]).map((p) => (
<TouchableOpacity
key={p}
style={[styles.periodBtn, period === p && styles.periodBtnActive]}
onPress={() => setPeriod(p)}
>
<Text style={[styles.periodBtnText, period === p && styles.periodBtnTextActive]}>
{p === "day" ? "Jour" : p === "week" ? "Semaine" : "Mois"}
</Text>
</TouchableOpacity>
))}
</View>
<Card style={styles.chartCard}>
<Text style={styles.periodHint}>{periodLabels[period]}</Text>
<BarChart data={chartData} colors={colors} />
{chartData.length > 0 && (
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryValue}>{periodTotal}</Text>
<Text style={styles.summaryLabel}>livraisons</Text>
</View>
</View>
)}
</Card>
</ScrollView>
);
}