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
+15 -3
View File
@@ -33,7 +33,7 @@ func GetMyDeliveries(c *gin.Context) {
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération",
"error": "Erreur récupération",
})
return
}
@@ -188,7 +188,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"error": "Données invalides",
})
return
}
@@ -258,7 +258,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
// Mettre à jour le statut
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
"error": "Erreur mise à jour",
})
return
}
@@ -269,6 +269,18 @@ func UpdateDeliveryStatus(c *gin.Context) {
cancelMsg = "Annulé par le livreur"
}
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
@@ -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);
};
@@ -661,10 +722,20 @@ export default function DashboardScreen() {
</View>
{(item.referral_used ?? 0) > 0 && (
<View style={[styles.totalRow, { marginTop: 2 }]}>
<Text style={[styles.totalLabel, { color: colors.success }]}>
<Text
style={[
styles.totalLabel,
{ color: colors.success },
]}
>
Parrainage client
</Text>
<Text style={[styles.totalValue, { color: colors.success }]}>
<Text
style={[
styles.totalValue,
{ color: colors.success },
]}
>
-{(item.referral_used ?? 0).toFixed(2)}
</Text>
</View>
@@ -678,8 +749,14 @@ export default function DashboardScreen() {
{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
style={[
styles.priceOnly,
{ color: colors.success, marginTop: 2 },
]}
>
Parrainage: -
{(item.referral_used ?? 0).toFixed(2)}
</Text>
)}
</>
@@ -735,27 +812,47 @@ export default function DashboardScreen() {
}}
/>
)}
{item.status === "arrived" && (
<View style={{ flexDirection: "row", gap: spacing.s, marginTop: 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 }}
/>
{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>
{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>
);
};
@@ -777,7 +874,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 +1004,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>
@@ -1797,10 +1990,20 @@ export default function DashboardScreen() {
</View>
{(detailsDelivery?.referral_used ?? 0) > 0 && (
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text style={[styles.detailTotalLabel, { color: colors.success }]}>
<Text
style={[
styles.detailTotalLabel,
{ color: colors.success },
]}
>
Parrainage client
</Text>
<Text style={[styles.detailTotalValue, { color: colors.success }]}>
<Text
style={[
styles.detailTotalValue,
{ color: colors.success },
]}
>
-{detailsDelivery?.referral_used?.toFixed(2)}
</Text>
</View>
@@ -1873,31 +2076,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 +2158,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>