From 74bf9cf14c861ac30df9c50f4622472c62bc60a5 Mon Sep 17 00:00:00 2001 From: Nuxgrid Date: Wed, 15 Jul 2026 20:55:46 +0200 Subject: [PATCH] chore: build --- backend/gestion/db/db_init.go | 10 + backend/gestion/db/db_login_history.go | 34 +++ backend/gestion/handlers/auth.go | 6 + .../gestion/handlers/livreur_login_history.go | 80 +++++++ backend/gestion/routes/routes.go | 1 + frontend-admin/src/api/api_admin.ts | 44 ++++ .../src/screens/admin/UsersScreen.tsx | 226 +++++++++++++++++- 7 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 backend/gestion/db/db_login_history.go create mode 100644 backend/gestion/handlers/livreur_login_history.go diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 980681fb..c3f0d7e6 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -563,6 +563,16 @@ func (db *Database) createTables() error { created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );`, `CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`, + + // ============================ + // TABLE login_history (livreur uniquement) + // ============================ + `CREATE TABLE IF NOT EXISTS login_history ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`, } for _, query := range queries { diff --git a/backend/gestion/db/db_login_history.go b/backend/gestion/db/db_login_history.go new file mode 100644 index 00000000..238c3483 --- /dev/null +++ b/backend/gestion/db/db_login_history.go @@ -0,0 +1,34 @@ +package db + +import ( + "time" +) + +type LoginHistoryEntry struct { + ID int `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +// RecordLivreurLogin enregistre une connexion réussie d'un livreur (best-effort, non bloquant). +func (d *Database) RecordLivreurLogin(username string) error { + return d.GDB.Exec(` + INSERT INTO login_history (username, created_at) + VALUES (?, NOW()) + `, username).Error +} + +// GetLivreurLoginHistoryByMonth retourne le détail des connexions d'un livreur pour un mois donné, +// triées du plus récent au plus ancien (max 50 entrées). +func (d *Database) GetLivreurLoginHistoryByMonth(username string, year, month int) ([]LoginHistoryEntry, error) { + var entries []LoginHistoryEntry + err := d.GDB.Raw(` + SELECT id, username, created_at FROM login_history + WHERE username = ? + AND EXTRACT(YEAR FROM created_at) = ? + AND EXTRACT(MONTH FROM created_at) = ? + ORDER BY created_at DESC + LIMIT 50 + `, username, year, month).Scan(&entries).Error + return entries, err +} diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index 4ffc960a..55833fa7 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -455,6 +455,12 @@ func LoginAdmin(c *gin.Context) { return } + if user.Role == "livreur" { + if err := database.RecordLivreurLogin(user.Username); err != nil { + log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err) + } + } + token, _ := generateAdminToken(user) expiresAt := time.Now().Add(adminTokenDuration) diff --git a/backend/gestion/handlers/livreur_login_history.go b/backend/gestion/handlers/livreur_login_history.go new file mode 100644 index 00000000..adaf26fb --- /dev/null +++ b/backend/gestion/handlers/livreur_login_history.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "gestion/db" + "gestion/utils" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type loginHistoryWeek struct { + Week int `json:"week"` + Entries []db.LoginHistoryEntry `json:"entries"` +} + +// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné, +// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur). +func GetLivreurLoginHistory(c *gin.Context) { + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"}) + return + } + + now := time.Now() + year := now.Year() + month := int(now.Month()) + + if y := c.Query("year"); y != "" { + parsed, err := strconv.Atoi(y) + if err != nil || parsed < 2000 || parsed > 2100 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"}) + return + } + year = parsed + } + if m := c.Query("month"); m != "" { + parsed, err := strconv.Atoi(m) + if err != nil || parsed < 1 || parsed > 12 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"}) + return + } + month = parsed + } + + database := c.MustGet("database").(*db.Database) + entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month) + if err != nil { + utils.ServerErr(c, "Erreur récupération historique de connexion", err) + return + } + + weekOrder := make([]int, 0) + weekMap := make(map[int]*loginHistoryWeek) + for _, e := range entries { + _, isoWeek := e.CreatedAt.ISOWeek() + w, ok := weekMap[isoWeek] + if !ok { + w = &loginHistoryWeek{Week: isoWeek} + weekMap[isoWeek] = w + weekOrder = append(weekOrder, isoWeek) + } + w.Entries = append(w.Entries, e) + } + + weeks := make([]*loginHistoryWeek, 0, len(weekOrder)) + for _, wk := range weekOrder { + weeks = append(weeks, weekMap[wk]) + } + + c.JSON(http.StatusOK, gin.H{ + "username": username, + "year": year, + "month": month, + "weeks": weeks, + "count": len(entries), + }) +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 79b7e799..9ca89c9b 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -278,6 +278,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue) adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks) adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings) + adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory) // Commandes annulées adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders) // ============================================ diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index bf078f78..3584518c 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -416,6 +416,50 @@ export const getLivreurRatings = async ( } }; +export interface LoginHistoryEntry { + id: number; + username: string; + created_at: string; +} +export interface LoginHistoryWeek { + week: number; + entries: LoginHistoryEntry[]; +} + +export const getLivreurLoginHistory = async ( + username: string, + year?: number, + month?: number, +): Promise<{ + success: boolean; + year: number; + month: number; + weeks: LoginHistoryWeek[]; + count: number; +}> => { + try { + const { data } = await apiClient.get( + `${V2}/admin/protected/delivery-persons/${username}/login-history`, + { params: { year, month } }, + ); + return { + success: true, + year: data.year, + month: data.month, + weeks: data.weeks || [], + count: data.count || 0, + }; + } catch { + return { + success: false, + year: year ?? new Date().getFullYear(), + month: month ?? new Date().getMonth() + 1, + weeks: [], + count: 0, + }; + } +}; + const parseStatus = (status: any): "available" | "busy" | "offline" => { if (!status) return "offline"; if (status === "available" || status === "busy" || status === "offline") diff --git a/frontend-admin/src/screens/admin/UsersScreen.tsx b/frontend-admin/src/screens/admin/UsersScreen.tsx index 1a4b4ef6..f753a4a7 100644 --- a/frontend-admin/src/screens/admin/UsersScreen.tsx +++ b/frontend-admin/src/screens/admin/UsersScreen.tsx @@ -34,8 +34,9 @@ import { resetClientPoints, addClientPoints, subtractClientPoints, + getLivreurLoginHistory, } from "../../api/api_admin"; -import type { CancelledOrder } from "../../api/api_admin"; +import type { CancelledOrder, LoginHistoryWeek } from "../../api/api_admin"; import type { ClientResponse } from "../../api/types"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; import Card from "../../components/ui/Card"; @@ -225,6 +226,23 @@ export default function UsersScreen() { loading: boolean; }>({ visible: false, username: "", orders: [], loading: false }); + // Login history modal (livreur) + const [loginHistoryModal, setLoginHistoryModal] = useState<{ + visible: boolean; + username: string; + year: number; + month: number; + weeks: LoginHistoryWeek[]; + loading: boolean; + }>({ + visible: false, + username: "", + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + weeks: [], + loading: false, + }); + const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert(); @@ -708,6 +726,53 @@ export default function UsersScreen() { })); }; + // -------------------------------------------------- + // Login history (livreur) + // -------------------------------------------------- + const fetchLoginHistory = async ( + username: string, + year: number, + month: number, + ) => { + setLoginHistoryModal((prev) => ({ ...prev, loading: true })); + const res = await getLivreurLoginHistory(username, year, month); + setLoginHistoryModal((prev) => ({ + ...prev, + year: res.year, + month: res.month, + weeks: res.weeks, + loading: false, + })); + }; + + const openLoginHistory = (username: string) => { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + setLoginHistoryModal({ + visible: true, + username, + year, + month, + weeks: [], + loading: true, + }); + fetchLoginHistory(username, year, month); + }; + + const changeLoginHistoryMonth = (delta: number) => { + let year = loginHistoryModal.year; + let month = loginHistoryModal.month + delta; + if (month < 1) { + month = 12; + year -= 1; + } else if (month > 12) { + month = 1; + year += 1; + } + fetchLoginHistory(loginHistoryModal.username, year, month); + }; + // -------------------------------------------------- // Create user // -------------------------------------------------- @@ -1150,6 +1215,18 @@ export default function UsersScreen() { )} + {item.role === "livreur" && ( + openLoginHistory(item.username)} + > + + + )} {(item.role === "livreur" || item.role === "cabine") && ( + {/* Modal historique de connexion (livreur) */} + + setLoginHistoryModal((prev) => ({ + ...prev, + visible: false, + })) + } + title={`Connexions — ${loginHistoryModal.username}`} + icon="time-outline" + iconColor={colors.accent} + > + + changeLoginHistoryMonth(-1)} + > + + + + {new Date( + loginHistoryModal.year, + loginHistoryModal.month - 1, + 1, + ) + .toLocaleDateString("fr-FR", { + month: "long", + year: "numeric", + }) + .replace(/^./, (c) => c.toUpperCase())} + + changeLoginHistoryMonth(1)} + > + + + + + {loginHistoryModal.loading ? ( + + ) : loginHistoryModal.weeks.length === 0 ? ( + + Aucune connexion ce mois-ci + + ) : ( + + {loginHistoryModal.weeks.map((week) => ( + + + Semaine {week.week} + + {week.entries.map((entry, idx) => ( + + + {new Date( + entry.created_at, + ).toLocaleDateString("fr-FR", { + weekday: "short", + day: "2-digit", + month: "2-digit", + })} + + + {new Date( + entry.created_at, + ).toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + })} + + + ))} + + ))} + + )} + + {/* Modal points & amendes */}