chore: fix UI

This commit is contained in:
2026-04-26 18:23:50 +02:00
parent 322882d587
commit a120ad6391
11 changed files with 317 additions and 44 deletions
@@ -13,7 +13,6 @@ import (
func (d *Database) CleanupInvalidQueueCommands() (int, error) { func (d *Database) CleanupInvalidQueueCommands() (int, error) {
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...") 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() keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
if err != nil { if err != nil {
return 0, fmt.Errorf("erreur récupération des clés: %w", err) 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 validCount := 0
for _, key := range keys { for _, key := range keys {
// Récupérer les données
data, err := Redis.Get(RedisCtx, key).Result() data, err := Redis.Get(RedisCtx, key).Result()
if err != nil { if err != nil {
log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err) log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err)
+10
View File
@@ -81,6 +81,11 @@ func RegisterClient(c *gin.Context) {
return 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 // Validation téléphone
if !utils.ValidatePhoneNumber(req.Telephone) { if !utils.ValidatePhoneNumber(req.Telephone) {
log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone) log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone)
@@ -180,6 +185,11 @@ func AdminCreateClient(c *gin.Context) {
return 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) { if !utils.ValidatePhoneNumber(req.Telephone) {
log.Printf("❌ [ADMIN_CREATE_CLIENT] Téléphone invalide: %q", 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
+1 -1
View File
@@ -50,7 +50,7 @@ func IPNWebhook(c *gin.Context) {
payAmount, _ := payload.PayAmount.Float64() payAmount, _ := payload.PayAmount.Float64()
if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil { if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil {
log.Printf("[IPN] erreur mise à jour paiement %d: %v", payment.ID, err) 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 return
} }
+110 -1
View File
@@ -754,8 +754,26 @@ func ApplyClientPenalty(c *gin.Context) {
return 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É // ✅ APPLIQUER PÉNALITÉ
err := database.AddClientPenalty(req.Username, req.Points) err = database.AddClientPenalty(req.Username, req.Points)
if err != nil { if err != nil {
log.Printf("❌ [PENALTY] Erreur: %v", err) 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 // STATISTIQUES TEMPS RÉEL
// ============================================ // ============================================
+51 -15
View File
@@ -9,7 +9,6 @@ import (
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -33,7 +32,7 @@ func UpdateMyProfile(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err) log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides", "error": "Données invalides",
}) })
return return
} }
@@ -50,13 +49,21 @@ func UpdateMyProfile(c *gin.Context) {
hasChanges := false hasChanges := false
// Mise à jour du username // Mise à jour du username
if req.Username != "" {
req.Username = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != client.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 { if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Username déjà utilisé: %s", req.Username) 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é"}) c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return 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 client.Username = req.Username
hasChanges = true hasChanges = true
} }
@@ -78,22 +85,28 @@ func UpdateMyProfile(c *gin.Context) {
} }
// Mise à jour du nom // Mise à jour du nom
if req.Nom != "" {
req.Nom = utils.StripHTML(req.Nom)
}
if req.Nom != "" && req.Nom != client.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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
return return
} }
client.Nom = strings.TrimSpace(req.Nom) client.Nom = req.Nom
hasChanges = true hasChanges = true
} }
// Mise à jour du prénom // Mise à jour du prénom
if req.Prenom != "" {
req.Prenom = utils.StripHTML(req.Prenom)
}
if req.Prenom != "" && req.Prenom != client.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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
return return
} }
client.Prenom = strings.TrimSpace(req.Prenom) client.Prenom = req.Prenom
hasChanges = true hasChanges = true
} }
@@ -184,7 +197,7 @@ func UpdateClientByAdmin(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err) log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides", "error": "Données invalides",
}) })
return return
} }
@@ -208,6 +221,9 @@ func UpdateClientByAdmin(c *gin.Context) {
hasChanges := false hasChanges := false
// Mise à jour du username // Mise à jour du username
if req.Username != "" {
req.Username = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != client.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
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil { if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
@@ -238,15 +254,21 @@ func UpdateClientByAdmin(c *gin.Context) {
} }
// Mise à jour du nom // Mise à jour du nom
if req.Nom != "" {
req.Nom = utils.StripHTML(req.Nom)
}
if req.Nom != "" && req.Nom != client.Nom { if req.Nom != "" && req.Nom != client.Nom {
client.Nom = strings.TrimSpace(req.Nom) client.Nom = req.Nom
hasChanges = true hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom) log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
} }
// Mise à jour du prénom // Mise à jour du prénom
if req.Prenom != "" {
req.Prenom = utils.StripHTML(req.Prenom)
}
if req.Prenom != "" && req.Prenom != client.Prenom { if req.Prenom != "" && req.Prenom != client.Prenom {
client.Prenom = strings.TrimSpace(req.Prenom) client.Prenom = req.Prenom
hasChanges = true hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Prénom modifié: %s", req.Prenom) 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) log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Commandes modifiées: %d → %d", client.Command, *req.Command)
} }
if req.Amende != nil && *req.Amende != client.Amende { if req.Amende != nil {
client.Amende = *req.Amende if *req.Amende < 0 {
hasChanges = true c.JSON(http.StatusBadRequest, gin.H{"error": "L'amende ne peut pas être négative"})
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Amendes modifiées: %.2f → %.2f", client.Amende, *req.Amende) 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 { if !hasChanges {
@@ -353,13 +381,21 @@ func UpdateUserByAdmin(c *gin.Context) {
hasChanges := false hasChanges := false
// Mise à jour du username // Mise à jour du username
if req.Username != "" {
req.Username = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != user.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 { if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Username déjà utilisé: %s", req.Username) 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é"}) c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return 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 user.Username = req.Username
hasChanges = true hasChanges = true
} }
@@ -479,10 +479,8 @@ func RateLimitMiddleware(c *gin.Context) {
// LoginRateLimitMiddleware limite les tentatives de connexion par IP. // LoginRateLimitMiddleware limite les tentatives de connexion par IP.
// Config: 10 tentatives par 15 minutes. // Config: 10 tentatives par 15 minutes.
func LoginRateLimitMiddleware(c *gin.Context) { func LoginRateLimitMiddleware(c *gin.Context) {
ip := c.GetHeader("X-Real-IP")
if ip == "" { ip := c.ClientIP()
ip = c.ClientIP()
}
rateLimitKey := "ratelimit:login:" + ip rateLimitKey := "ratelimit:login:" + ip
+1
View File
@@ -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/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 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/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/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats) adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
+25
View File
@@ -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))
}
+19 -3
View File
@@ -564,14 +564,14 @@ export const applyClientPenalty = async (
username: string, username: string,
points: number, points: number,
reason: string, reason: string,
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; compensated?: boolean; error?: string }> => {
try { try {
await apiClient.post(`${V2}/admin/protected/penalty`, { const { data } = await apiClient.post(`${V2}/admin/protected/penalty`, {
username, username,
points, points,
reason, reason,
}); });
return { success: true }; return { success: true, compensated: data?.compensated === true };
} catch (e: any) { } catch (e: any) {
return { success: false, error: e.response?.data?.error || "Erreur" }; 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 // ALERTES
// ============================================ // ============================================
@@ -513,12 +513,13 @@ export default function ProductsScreen() {
backgroundColor: "rgba(0,0,0,0.85)", backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
modalWrapper: { maxHeight: "94%" }, modalWrapper: { maxHeight: "94%", flex: 1 },
modal: { modal: {
backgroundColor: colors.bgSecondary, backgroundColor: colors.bgSecondary,
borderTopLeftRadius: 24, borderTopLeftRadius: 24,
borderTopRightRadius: 24, borderTopRightRadius: 24,
maxHeight: "100%", maxHeight: "100%",
flex: 1,
}, },
modalHeader: { modalHeader: {
flexDirection: "row", flexDirection: "row",
@@ -538,6 +539,7 @@ export default function ProductsScreen() {
modalBody: { modalBody: {
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
paddingTop: spacing.l, paddingTop: spacing.l,
flex: 1,
}, },
modalFooter: { modalFooter: {
flexDirection: "row", flexDirection: "row",
@@ -30,6 +30,7 @@ import {
resetClientPenalties, resetClientPenalties,
resetClientPoints, resetClientPoints,
addClientPoints, addClientPoints,
subtractClientPoints,
} from "../../api/api_admin"; } from "../../api/api_admin";
import type { CancelledOrder } from "../../api/api_admin"; import type { CancelledOrder } from "../../api/api_admin";
import type { ClientResponse } from "../../api/types"; import type { ClientResponse } from "../../api/types";
@@ -197,6 +198,8 @@ export default function UsersScreen() {
const [amendeLoading, setAmendeLoading] = useState(false); const [amendeLoading, setAmendeLoading] = useState(false);
const [pointsInputs, setPointsInputs] = useState<Record<string, string>>({}); const [pointsInputs, setPointsInputs] = useState<Record<string, string>>({});
const [pointsLoading, setPointsLoading] = useState<Record<string, boolean>>({}); const [pointsLoading, setPointsLoading] = useState<Record<string, boolean>>({});
const [subtractInputs, setSubtractInputs] = useState<Record<string, string>>({});
const [subtractLoading, setSubtractLoading] = useState<Record<string, boolean>>({});
// Cancelled orders modal // Cancelled orders modal
const [cancelledModal, setCancelledModal] = useState<{ const [cancelledModal, setCancelledModal] = useState<{
@@ -206,7 +209,7 @@ export default function UsersScreen() {
loading: boolean; loading: boolean;
}>({ visible: false, username: "", orders: [], loading: false }); }>({ visible: false, username: "", orders: [], loading: false });
const { alert, showError, showConfirm, hideAlert } = useAlert(); const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert();
// -------------------------------------------------- // --------------------------------------------------
// Data // Data
@@ -455,11 +458,13 @@ export default function UsersScreen() {
setAmendeReason(""); setAmendeReason("");
setPointsInputs({}); setPointsInputs({});
setPointsLoading({}); setPointsLoading({});
setSubtractInputs({});
setSubtractLoading({});
setSanctionTab("amende"); setSanctionTab("amende");
setSanctionModal({ visible: true, client }); setSanctionModal({ visible: true, client });
}; };
const handleAddAmende = async () => { const handleAddAmende = () => {
const pts = parseInt(amendeAmount, 10); const pts = parseInt(amendeAmount, 10);
if (isNaN(pts) || pts <= 0) { if (isNaN(pts) || pts <= 0) {
showError("Erreur", "Entrez un montant valide"); showError("Erreur", "Entrez un montant valide");
@@ -470,13 +475,22 @@ export default function UsersScreen() {
return; return;
} }
if (!sanctionModal.client) return; if (!sanctionModal.client) return;
setAmendeLoading(true); showConfirm(
const res = await applyClientPenalty(sanctionModal.client.username, pts, amendeReason.trim()); "Confirmer l'amende",
setAmendeLoading(false); `Ajouter ${pts} € d'amende à "${sanctionModal.client.username}" ?\nRaison : ${amendeReason.trim()}`,
if (!res.success) { showError("Erreur", res.error || "Echec"); return; } async () => {
setAmendeAmount(""); setAmendeLoading(true);
setAmendeReason(""); const res = await applyClientPenalty(sanctionModal.client!.username, pts, amendeReason.trim());
await loadData(); 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 = () => { 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); const pts = parseInt(pointsInputs[poolKey] || "", 10);
if (isNaN(pts) || pts <= 0) { if (isNaN(pts) || pts <= 0) {
showError("Erreur", "Entrez un nombre de points valide"); showError("Erreur", "Entrez un nombre de points valide");
return; return;
} }
if (!sanctionModal.client) return; if (!sanctionModal.client) return;
setPointsLoading((prev) => ({ ...prev, [poolKey]: true })); showConfirm(
const res = await addClientPoints(sanctionModal.client.username, poolKey, pts); "Confirmer l'ajout",
setPointsLoading((prev) => ({ ...prev, [poolKey]: false })); `Ajouter ${pts} points "${poolName}" à "${sanctionModal.client.username}" ?`,
if (!res.success) { showError("Erreur", res.error || "Echec"); return; } async () => {
setPointsInputs((prev) => ({ ...prev, [poolKey]: "" })); setPointsLoading((prev) => ({ ...prev, [poolKey]: true }));
await loadData(); 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) => { const handleResetPoints = (poolKey: string, poolName: string, poolIdx: number) => {
@@ -1538,7 +1579,7 @@ export default function UsersScreen() {
/> />
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={() => handleAddPoints(key)} onPress={() => handleAddPoints(key, name)}
disabled={pointsLoading[key]} disabled={pointsLoading[key]}
style={{ style={{
backgroundColor: color + "20", backgroundColor: color + "20",
@@ -1562,6 +1603,43 @@ export default function UsersScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Input + bouton enlever */}
<View style={{ flexDirection: "row", gap: spacing.s, alignItems: "center" }}>
<View style={{ flex: 1 }}>
<TextInput
placeholder="Points à enlever"
value={subtractInputs[key] ?? ""}
onChangeText={(v) =>
setSubtractInputs((prev) => ({ ...prev, [key]: v }))
}
keyboardType="numeric"
/>
</View>
<TouchableOpacity
onPress={() => 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] ? (
<ActivityIndicator size="small" color={colors.danger} />
) : (
<Text style={{ color: colors.danger, fontWeight: "700", fontSize: fontSize.sm }}>
Enlever
</Text>
)}
</TouchableOpacity>
</View>
{/* Bouton reset */} {/* Bouton reset */}
<TouchableOpacity <TouchableOpacity
onPress={() => handleResetPoints(key, name, idx)} onPress={() => handleResetPoints(key, name, idx)}