chore: build
This commit is contained in:
@@ -563,6 +563,16 @@ func (db *Database) createTables() error {
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);`,
|
);`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`,
|
`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 {
|
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
|
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)
|
token, _ := generateAdminToken(user)
|
||||||
|
|
||||||
expiresAt := time.Now().Add(adminTokenDuration)
|
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.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
|
||||||
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
|
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
|
||||||
adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings)
|
adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings)
|
||||||
|
adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory)
|
||||||
// Commandes annulées
|
// Commandes annulées
|
||||||
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
|
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" => {
|
const parseStatus = (status: any): "available" | "busy" | "offline" => {
|
||||||
if (!status) return "offline";
|
if (!status) return "offline";
|
||||||
if (status === "available" || status === "busy" || status === "offline")
|
if (status === "available" || status === "busy" || status === "offline")
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ import {
|
|||||||
resetClientPoints,
|
resetClientPoints,
|
||||||
addClientPoints,
|
addClientPoints,
|
||||||
subtractClientPoints,
|
subtractClientPoints,
|
||||||
|
getLivreurLoginHistory,
|
||||||
} from "../../api/api_admin";
|
} 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 type { ClientResponse } from "../../api/types";
|
||||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
import Card from "../../components/ui/Card";
|
import Card from "../../components/ui/Card";
|
||||||
@@ -225,6 +226,23 @@ export default function UsersScreen() {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
}>({ visible: false, username: "", orders: [], loading: false });
|
}>({ 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 } =
|
const { alert, showError, showSuccess, showConfirm, hideAlert } =
|
||||||
useAlert();
|
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
|
// Create user
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
@@ -1150,6 +1215,18 @@ export default function UsersScreen() {
|
|||||||
</TouchableOpacity>
|
</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 === "livreur" ||
|
||||||
item.role === "cabine") && (
|
item.role === "cabine") && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -1799,6 +1876,153 @@ export default function UsersScreen() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</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 points & amendes */}
|
||||||
<Modal
|
<Modal
|
||||||
visible={sanctionModal.visible}
|
visible={sanctionModal.visible}
|
||||||
|
|||||||
Reference in New Issue
Block a user