chore: build
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user