feat: add CSV export for approved orders && add non-delivery reason modal for livreurs
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user