feat: add CSV export for approved orders && add non-delivery reason modal for livreurs

This commit is contained in:
2026-05-03 13:23:20 +02:00
parent a273484659
commit 7dc2686f6c
8 changed files with 233 additions and 14 deletions
+6
View File
@@ -265,6 +265,12 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
return command, nil return command, nil
} }
func (d *Database) GetApprovedCommands() ([]models.Command, error) {
var commands []models.Command
err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error
return commands, err
}
func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) { func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) {
if username != "" { if username != "" {
if err := validateUsername(username); err != nil { if err := validateUsername(username); err != nil {
+43
View File
@@ -1,6 +1,8 @@
package handlers package handlers
import ( import (
"bytes"
"encoding/csv"
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/utils" "gestion/utils"
@@ -259,6 +261,47 @@ func RespondToAddressProposal(c *gin.Context) {
}) })
} }
func ExportApprovedCommandsCSV(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
commands, err := database.GetApprovedCommands()
if err != nil {
log.Printf("❌ [EXPORT_CSV] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur export CSV"})
return
}
var buf bytes.Buffer
w := csv.NewWriter(&buf)
_ = w.Write([]string{
"ID", "Client Order ID", "User ID", "Username",
"Statut", "Total (€)", "Adresse", "Livreur",
"Créé le", "Mis à jour le",
})
for _, cmd := range commands {
_ = w.Write([]string{
strconv.Itoa(cmd.ID),
strconv.Itoa(cmd.ClientOrderID),
strconv.Itoa(cmd.UserID),
cmd.Username,
cmd.Status,
strconv.FormatFloat(cmd.Total, 'f', 2, 64),
cmd.DeliveryAddress,
cmd.LivreurAssign,
cmd.CreatedAt.Format("2006-01-02 15:04:05"),
cmd.UpdatedAt.Format("2006-01-02 15:04:05"),
})
}
w.Flush()
filename := fmt.Sprintf("commandes_approved_%s.csv", time.Now().Format("2006-01-02"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.String(http.StatusOK, buf.String())
}
func GetAllCommands(c *gin.Context) { func GetAllCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
+58
View File
@@ -414,3 +414,61 @@ func UpdateDeliveryStatus(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// POST /api/v1/livreur/deliveries/:id/issue
func ReportDeliveryIssue(c *gin.Context) {
username := c.GetString("username")
if c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
IssueType string `json:"issue_type" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "issue_type requis"})
return
}
validTypes := map[string]bool{
"client_absent": true,
"wrong_address": true,
"refused_delivery": true,
"no_access": true,
"other": true,
}
if !validTypes[req.IssueType] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de problème invalide"})
return
}
database := c.MustGet("database").(*db.Database)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"})
return
}
if livreur, _ := command["livreur_assign"].(string); livreur != username {
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non assignée à vous"})
return
}
issue, err := database.CreateDeliveryIssue(commandID, req.IssueType, req.Description, username)
if err != nil {
log.Printf("❌ [ISSUE] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création problème"})
return
}
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
}
+3 -1
View File
@@ -193,6 +193,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// COMMANDES - GESTION DE BASE // COMMANDES - GESTION DE BASE
// ============================================ // ============================================
adminGroupV2.GET("/orders", handlers.GetAllCommands) adminGroupV2.GET("/orders", handlers.GetAllCommands)
adminGroupV2.GET("/orders/export/csv", handlers.ExportApprovedCommandsCSV)
adminGroupV2.GET("/orders/:id", handlers.GetCommandByID) adminGroupV2.GET("/orders/:id", handlers.GetCommandByID)
adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress) adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress)
adminGroupV2.POST("/orders/:id/propose-address", handlers.ProposeAddressChange) adminGroupV2.POST("/orders/:id/propose-address", handlers.ProposeAddressChange)
@@ -336,7 +337,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
livreurGroupV1.GET("/deliveries", handlers.GetMyDeliveries) // ✅ Données filtrées livreurGroupV1.GET("/deliveries", handlers.GetMyDeliveries) // ✅ Données filtrées
livreurGroupV1.GET("/deliveries/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré livreurGroupV1.GET("/deliveries/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré
livreurGroupV1.POST("/deliveries/:id/start", handlers.StartDelivery) livreurGroupV1.POST("/deliveries/:id/start", handlers.StartDelivery)
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
livreurGroupV1.POST("/deliveries/:id/issue", handlers.ReportDeliveryIssue) // Motif non-livraison
livreurGroupV1.GET("/deliveries/:id/nav-link", handlers.GetLivreurNavLink) // Lien Waze App livreurGroupV1.GET("/deliveries/:id/nav-link", handlers.GetLivreurNavLink) // Lien Waze App
// ============================================ // ============================================
+7
View File
@@ -126,6 +126,13 @@ export const getCommandItems = async (commandId: number) => {
}; };
}; };
export const exportApprovedCommandsCSV = async (): Promise<string> => {
const { data } = await apiClient.get(`${V2}/admin/protected/orders/export/csv`, {
responseType: "text",
});
return data as string;
};
export const deleteCommand = async (commandId: number) => { export const deleteCommand = async (commandId: number) => {
const { data } = await apiClient.delete( const { data } = await apiClient.delete(
`${V2}/admin/protected/orders/${commandId}`, `${V2}/admin/protected/orders/${commandId}`,
+31
View File
@@ -367,3 +367,34 @@ export const unlinkLivreurTelegram = async (): Promise<void> => {
/* ignore */ /* ignore */
} }
}; };
export type IssueType =
| "client_absent"
| "wrong_address"
| "refused_delivery"
| "no_access"
| "other";
export const ISSUE_LABELS: Record<IssueType, string> = {
client_absent: "Client absent",
wrong_address: "Adresse incorrecte",
refused_delivery: "Livraison refusée",
no_access: "Accès impossible",
other: "Autre",
};
export const reportDeliveryIssue = async (
deliveryId: number,
issueType: IssueType,
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
await apiClient.post(`${API}/deliveries/${deliveryId}/issue`, {
issue_type: issueType,
description,
});
return { success: true };
} catch (e: any) {
return { success: false, error: e?.response?.data?.error || "Erreur" };
}
};
@@ -8,6 +8,7 @@ import {
RefreshControl, RefreshControl,
ScrollView, ScrollView,
TextInput, TextInput,
Share,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
@@ -25,6 +26,7 @@ import {
deleteCommand, deleteCommand,
deleteCommandItem, deleteCommandItem,
proposeAddressChangeAdmin, proposeAddressChangeAdmin,
exportApprovedCommandsCSV,
} from "../../api/api_admin"; } from "../../api/api_admin";
import type { CommandResponse } from "../../api/types"; import type { CommandResponse } from "../../api/types";
import type { AdminStackParamList } from "../../navigation/types"; import type { AdminStackParamList } from "../../navigation/types";
@@ -73,6 +75,8 @@ export default function OrdersScreen() {
clientInfo: null, clientInfo: null,
}); });
const [exporting, setExporting] = useState(false);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const result = await getAllCommands(undefined); const result = await getAllCommands(undefined);
@@ -83,6 +87,18 @@ export default function OrdersScreen() {
setLoading(false); setLoading(false);
}, []); }, []);
const handleExportCSV = async () => {
setExporting(true);
try {
const csv = await exportApprovedCommandsCSV();
await Share.share({ message: csv, title: "Commandes approuvées" });
} catch {
showError("Erreur lors de l'export CSV");
} finally {
setExporting(false);
}
};
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [loadData]); }, [loadData]);
@@ -238,8 +254,10 @@ export default function OrdersScreen() {
StyleSheet.create({ StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary }, container: { flex: 1, backgroundColor: colors.bgPrimary },
refreshRow: { refreshRow: {
flexDirection: "row",
gap: spacing.s,
paddingBottom: spacing.m, paddingBottom: spacing.m,
alignItems: "flex-start", alignItems: "center",
}, },
refreshBtn: { refreshBtn: {
flexDirection: "row", flexDirection: "row",
@@ -604,6 +622,20 @@ export default function OrdersScreen() {
/> />
<Text style={styles.refreshBtnText}>Actualiser</Text> <Text style={styles.refreshBtnText}>Actualiser</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={styles.refreshBtn}
onPress={handleExportCSV}
disabled={exporting}
>
<Ionicons
name="download-outline"
size={16}
color={exporting ? colors.textMuted : colors.accent}
/>
<Text style={styles.refreshBtnText}>
{exporting ? "Export..." : "Export CSV"}
</Text>
</TouchableOpacity>
</View> </View>
} }
ListEmptyComponent={ ListEmptyComponent={
@@ -39,7 +39,10 @@ import {
generateLivreurLinkToken, generateLivreurLinkToken,
unlinkLivreurTelegram, unlinkLivreurTelegram,
getDeliveryNavLink, getDeliveryNavLink,
reportDeliveryIssue,
ISSUE_LABELS,
} from "../../api/api_delivery"; } from "../../api/api_delivery";
import type { IssueType } from "../../api/api_delivery";
import { geocodeAddress, calculateRoute } from "../../api/tomtom"; import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import type { RouteInfo } from "../../api/tomtom"; import type { RouteInfo } from "../../api/tomtom";
import type { DeliveryStatus, DeliveryItem, QueueInfo } from "../../api/types"; import type { DeliveryStatus, DeliveryItem, QueueInfo } from "../../api/types";
@@ -118,8 +121,9 @@ export default function DashboardScreen() {
const [cancelModal, setCancelModal] = useState<{ const [cancelModal, setCancelModal] = useState<{
visible: boolean; visible: boolean;
deliveryId: number | null; deliveryId: number | null;
reason: string; issueType: IssueType | null;
}>({ visible: false, deliveryId: null, reason: "" }); description: string;
}>({ visible: false, deliveryId: null, issueType: null, description: "" });
const STATUS_COLORS: Record<string, string> = useMemo( const STATUS_COLORS: Record<string, string> = useMemo(
() => ({ () => ({
@@ -502,17 +506,25 @@ export default function DashboardScreen() {
}; };
const handleCancelDelivery = async () => { const handleCancelDelivery = async () => {
if (!cancelModal.deliveryId) return; if (!cancelModal.deliveryId || !cancelModal.issueType) return;
const lat = lastCoords?.lat || 0; const lat = lastCoords?.lat || 0;
const lng = lastCoords?.lng || 0; const lng = lastCoords?.lng || 0;
const issueLabel = ISSUE_LABELS[cancelModal.issueType];
const res = await updateDeliveryStatus( const res = await updateDeliveryStatus(
cancelModal.deliveryId, cancelModal.deliveryId,
"cancelled", "cancelled",
lat, lat,
lng, lng,
cancelModal.reason || "Client absent", cancelModal.description || issueLabel,
); );
setCancelModal({ visible: false, deliveryId: null, reason: "" }); if (res.success) {
await reportDeliveryIssue(
cancelModal.deliveryId,
cancelModal.issueType,
cancelModal.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();
@@ -736,7 +748,8 @@ export default function DashboardScreen() {
setCancelModal({ setCancelModal({
visible: true, visible: true,
deliveryId: item.id, deliveryId: item.id,
reason: "", issueType: null,
description: "",
}) })
} }
style={{ flex: 1, backgroundColor: colors.danger }} style={{ flex: 1, backgroundColor: colors.danger }}
@@ -1860,24 +1873,51 @@ export default function DashboardScreen() {
<DetailsModal <DetailsModal
visible={cancelModal.visible} visible={cancelModal.visible}
onClose={() => onClose={() =>
setCancelModal({ visible: false, deliveryId: null, reason: "" }) setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" })
} }
title="Annuler la 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 }]}>
Sélectionnez un motif
</Text>
<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 }))}
style={{
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
borderRadius: 20,
borderWidth: 1,
borderColor: selected ? colors.danger : colors.border,
backgroundColor: selected ? colors.danger + "22" : colors.bgCard,
}}
>
<Text style={{ color: selected ? colors.danger : colors.textSecondary, fontSize: 13 }}>
{ISSUE_LABELS[type]}
</Text>
</TouchableOpacity>
);
})}
</View>
<TextInput <TextInput
style={styles.cancelInput} style={styles.cancelInput}
placeholder="Motif (ex: client absent)..." placeholder="Précisions (facultatif)..."
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={cancelModal.reason} value={cancelModal.description}
onChangeText={(t) => onChangeText={(t) =>
setCancelModal((prev) => ({ ...prev, reason: t })) setCancelModal((prev) => ({ ...prev, description: t }))
} }
multiline multiline
/> />
<TouchableOpacity <TouchableOpacity
style={styles.cancelConfirmBtn} style={[styles.cancelConfirmBtn, !cancelModal.issueType && { opacity: 0.4 }]}
onPress={handleCancelDelivery} onPress={handleCancelDelivery}
disabled={!cancelModal.issueType}
> >
<Text style={styles.cancelConfirmText}>Confirmer l'annulation</Text> <Text style={styles.cancelConfirmText}>Confirmer l'annulation</Text>
</TouchableOpacity> </TouchableOpacity>