From 7dc2686f6cd51fe3e716cb2a1eab35e5826a82d3 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sun, 3 May 2026 13:23:20 +0200 Subject: [PATCH] feat: add CSV export for approved orders && add non-delivery reason modal for livreurs --- backend/gestion/db/db_commands.go | 6 ++ backend/gestion/handlers/commands.go | 43 +++++++++++++ backend/gestion/handlers/deleviry.go | 58 +++++++++++++++++ backend/gestion/routes/routes.go | 4 +- frontend-admin/src/api/api_admin.ts | 7 ++ frontend-admin/src/api/api_delivery.ts | 31 +++++++++ .../src/screens/admin/OrdersScreen.tsx | 34 +++++++++- .../src/screens/delivery/DashboardScreen.tsx | 64 +++++++++++++++---- 8 files changed, 233 insertions(+), 14 deletions(-) diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index b1a6a336..cffe6017 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -265,6 +265,12 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (* 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) { if username != "" { if err := validateUsername(username); err != nil { diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go index 6b79218c..68063d03 100644 --- a/backend/gestion/handlers/commands.go +++ b/backend/gestion/handlers/commands.go @@ -1,6 +1,8 @@ package handlers import ( + "bytes" + "encoding/csv" "fmt" "gestion/db" "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) { database := c.MustGet("database").(*db.Database) diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index 7c598f96..69c70621 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -414,3 +414,61 @@ func UpdateDeliveryStatus(c *gin.Context) { 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}) +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index d4c3ba38..4f4f32fd 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -193,6 +193,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // COMMANDES - GESTION DE BASE // ============================================ adminGroupV2.GET("/orders", handlers.GetAllCommands) + adminGroupV2.GET("/orders/export/csv", handlers.ExportApprovedCommandsCSV) adminGroupV2.GET("/orders/:id", handlers.GetCommandByID) adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress) 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/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré 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 // ============================================ diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index c2ffbce3..cc6edba3 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -126,6 +126,13 @@ export const getCommandItems = async (commandId: number) => { }; }; +export const exportApprovedCommandsCSV = async (): Promise => { + const { data } = await apiClient.get(`${V2}/admin/protected/orders/export/csv`, { + responseType: "text", + }); + return data as string; +}; + export const deleteCommand = async (commandId: number) => { const { data } = await apiClient.delete( `${V2}/admin/protected/orders/${commandId}`, diff --git a/frontend-admin/src/api/api_delivery.ts b/frontend-admin/src/api/api_delivery.ts index 4001cbb0..7650f9ff 100644 --- a/frontend-admin/src/api/api_delivery.ts +++ b/frontend-admin/src/api/api_delivery.ts @@ -367,3 +367,34 @@ export const unlinkLivreurTelegram = async (): Promise => { /* ignore */ } }; + +export type IssueType = + | "client_absent" + | "wrong_address" + | "refused_delivery" + | "no_access" + | "other"; + +export const ISSUE_LABELS: Record = { + 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" }; + } +}; diff --git a/frontend-admin/src/screens/admin/OrdersScreen.tsx b/frontend-admin/src/screens/admin/OrdersScreen.tsx index 39820833..d11ce523 100644 --- a/frontend-admin/src/screens/admin/OrdersScreen.tsx +++ b/frontend-admin/src/screens/admin/OrdersScreen.tsx @@ -8,6 +8,7 @@ import { RefreshControl, ScrollView, TextInput, + Share, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useNavigation } from "@react-navigation/native"; @@ -25,6 +26,7 @@ import { deleteCommand, deleteCommandItem, proposeAddressChangeAdmin, + exportApprovedCommandsCSV, } from "../../api/api_admin"; import type { CommandResponse } from "../../api/types"; import type { AdminStackParamList } from "../../navigation/types"; @@ -73,6 +75,8 @@ export default function OrdersScreen() { clientInfo: null, }); + const [exporting, setExporting] = useState(false); + const loadData = useCallback(async () => { try { const result = await getAllCommands(undefined); @@ -83,6 +87,18 @@ export default function OrdersScreen() { 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(() => { loadData(); }, [loadData]); @@ -238,8 +254,10 @@ export default function OrdersScreen() { StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgPrimary }, refreshRow: { + flexDirection: "row", + gap: spacing.s, paddingBottom: spacing.m, - alignItems: "flex-start", + alignItems: "center", }, refreshBtn: { flexDirection: "row", @@ -604,6 +622,20 @@ export default function OrdersScreen() { /> Actualiser + + + + {exporting ? "Export..." : "Export CSV"} + + } ListEmptyComponent={ diff --git a/frontend-admin/src/screens/delivery/DashboardScreen.tsx b/frontend-admin/src/screens/delivery/DashboardScreen.tsx index 62c716af..e638a61a 100644 --- a/frontend-admin/src/screens/delivery/DashboardScreen.tsx +++ b/frontend-admin/src/screens/delivery/DashboardScreen.tsx @@ -39,7 +39,10 @@ import { generateLivreurLinkToken, unlinkLivreurTelegram, getDeliveryNavLink, + reportDeliveryIssue, + ISSUE_LABELS, } from "../../api/api_delivery"; +import type { IssueType } from "../../api/api_delivery"; import { geocodeAddress, calculateRoute } from "../../api/tomtom"; import type { RouteInfo } from "../../api/tomtom"; import type { DeliveryStatus, DeliveryItem, QueueInfo } from "../../api/types"; @@ -118,8 +121,9 @@ export default function DashboardScreen() { const [cancelModal, setCancelModal] = useState<{ visible: boolean; deliveryId: number | null; - reason: string; - }>({ visible: false, deliveryId: null, reason: "" }); + issueType: IssueType | null; + description: string; + }>({ visible: false, deliveryId: null, issueType: null, description: "" }); const STATUS_COLORS: Record = useMemo( () => ({ @@ -502,17 +506,25 @@ export default function DashboardScreen() { }; const handleCancelDelivery = async () => { - if (!cancelModal.deliveryId) return; + if (!cancelModal.deliveryId || !cancelModal.issueType) return; const lat = lastCoords?.lat || 0; const lng = lastCoords?.lng || 0; + const issueLabel = ISSUE_LABELS[cancelModal.issueType]; const res = await updateDeliveryStatus( cancelModal.deliveryId, "cancelled", lat, 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) { showSuccess("Succès", "Livraison annulée"); loadData(); @@ -736,7 +748,8 @@ export default function DashboardScreen() { setCancelModal({ visible: true, deliveryId: item.id, - reason: "", + issueType: null, + description: "", }) } style={{ flex: 1, backgroundColor: colors.danger }} @@ -1860,24 +1873,51 @@ export default function DashboardScreen() { - 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" > + + Sélectionnez un motif + + + {(Object.keys(ISSUE_LABELS) as IssueType[]).map((type) => { + const selected = cancelModal.issueType === type; + return ( + 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, + }} + > + + {ISSUE_LABELS[type]} + + + ); + })} + - setCancelModal((prev) => ({ ...prev, reason: t })) + setCancelModal((prev) => ({ ...prev, description: t })) } multiline /> Confirmer l'annulation