feat: chrono and add penality

This commit is contained in:
2026-05-15 19:13:13 +02:00
parent 7443d06029
commit e09bbb05b9
2 changed files with 324 additions and 64 deletions
+12
View File
@@ -269,6 +269,18 @@ func UpdateDeliveryStatus(c *gin.Context) {
cancelMsg = "Annulé par le livreur" cancelMsg = "Annulé par le livreur"
} }
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg)) database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
prevStatus, _ := command["status"].(string)
if prevStatus == "arrived" || prevStatus == "livre" {
clientUsername, _ := command["username"].(string)
if clientUsername != "" {
if penalty, err := database.ApplyCancellationPenalty(clientUsername); err == nil {
log.Printf("⚠️ [CANCEL_LIVREUR] Amende %d appliquée à %s (client absent)", penalty, clientUsername)
} else {
log.Printf("⚠️ [CANCEL_LIVREUR] Erreur application amende pour %s: %v", clientUsername, err)
}
}
}
} }
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA // ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
@@ -125,6 +125,12 @@ export default function DashboardScreen() {
description: string; description: string;
}>({ visible: false, deliveryId: null, issueType: null, description: "" }); }>({ 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( const STATUS_COLORS: Record<string, string> = useMemo(
() => ({ () => ({
available: colors.success, available: colors.success,
@@ -276,11 +282,21 @@ export default function DashboardScreen() {
setDeliveries(enriched); 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 = const activeDelivery =
enriched.find( enriched.find((d) => d.status === "en_route") ||
(d) => enriched.find((d) => d.status === "assigned");
d.status === "en_route",
) || enriched.find((d) => d.status === "assigned");
if (activeDelivery && activeDelivery.adresse) { if (activeDelivery && activeDelivery.adresse) {
calcRoute(activeDelivery.adresse); calcRoute(activeDelivery.adresse);
} }
@@ -292,7 +308,10 @@ export default function DashboardScreen() {
useEffect(() => { useEffect(() => {
loadData(); loadData();
getLivreurTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); }); getLivreurTelegramStatus().then((s) => {
setTgLinked(s.linked);
setTgEnabled(s.enabled);
});
}, [loadData]); }, [loadData]);
useEffect(() => { useEffect(() => {
@@ -302,6 +321,18 @@ export default function DashboardScreen() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadData]); }, [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 // Quand GPS devient disponible, rejouer la route en attente
useEffect(() => { useEffect(() => {
if (lastCoords && pendingRouteAddress.current) { if (lastCoords && pendingRouteAddress.current) {
@@ -524,7 +555,12 @@ export default function DashboardScreen() {
cancelModal.description, cancelModal.description,
); );
} }
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" }); setCancelModal({
visible: false,
deliveryId: null,
issueType: null,
description: "",
});
if (res.success) { if (res.success) {
showSuccess("Succès", "Livraison annulée"); showSuccess("Succès", "Livraison annulée");
loadData(); loadData();
@@ -533,9 +569,34 @@ 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 openNavigation = async (deliveryId: number, address: string) => {
const res = await getDeliveryNavLink(deliveryId); const res = await getDeliveryNavLink(deliveryId);
const link = res.success && res.waze_app const link =
res.success && res.waze_app
? res.waze_app ? res.waze_app
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`; : `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
Linking.openURL(link); Linking.openURL(link);
@@ -661,10 +722,20 @@ export default function DashboardScreen() {
</View> </View>
{(item.referral_used ?? 0) > 0 && ( {(item.referral_used ?? 0) > 0 && (
<View style={[styles.totalRow, { marginTop: 2 }]}> <View style={[styles.totalRow, { marginTop: 2 }]}>
<Text style={[styles.totalLabel, { color: colors.success }]}> <Text
style={[
styles.totalLabel,
{ color: colors.success },
]}
>
Parrainage client Parrainage client
</Text> </Text>
<Text style={[styles.totalValue, { color: colors.success }]}> <Text
style={[
styles.totalValue,
{ color: colors.success },
]}
>
-{(item.referral_used ?? 0).toFixed(2)} -{(item.referral_used ?? 0).toFixed(2)}
</Text> </Text>
</View> </View>
@@ -678,8 +749,14 @@ export default function DashboardScreen() {
{item.total_prix ?? 0} {item.total_prix ?? 0}
</Text> </Text>
{(item.referral_used ?? 0) > 0 && ( {(item.referral_used ?? 0) > 0 && (
<Text style={[styles.priceOnly, { color: colors.success, marginTop: 2 }]}> <Text
Parrainage: -{(item.referral_used ?? 0).toFixed(2)} style={[
styles.priceOnly,
{ color: colors.success, marginTop: 2 },
]}
>
Parrainage: -
{(item.referral_used ?? 0).toFixed(2)}
</Text> </Text>
)} )}
</> </>
@@ -735,8 +812,15 @@ export default function DashboardScreen() {
}} }}
/> />
)} )}
{item.status === "arrived" && ( {item.status === "arrived" && (() => {
<View style={{ flexDirection: "row", gap: spacing.s, marginTop: spacing.s }}> 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 <Button
title="Terminer" title="Terminer"
onPress={() => handleCompleteDelivery(item.id)} onPress={() => handleCompleteDelivery(item.id)}
@@ -755,7 +839,20 @@ export default function DashboardScreen() {
style={{ flex: 1, backgroundColor: colors.danger }} style={{ flex: 1, backgroundColor: colors.danger }}
/> />
</View> </View>
{showAbsent ? (
<Button
title="Client absent"
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> </Card>
); );
}; };
@@ -777,7 +874,10 @@ export default function DashboardScreen() {
const handleUnlinkTelegram = async () => { const handleUnlinkTelegram = async () => {
await unlinkLivreurTelegram(); await unlinkLivreurTelegram();
setTgLinked(false); 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 = () => ( const renderHeader = () => (
@@ -904,26 +1004,119 @@ export default function DashboardScreen() {
{/* Carte Telegram */} {/* Carte Telegram */}
{tgEnabled && ( {tgEnabled && (
<View style={{ marginHorizontal: spacing.l, marginBottom: spacing.m, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}> <View
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}> style={{
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" /> marginHorizontal: spacing.l,
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text> 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> </View>
{tgLinked ? ( {tgLinked ? (
<View> <View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}> <View
<Ionicons name="checkmark-circle" size={14} color={colors.success} /> style={{
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte lié</Text> 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> </View>
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}> <TouchableOpacity
<Ionicons name="unlink-outline" size={14} color={colors.danger} /> onPress={handleUnlinkTelegram}
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier</Text> 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> </TouchableOpacity>
</View> </View>
) : ( ) : (
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}> <TouchableOpacity
<Ionicons name="paper-plane-outline" size={14} color="#fff" /> onPress={handleLinkTelegram}
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text> 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> </TouchableOpacity>
)} )}
</View> </View>
@@ -1797,10 +1990,20 @@ export default function DashboardScreen() {
</View> </View>
{(detailsDelivery?.referral_used ?? 0) > 0 && ( {(detailsDelivery?.referral_used ?? 0) > 0 && (
<View style={[styles.detailTotalRow, { marginTop: 4 }]}> <View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text style={[styles.detailTotalLabel, { color: colors.success }]}> <Text
style={[
styles.detailTotalLabel,
{ color: colors.success },
]}
>
Parrainage client Parrainage client
</Text> </Text>
<Text style={[styles.detailTotalValue, { color: colors.success }]}> <Text
style={[
styles.detailTotalValue,
{ color: colors.success },
]}
>
-{detailsDelivery?.referral_used?.toFixed(2)} -{detailsDelivery?.referral_used?.toFixed(2)}
</Text> </Text>
</View> </View>
@@ -1873,31 +2076,71 @@ export default function DashboardScreen() {
<DetailsModal <DetailsModal
visible={cancelModal.visible} visible={cancelModal.visible}
onClose={() => onClose={() =>
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" }) setCancelModal({
visible: false,
deliveryId: null,
issueType: null,
description: "",
})
} }
title="Motif de non-livraison" title="Motif de non-livraison"
icon="close-circle-outline" 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 Sélectionnez un motif
</Text> </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) => { {(Object.keys(ISSUE_LABELS) as IssueType[]).map((type) => {
const selected = cancelModal.issueType === type; const selected = cancelModal.issueType === type;
return ( return (
<TouchableOpacity <TouchableOpacity
key={type} key={type}
onPress={() => setCancelModal((prev) => ({ ...prev, issueType: type }))} onPress={() =>
setCancelModal((prev) => ({
...prev,
issueType: type,
}))
}
style={{ style={{
paddingHorizontal: spacing.m, paddingHorizontal: spacing.m,
paddingVertical: spacing.s, paddingVertical: spacing.s,
borderRadius: 20, borderRadius: 20,
borderWidth: 1, borderWidth: 1,
borderColor: selected ? colors.danger : colors.border, borderColor: selected
backgroundColor: selected ? colors.danger + "22" : colors.bgCard, ? 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]} {ISSUE_LABELS[type]}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -1915,11 +2158,16 @@ export default function DashboardScreen() {
multiline multiline
/> />
<TouchableOpacity <TouchableOpacity
style={[styles.cancelConfirmBtn, !cancelModal.issueType && { opacity: 0.4 }]} style={[
styles.cancelConfirmBtn,
!cancelModal.issueType && { opacity: 0.4 },
]}
onPress={handleCancelDelivery} onPress={handleCancelDelivery}
disabled={!cancelModal.issueType} disabled={!cancelModal.issueType}
> >
<Text style={styles.cancelConfirmText}>Confirmer l'annulation</Text> <Text style={styles.cancelConfirmText}>
Confirmer l'annulation
</Text>
</TouchableOpacity> </TouchableOpacity>
</DetailsModal> </DetailsModal>