From a120ad6391ce51658c62a602f668acaed8b199bf Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sun, 26 Apr 2026 18:23:50 +0200 Subject: [PATCH] chore: fix UI --- backend/gestion/db/redis_queue_clean_up.go | 2 - backend/gestion/handlers/auth.go | 10 ++ backend/gestion/handlers/crypto_payment.go | 2 +- backend/gestion/handlers/redis_services.go | 111 ++++++++++++++++- backend/gestion/handlers/update_profile.go | 66 ++++++++--- .../gestion/middleware/session_middleware.go | 6 +- backend/gestion/routes/routes.go | 1 + backend/gestion/utils/sanitize.go | 25 ++++ frontend-admin/src/api/api_admin.ts | 22 +++- .../src/screens/admin/ProductsScreen.tsx | 4 +- .../src/screens/admin/UsersScreen.tsx | 112 +++++++++++++++--- 11 files changed, 317 insertions(+), 44 deletions(-) create mode 100644 backend/gestion/utils/sanitize.go diff --git a/backend/gestion/db/redis_queue_clean_up.go b/backend/gestion/db/redis_queue_clean_up.go index 17e70239..918c51fa 100644 --- a/backend/gestion/db/redis_queue_clean_up.go +++ b/backend/gestion/db/redis_queue_clean_up.go @@ -13,7 +13,6 @@ import ( func (d *Database) CleanupInvalidQueueCommands() (int, error) { log.Println("đŸ§č [CLEANUP] DĂ©marrage du nettoyage des commandes invalides...") - // RĂ©cupĂ©rer toutes les clĂ©s de commandes en attente keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result() if err != nil { return 0, fmt.Errorf("erreur rĂ©cupĂ©ration des clĂ©s: %w", err) @@ -23,7 +22,6 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) { validCount := 0 for _, key := range keys { - // RĂ©cupĂ©rer les donnĂ©es data, err := Redis.Get(RedisCtx, key).Result() if err != nil { log.Printf("⚠ [CLEANUP] Impossible de lire %s: %v", key, err) diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index c54d7cb8..e22c49c4 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -81,6 +81,11 @@ func RegisterClient(c *gin.Context) { return } + // Sanitize text inputs + req.Username = utils.StripHTML(req.Username) + req.Nom = utils.StripHTML(req.Nom) + req.Prenom = utils.StripHTML(req.Prenom) + // Validation tĂ©lĂ©phone if !utils.ValidatePhoneNumber(req.Telephone) { log.Printf("❌ [REGISTER_CLIENT] TĂ©lĂ©phone invalide: %s", req.Telephone) @@ -180,6 +185,11 @@ func AdminCreateClient(c *gin.Context) { return } + // Sanitize text inputs + req.Username = utils.StripHTML(req.Username) + req.Nom = utils.StripHTML(req.Nom) + req.Prenom = utils.StripHTML(req.Prenom) + if !utils.ValidatePhoneNumber(req.Telephone) { log.Printf("❌ [ADMIN_CREATE_CLIENT] TĂ©lĂ©phone invalide: %q", req.Telephone) c.JSON(http.StatusBadRequest, gin.H{"error": "NumĂ©ro de tĂ©lĂ©phone invalide"}) diff --git a/backend/gestion/handlers/crypto_payment.go b/backend/gestion/handlers/crypto_payment.go index 5f06d42a..765cfeea 100644 --- a/backend/gestion/handlers/crypto_payment.go +++ b/backend/gestion/handlers/crypto_payment.go @@ -50,7 +50,7 @@ func IPNWebhook(c *gin.Context) { payAmount, _ := payload.PayAmount.Float64() if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil { log.Printf("[IPN] erreur mise Ă  jour paiement %d: %v", payment.ID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de donnĂ©es"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "erreur base de donnĂ©es"}) return } diff --git a/backend/gestion/handlers/redis_services.go b/backend/gestion/handlers/redis_services.go index 99db8e82..687837c7 100644 --- a/backend/gestion/handlers/redis_services.go +++ b/backend/gestion/handlers/redis_services.go @@ -754,8 +754,26 @@ func ApplyClientPenalty(c *gin.Context) { return } + // VĂ©rifier si amende == solde parrainage → compensation automatique + referralBalance, err := database.GetClientReferralBalance(req.Username) + if err == nil && float64(req.Points) == referralBalance && referralBalance > 0 { + if err := database.DebitReferralBalance(req.Username, referralBalance); err != nil { + utils.ServerErr(c, "Erreur dĂ©bit solde parrainage", err) + return + } + log.Printf("🔄 [PENALTY] Compensation parrainage pour %s: amende %d€ annulĂ©e, solde parrainage %.2f€ dĂ©bitĂ© par %s", + req.Username, req.Points, referralBalance, adminUsername) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "username": req.Username, + "compensated": true, + "message": "Amende annulĂ©e — solde parrainage dĂ©bitĂ© en compensation", + }) + return + } + // ✅ APPLIQUER PÉNALITÉ - err := database.AddClientPenalty(req.Username, req.Points) + err = database.AddClientPenalty(req.Username, req.Points) if err != nil { log.Printf("❌ [PENALTY] Erreur: %v", err) @@ -1055,6 +1073,97 @@ func AddClientPointsAdmin(c *gin.Context) { }) } +// SubtractClientPointsAdmin retire des points Ă  un client (plancher Ă  0) +// POST /api/v2/admin/protected/client/:username/points/subtract +// Body: {"pool_key": "pool_0", "points": 10} +func SubtractClientPointsAdmin(c *gin.Context) { + userRole := c.GetString("role") + if userRole != "admin" && userRole != "cabine" { + c.JSON(http.StatusForbidden, gin.H{"error": "AccĂšs refusĂ©"}) + return + } + + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"}) + return + } + + var req struct { + PoolKey string `json:"pool_key" binding:"required"` + Points int `json:"points" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "DonnĂ©es invalides"}) + return + } + if req.Points <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Le nombre de points Ă  retirer doit ĂȘtre positif"}) + return + } + + database := c.MustGet("database").(*db.Database) + + settings, err := database.GetSettings() + if err != nil { + utils.ServerErr(c, "Erreur rĂ©cupĂ©ration paramĂštres", err) + return + } + poolExists := false + for _, pool := range settings.PointsPools { + if pool.Key == req.PoolKey { + poolExists = true + break + } + } + if !poolExists { + c.JSON(http.StatusBadRequest, gin.H{"error": "Pool de points invalide"}) + return + } + + // RĂ©cupĂ©rer le client pour vĂ©rifier le solde actuel + client, err := database.GetClientByUsername(username) + if err != nil || client == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvĂ©"}) + return + } + + current := client.PointsExtra[req.PoolKey] + + // Plancher Ă  0 + toSubtract := req.Points + if toSubtract > current { + toSubtract = current + } + + if toSubtract == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Aucun point Ă  retirer (solde dĂ©jĂ  Ă  0)", + "username": username, + "pool_key": req.PoolKey, + "points": 0, + }) + return + } + + if err := database.AddClientPointsByCategory(username, -toSubtract, req.PoolKey); err != nil { + utils.ServerErr(c, "Erreur retrait de points", err) + return + } + + log.Printf("✅ [SUBTRACT_POINTS] %d points (pool=%s) retirĂ©s Ă  %s par %s", + toSubtract, req.PoolKey, username, c.GetString("username")) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Points retirĂ©s avec succĂšs", + "username": username, + "pool_key": req.PoolKey, + "points": toSubtract, + }) +} + // ============================================ // STATISTIQUES TEMPS RÉEL // ============================================ diff --git a/backend/gestion/handlers/update_profile.go b/backend/gestion/handlers/update_profile.go index 400a8a72..76246373 100644 --- a/backend/gestion/handlers/update_profile.go +++ b/backend/gestion/handlers/update_profile.go @@ -9,7 +9,6 @@ import ( "log" "net/http" "strconv" - "strings" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -33,7 +32,7 @@ func UpdateMyProfile(c *gin.Context) { if err := c.ShouldBindJSON(&req); err != nil { log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err) c.JSON(http.StatusBadRequest, gin.H{ - "error": "DonnĂ©es invalides", + "error": "DonnĂ©es invalides", }) return } @@ -50,13 +49,21 @@ func UpdateMyProfile(c *gin.Context) { hasChanges := false // Mise Ă  jour du username + if req.Username != "" { + req.Username = utils.StripHTML(req.Username) + } if req.Username != "" && req.Username != client.Username { - // VĂ©rifier que le nouveau username n'existe pas + // VĂ©rifier que le nouveau username n'existe pas (clients et users) if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil { log.Printf("❌ [UPDATE_MY_PROFILE] Username dĂ©jĂ  utilisĂ©: %s", req.Username) c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur dĂ©jĂ  utilisĂ©"}) return } + if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil { + log.Printf("❌ [UPDATE_MY_PROFILE] Username rĂ©servĂ©: %s", req.Username) + c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur dĂ©jĂ  utilisĂ©"}) + return + } client.Username = req.Username hasChanges = true } @@ -78,22 +85,28 @@ func UpdateMyProfile(c *gin.Context) { } // Mise Ă  jour du nom + if req.Nom != "" { + req.Nom = utils.StripHTML(req.Nom) + } if req.Nom != "" && req.Nom != client.Nom { - if len(strings.TrimSpace(req.Nom)) < 2 { + if len(req.Nom) < 2 { c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractĂšres"}) return } - client.Nom = strings.TrimSpace(req.Nom) + client.Nom = req.Nom hasChanges = true } // Mise Ă  jour du prĂ©nom + if req.Prenom != "" { + req.Prenom = utils.StripHTML(req.Prenom) + } if req.Prenom != "" && req.Prenom != client.Prenom { - if len(strings.TrimSpace(req.Prenom)) < 2 { + if len(req.Prenom) < 2 { c.JSON(http.StatusBadRequest, gin.H{"error": "Le prĂ©nom doit contenir au moins 2 caractĂšres"}) return } - client.Prenom = strings.TrimSpace(req.Prenom) + client.Prenom = req.Prenom hasChanges = true } @@ -184,7 +197,7 @@ func UpdateClientByAdmin(c *gin.Context) { if err := c.ShouldBindJSON(&req); err != nil { log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err) c.JSON(http.StatusBadRequest, gin.H{ - "error": "DonnĂ©es invalides", + "error": "DonnĂ©es invalides", }) return } @@ -208,6 +221,9 @@ func UpdateClientByAdmin(c *gin.Context) { hasChanges := false // Mise Ă  jour du username + if req.Username != "" { + req.Username = utils.StripHTML(req.Username) + } if req.Username != "" && req.Username != client.Username { // VĂ©rifier que le nouveau username n'existe pas if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil { @@ -238,15 +254,21 @@ func UpdateClientByAdmin(c *gin.Context) { } // Mise Ă  jour du nom + if req.Nom != "" { + req.Nom = utils.StripHTML(req.Nom) + } if req.Nom != "" && req.Nom != client.Nom { - client.Nom = strings.TrimSpace(req.Nom) + client.Nom = req.Nom hasChanges = true log.Printf("✏ [UPDATE_CLIENT_ADMIN] Nom modifiĂ©: %s", req.Nom) } // Mise Ă  jour du prĂ©nom + if req.Prenom != "" { + req.Prenom = utils.StripHTML(req.Prenom) + } if req.Prenom != "" && req.Prenom != client.Prenom { - client.Prenom = strings.TrimSpace(req.Prenom) + client.Prenom = req.Prenom hasChanges = true log.Printf("✏ [UPDATE_CLIENT_ADMIN] PrĂ©nom modifiĂ©: %s", req.Prenom) } @@ -277,10 +299,16 @@ func UpdateClientByAdmin(c *gin.Context) { log.Printf("✏ [UPDATE_CLIENT_ADMIN] Commandes modifiĂ©es: %d → %d", client.Command, *req.Command) } - if req.Amende != nil && *req.Amende != client.Amende { - client.Amende = *req.Amende - hasChanges = true - log.Printf("✏ [UPDATE_CLIENT_ADMIN] Amendes modifiĂ©es: %.2f → %.2f", client.Amende, *req.Amende) + if req.Amende != nil { + if *req.Amende < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "L'amende ne peut pas ĂȘtre nĂ©gative"}) + return + } + if *req.Amende != client.Amende { + client.Amende = *req.Amende + hasChanges = true + log.Printf("✏ [UPDATE_CLIENT_ADMIN] Amendes modifiĂ©es: %.2f → %.2f", client.Amende, *req.Amende) + } } if !hasChanges { @@ -353,13 +381,21 @@ func UpdateUserByAdmin(c *gin.Context) { hasChanges := false // Mise Ă  jour du username + if req.Username != "" { + req.Username = utils.StripHTML(req.Username) + } if req.Username != "" && req.Username != user.Username { - // VĂ©rifier que le nouveau username n'existe pas + // VĂ©rifier que le nouveau username n'existe pas (users et clients) if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil { log.Printf("❌ [UPDATE_USER_ADMIN] Username dĂ©jĂ  utilisĂ©: %s", req.Username) c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur dĂ©jĂ  utilisĂ©"}) return } + if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil { + log.Printf("❌ [UPDATE_USER_ADMIN] Username rĂ©servĂ©: %s", req.Username) + c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur dĂ©jĂ  utilisĂ©"}) + return + } user.Username = req.Username hasChanges = true } diff --git a/backend/gestion/middleware/session_middleware.go b/backend/gestion/middleware/session_middleware.go index db921ba6..0fbc8685 100644 --- a/backend/gestion/middleware/session_middleware.go +++ b/backend/gestion/middleware/session_middleware.go @@ -479,10 +479,8 @@ func RateLimitMiddleware(c *gin.Context) { // LoginRateLimitMiddleware limite les tentatives de connexion par IP. // Config: 10 tentatives par 15 minutes. func LoginRateLimitMiddleware(c *gin.Context) { - ip := c.GetHeader("X-Real-IP") - if ip == "" { - ip = c.ClientIP() - } + + ip := c.ClientIP() rateLimitKey := "ratelimit:login:" + ip diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 8759eabc..ac0225a5 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -251,6 +251,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool + adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pĂ©nalitĂ©s adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats) diff --git a/backend/gestion/utils/sanitize.go b/backend/gestion/utils/sanitize.go new file mode 100644 index 00000000..d885795f --- /dev/null +++ b/backend/gestion/utils/sanitize.go @@ -0,0 +1,25 @@ +package utils + +import ( + "html" + "regexp" + "strings" +) + +var htmlTagRe = regexp.MustCompile(`<[^>]*>`) + +// StripHTML removes HTML tags and decodes HTML entities from s. +func StripHTML(s string) string { + stripped := htmlTagRe.ReplaceAllString(s, "") + decoded := html.UnescapeString(stripped) + return StripCRLF(decoded) +} + +func StripCRLF(s string) string { + return strings.TrimSpace(strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' { + return -1 + } + return r + }, s)) +} diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index a5efd21d..1f251987 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -564,14 +564,14 @@ export const applyClientPenalty = async ( username: string, points: number, reason: string, -): Promise<{ success: boolean; error?: string }> => { +): Promise<{ success: boolean; compensated?: boolean; error?: string }> => { try { - await apiClient.post(`${V2}/admin/protected/penalty`, { + const { data } = await apiClient.post(`${V2}/admin/protected/penalty`, { username, points, reason, }); - return { success: true }; + return { success: true, compensated: data?.compensated === true }; } catch (e: any) { return { success: false, error: e.response?.data?.error || "Erreur" }; } @@ -623,6 +623,22 @@ export const addClientPoints = async ( } }; +export const subtractClientPoints = async ( + username: string, + poolKey: string, + points: number, +): Promise<{ success: boolean; error?: string }> => { + try { + await apiClient.post( + `${V2}/admin/protected/client/${username}/points/subtract`, + { pool_key: poolKey, points }, + ); + return { success: true }; + } catch (e: any) { + return { success: false, error: e.response?.data?.error || "Erreur" }; + } +}; + // ============================================ // ALERTES // ============================================ diff --git a/frontend-admin/src/screens/admin/ProductsScreen.tsx b/frontend-admin/src/screens/admin/ProductsScreen.tsx index 3c419a19..f9a2944b 100644 --- a/frontend-admin/src/screens/admin/ProductsScreen.tsx +++ b/frontend-admin/src/screens/admin/ProductsScreen.tsx @@ -513,12 +513,13 @@ export default function ProductsScreen() { backgroundColor: "rgba(0,0,0,0.85)", justifyContent: "flex-end", }, - modalWrapper: { maxHeight: "94%" }, + modalWrapper: { maxHeight: "94%", flex: 1 }, modal: { backgroundColor: colors.bgSecondary, borderTopLeftRadius: 24, borderTopRightRadius: 24, maxHeight: "100%", + flex: 1, }, modalHeader: { flexDirection: "row", @@ -538,6 +539,7 @@ export default function ProductsScreen() { modalBody: { paddingHorizontal: spacing.xl, paddingTop: spacing.l, + flex: 1, }, modalFooter: { flexDirection: "row", diff --git a/frontend-admin/src/screens/admin/UsersScreen.tsx b/frontend-admin/src/screens/admin/UsersScreen.tsx index 0b5a2131..80221821 100644 --- a/frontend-admin/src/screens/admin/UsersScreen.tsx +++ b/frontend-admin/src/screens/admin/UsersScreen.tsx @@ -30,6 +30,7 @@ import { resetClientPenalties, resetClientPoints, addClientPoints, + subtractClientPoints, } from "../../api/api_admin"; import type { CancelledOrder } from "../../api/api_admin"; import type { ClientResponse } from "../../api/types"; @@ -197,6 +198,8 @@ export default function UsersScreen() { const [amendeLoading, setAmendeLoading] = useState(false); const [pointsInputs, setPointsInputs] = useState>({}); const [pointsLoading, setPointsLoading] = useState>({}); + const [subtractInputs, setSubtractInputs] = useState>({}); + const [subtractLoading, setSubtractLoading] = useState>({}); // Cancelled orders modal const [cancelledModal, setCancelledModal] = useState<{ @@ -206,7 +209,7 @@ export default function UsersScreen() { loading: boolean; }>({ visible: false, username: "", orders: [], loading: false }); - const { alert, showError, showConfirm, hideAlert } = useAlert(); + const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert(); // -------------------------------------------------- // Data @@ -455,11 +458,13 @@ export default function UsersScreen() { setAmendeReason(""); setPointsInputs({}); setPointsLoading({}); + setSubtractInputs({}); + setSubtractLoading({}); setSanctionTab("amende"); setSanctionModal({ visible: true, client }); }; - const handleAddAmende = async () => { + const handleAddAmende = () => { const pts = parseInt(amendeAmount, 10); if (isNaN(pts) || pts <= 0) { showError("Erreur", "Entrez un montant valide"); @@ -470,13 +475,22 @@ export default function UsersScreen() { return; } if (!sanctionModal.client) return; - setAmendeLoading(true); - const res = await applyClientPenalty(sanctionModal.client.username, pts, amendeReason.trim()); - setAmendeLoading(false); - if (!res.success) { showError("Erreur", res.error || "Echec"); return; } - setAmendeAmount(""); - setAmendeReason(""); - await loadData(); + showConfirm( + "Confirmer l'amende", + `Ajouter ${pts} € d'amende Ă  "${sanctionModal.client.username}" ?\nRaison : ${amendeReason.trim()}`, + async () => { + setAmendeLoading(true); + const res = await applyClientPenalty(sanctionModal.client!.username, pts, amendeReason.trim()); + setAmendeLoading(false); + if (!res.success) { showError("Erreur", res.error || "Echec"); return; } + setAmendeAmount(""); + setAmendeReason(""); + if (res.compensated) { + showSuccess("Compensation", "Amende annulĂ©e — solde parrainage dĂ©bitĂ© en compensation"); + } + await loadData(); + }, + ); }; const handleResetAmende = () => { @@ -493,19 +507,46 @@ export default function UsersScreen() { ); }; - const handleAddPoints = async (poolKey: string) => { + const handleAddPoints = (poolKey: string, poolName: string) => { const pts = parseInt(pointsInputs[poolKey] || "", 10); if (isNaN(pts) || pts <= 0) { showError("Erreur", "Entrez un nombre de points valide"); return; } if (!sanctionModal.client) return; - setPointsLoading((prev) => ({ ...prev, [poolKey]: true })); - const res = await addClientPoints(sanctionModal.client.username, poolKey, pts); - setPointsLoading((prev) => ({ ...prev, [poolKey]: false })); - if (!res.success) { showError("Erreur", res.error || "Echec"); return; } - setPointsInputs((prev) => ({ ...prev, [poolKey]: "" })); - await loadData(); + showConfirm( + "Confirmer l'ajout", + `Ajouter ${pts} points "${poolName}" Ă  "${sanctionModal.client.username}" ?`, + async () => { + setPointsLoading((prev) => ({ ...prev, [poolKey]: true })); + const res = await addClientPoints(sanctionModal.client!.username, poolKey, pts); + setPointsLoading((prev) => ({ ...prev, [poolKey]: false })); + if (!res.success) { showError("Erreur", res.error || "Echec"); return; } + setPointsInputs((prev) => ({ ...prev, [poolKey]: "" })); + await loadData(); + }, + ); + }; + + const handleSubtractPoints = (poolKey: string, poolName: string) => { + const pts = parseInt(subtractInputs[poolKey] || "", 10); + if (isNaN(pts) || pts <= 0) { + showError("Erreur", "Entrez un nombre de points valide"); + return; + } + if (!sanctionModal.client) return; + showConfirm( + "Confirmer le retrait", + `Retirer ${pts} points "${poolName}" Ă  "${sanctionModal.client.username}" ?`, + async () => { + setSubtractLoading((prev) => ({ ...prev, [poolKey]: true })); + const res = await subtractClientPoints(sanctionModal.client!.username, poolKey, pts); + setSubtractLoading((prev) => ({ ...prev, [poolKey]: false })); + if (!res.success) { showError("Erreur", res.error || "Echec"); return; } + setSubtractInputs((prev) => ({ ...prev, [poolKey]: "" })); + await loadData(); + }, + ); }; const handleResetPoints = (poolKey: string, poolName: string, poolIdx: number) => { @@ -1538,7 +1579,7 @@ export default function UsersScreen() { /> handleAddPoints(key)} + onPress={() => handleAddPoints(key, name)} disabled={pointsLoading[key]} style={{ backgroundColor: color + "20", @@ -1562,6 +1603,43 @@ export default function UsersScreen() { + {/* Input + bouton enlever */} + + + + setSubtractInputs((prev) => ({ ...prev, [key]: v })) + } + keyboardType="numeric" + /> + + handleSubtractPoints(key, name)} + disabled={subtractLoading[key]} + style={{ + backgroundColor: colors.danger + "15", + borderWidth: 1, + borderColor: colors.danger, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.m, + paddingVertical: spacing.m, + alignItems: "center", + justifyContent: "center", + minWidth: 72, + }} + > + {subtractLoading[key] ? ( + + ) : ( + + − Enlever + + )} + + + {/* Bouton reset */} handleResetPoints(key, name, idx)}