chore: build
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
// ============================================
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
{item.role === "livreur" && (
|
||||
<TouchableOpacity
|
||||
style={styles.editIconBtn}
|
||||
onPress={() => openLoginHistory(item.username)}
|
||||
>
|
||||
<Ionicons
|
||||
name="time-outline"
|
||||
size={20}
|
||||
color={colors.accent}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{(item.role === "livreur" ||
|
||||
item.role === "cabine") && (
|
||||
<TouchableOpacity
|
||||
@@ -1799,6 +1876,153 @@ export default function UsersScreen() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal historique de connexion (livreur) */}
|
||||
<Modal
|
||||
visible={loginHistoryModal.visible}
|
||||
onClose={() =>
|
||||
setLoginHistoryModal((prev) => ({
|
||||
...prev,
|
||||
visible: false,
|
||||
}))
|
||||
}
|
||||
title={`Connexions — ${loginHistoryModal.username}`}
|
||||
icon="time-outline"
|
||||
iconColor={colors.accent}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: spacing.m,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.editIconBtn}
|
||||
onPress={() => changeLoginHistoryMonth(-1)}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={20}
|
||||
color={colors.textWhite}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textWhite,
|
||||
fontWeight: "700",
|
||||
fontSize: fontSize.md,
|
||||
}}
|
||||
>
|
||||
{new Date(
|
||||
loginHistoryModal.year,
|
||||
loginHistoryModal.month - 1,
|
||||
1,
|
||||
)
|
||||
.toLocaleDateString("fr-FR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
.replace(/^./, (c) => c.toUpperCase())}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.editIconBtn}
|
||||
onPress={() => changeLoginHistoryMonth(1)}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={colors.textWhite}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{loginHistoryModal.loading ? (
|
||||
<ActivityIndicator
|
||||
color={colors.accent}
|
||||
style={{ marginVertical: spacing.xl }}
|
||||
/>
|
||||
) : loginHistoryModal.weeks.length === 0 ? (
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textMuted,
|
||||
textAlign: "center",
|
||||
paddingVertical: spacing.xl,
|
||||
}}
|
||||
>
|
||||
Aucune connexion ce mois-ci
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={{ maxHeight: 400 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{loginHistoryModal.weeks.map((week) => (
|
||||
<View
|
||||
key={week.week}
|
||||
style={{ marginBottom: spacing.m }}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.xs,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: spacing.xs,
|
||||
}}
|
||||
>
|
||||
Semaine {week.week}
|
||||
</Text>
|
||||
{week.entries.map((entry, idx) => (
|
||||
<View
|
||||
key={entry.id}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: spacing.xs,
|
||||
borderBottomWidth:
|
||||
idx <
|
||||
week.entries.length - 1
|
||||
? 1
|
||||
: 0,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textWhite,
|
||||
fontSize: fontSize.sm,
|
||||
}}
|
||||
>
|
||||
{new Date(
|
||||
entry.created_at,
|
||||
).toLocaleDateString("fr-FR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: colors.textMuted,
|
||||
fontSize: fontSize.sm,
|
||||
}}
|
||||
>
|
||||
{new Date(
|
||||
entry.created_at,
|
||||
).toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Modal points & amendes */}
|
||||
<Modal
|
||||
visible={sanctionModal.visible}
|
||||
|
||||
Reference in New Issue
Block a user