add achats import and hardware analyse
This commit is contained in:
34
TODO.md
34
TODO.md
@@ -3,29 +3,29 @@ en suivant consigne: MatosBox Documentation.md
|
|||||||
|
|
||||||
|
|
||||||
## Priorite haute (MVP)
|
## Priorite haute (MVP)
|
||||||
- [ ] Creer schemas Ent en francais : Objet, Categorie, Emplacement.
|
- [x] Creer schemas Ent en francais : Objet, Categorie, Emplacement.
|
||||||
- [ ] Ajouter migrations initiales (Goose ou Atlas).
|
- [x] Ajouter migrations initiales (Goose ou Atlas).
|
||||||
- [ ] CRUD API Objets/Categories/Emplacements (Gin).
|
- [x] CRUD API Objets/Categories/Emplacements (Gin).
|
||||||
- [ ] Endpoint upload multiple `POST /v1/objets/{id}/pieces_jointes`.
|
- [x] Endpoint upload multiple `POST /v1/objets/{id}/pieces_jointes`.
|
||||||
- [ ] Stockage pieces jointes + table PieceJointe.
|
- [x] Stockage pieces jointes + table PieceJointe.
|
||||||
- [ ] Activer WAL SQLite.
|
- [x] Activer WAL SQLite.
|
||||||
- [ ] Base frontend Nuxt + pages principales.
|
- [x] Base frontend Nuxt + pages principales.
|
||||||
|
|
||||||
## Priorite moyenne
|
## Priorite moyenne
|
||||||
- [ ] Swagger (Swaggo) pour la doc API.
|
- [x] Swagger (Swaggo) pour la doc API.
|
||||||
- [ ] Taskfile pour build/test/migrate.
|
- [x] Taskfile pour build/test/migrate.
|
||||||
- [ ] Docker Compose (SQLite/Postgres).
|
- [x] Docker Compose (SQLite/Postgres).
|
||||||
- [ ] Composants UI : FileUploader, ObjetCard.
|
- [x] Composants UI : FileUploader, ObjetCard.
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
- [ ] Analyse commandes hardware (lspci/lsusb).
|
- [x] Analyse commandes hardware (lspci/lsusb).
|
||||||
- [ ] Imports CSV/JSON (Amazon/AliExpress).
|
- [x] Imports CSV/JSON (Amazon/AliExpress).
|
||||||
- [ ] Garanties, prets, alertes.
|
- [ ] Garanties, prets, alertes.
|
||||||
- [ ] Tutos Markdown (EasyMDE) + endpoints.
|
- [ ] Tutos Markdown (EasyMDE) + endpoints.
|
||||||
- [ ] QR codes + backups ZIP.
|
- [ ] QR codes + backups ZIP.
|
||||||
|
|
||||||
## UX/Theme
|
## UX/Theme
|
||||||
- [ ] Themes light/dark/monokai/gruvbox-dark (defaut seventies).
|
- [x] Themes light/dark/monokai/gruvbox-dark (defaut seventies).
|
||||||
- [ ] Footer avec versions backend/frontend.
|
- [x] Footer avec versions backend/frontend.
|
||||||
- [ ] Onglet settings (config.json).
|
- [x] Onglet settings (config.json).
|
||||||
- [ ] Onglet debug (logs backend/frontend).
|
- [x] Onglet debug (logs backend/frontend).
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Activer WAL pour SQLite si disponible.
|
||||||
|
if driver == "sqlite3" {
|
||||||
|
if _, err := client.ExecContext(context.Background(), "PRAGMA journal_mode = WAL;"); err != nil {
|
||||||
|
log.Printf("activation WAL impossible: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-creation du schema en dev. A remplacer par migrations en prod.
|
// Auto-creation du schema en dev. A remplacer par migrations en prod.
|
||||||
if err := client.Schema.Create(context.Background()); err != nil {
|
if err := client.Schema.Create(context.Background()); err != nil {
|
||||||
log.Fatalf("creation schema impossible: %v", err)
|
log.Fatalf("creation schema impossible: %v", err)
|
||||||
@@ -56,7 +63,10 @@ func main() {
|
|||||||
|
|
||||||
// Route de sante pour verifier que le backend repond.
|
// Route de sante pour verifier que le backend repond.
|
||||||
r.GET("/healthz", func(c *gin.Context) {
|
r.GET("/healthz", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"version": "0.1.0",
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
handlers.RegisterRoutes(r, client)
|
handlers.RegisterRoutes(r, client)
|
||||||
|
|||||||
181
backend/internal/handlers/analyse_hardware.go
Normal file
181
backend/internal/handlers/analyse_hardware.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type analyseHardwareRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Texte string `json:"texte"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lspciDevice struct {
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
Class string `json:"class"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Subsystem string `json:"subsystem,omitempty"`
|
||||||
|
Driver string `json:"driver,omitempty"`
|
||||||
|
Modules []string `json:"modules,omitempty"`
|
||||||
|
Raw []string `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lsusbDevice struct {
|
||||||
|
Bus string `json:"bus"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
VendorID string `json:"vendor_id"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Raw string `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyseHardwareResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Devices []interface{} `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Analyser une sortie hardware (lspci/lsusb)
|
||||||
|
// @Tags Analyse
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body analyseHardwareRequest true "Texte a analyser"
|
||||||
|
// @Success 200 {object} analyseHardwareResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Router /analyse-hardware [post]
|
||||||
|
func (h *Handler) AnalyseHardware(c *gin.Context) {
|
||||||
|
var req analyseHardwareRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "donnees invalides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
req.Texte = strings.TrimSpace(req.Texte)
|
||||||
|
if req.Texte == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "texte vide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Type == "" {
|
||||||
|
req.Type = detectHardwareType(req.Texte)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "lspci":
|
||||||
|
devices := parseLspci(req.Texte)
|
||||||
|
c.JSON(http.StatusOK, analyseHardwareResponse{
|
||||||
|
Type: "lspci",
|
||||||
|
Count: len(devices),
|
||||||
|
Devices: toInterfaceSlice(devices),
|
||||||
|
})
|
||||||
|
case "lsusb":
|
||||||
|
devices := parseLsusb(req.Texte)
|
||||||
|
c.JSON(http.StatusOK, analyseHardwareResponse{
|
||||||
|
Type: "lsusb",
|
||||||
|
Count: len(devices),
|
||||||
|
Devices: toInterfaceSlice(devices),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "type non supporte"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectHardwareType(text string) string {
|
||||||
|
if strings.Contains(text, "Bus ") && strings.Contains(text, "ID ") {
|
||||||
|
return "lsusb"
|
||||||
|
}
|
||||||
|
return "lspci"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLspci(text string) []lspciDevice {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var devices []lspciDevice
|
||||||
|
re := regexp.MustCompile(`^(?:[0-9a-fA-F]{4}:)?([0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F])\s+([^:]+):\s+(.+)$`)
|
||||||
|
|
||||||
|
var current *lspciDevice
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimRight(raw, "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matches := re.FindStringSubmatch(line); matches != nil {
|
||||||
|
if current != nil {
|
||||||
|
devices = append(devices, *current)
|
||||||
|
}
|
||||||
|
current = &lspciDevice{
|
||||||
|
Slot: matches[1],
|
||||||
|
Class: strings.TrimSpace(matches[2]),
|
||||||
|
Description: strings.TrimSpace(matches[3]),
|
||||||
|
Raw: []string{line},
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
current.Raw = append(current.Raw, line)
|
||||||
|
if strings.HasPrefix(trimmed, "Subsystem:") {
|
||||||
|
current.Subsystem = strings.TrimSpace(strings.TrimPrefix(trimmed, "Subsystem:"))
|
||||||
|
} else if strings.HasPrefix(trimmed, "Kernel driver in use:") {
|
||||||
|
current.Driver = strings.TrimSpace(strings.TrimPrefix(trimmed, "Kernel driver in use:"))
|
||||||
|
} else if strings.HasPrefix(trimmed, "Kernel modules:") {
|
||||||
|
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "Kernel modules:"))
|
||||||
|
if value != "" {
|
||||||
|
current.Modules = splitCSV(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != nil {
|
||||||
|
devices = append(devices, *current)
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLsusb(text string) []lsusbDevice {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var devices []lsusbDevice
|
||||||
|
re := regexp.MustCompile(`^Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\s*(.*)$`)
|
||||||
|
for _, raw := range lines {
|
||||||
|
line := strings.TrimRight(raw, "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := re.FindStringSubmatch(line)
|
||||||
|
if matches == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices = append(devices, lsusbDevice{
|
||||||
|
Bus: matches[1],
|
||||||
|
Device: matches[2],
|
||||||
|
VendorID: matches[3],
|
||||||
|
ProductID: matches[4],
|
||||||
|
Description: strings.TrimSpace(matches[5]),
|
||||||
|
Raw: line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(value string) []string {
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInterfaceSlice[T any](items []T) []interface{} {
|
||||||
|
out := make([]interface{}, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
out[i] = item
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
471
backend/internal/handlers/imports.go
Normal file
471
backend/internal/handlers/imports.go
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maison43.duckdns.org/gilles/matosbox/internal/data/ent/objet"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type importAchat struct {
|
||||||
|
Nom string `json:"nom"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Quantite int `json:"quantite,omitempty"`
|
||||||
|
PrixAchat float64 `json:"prix_achat,omitempty"`
|
||||||
|
DateAchat string `json:"date_achat,omitempty"`
|
||||||
|
Boutique string `json:"boutique,omitempty"`
|
||||||
|
NumeroSerie string `json:"numero_serie,omitempty"`
|
||||||
|
NumeroModele string `json:"numero_modele,omitempty"`
|
||||||
|
Fabricant string `json:"fabricant,omitempty"`
|
||||||
|
Caracteristiques map[string]any `json:"caracteristiques,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importAchatPayload struct {
|
||||||
|
Boutique string `json:"boutique"`
|
||||||
|
Achats []importAchat `json:"achats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importErreur struct {
|
||||||
|
Ligne int `json:"ligne,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Nom string `json:"nom,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importResultat struct {
|
||||||
|
Importes int `json:"importes"`
|
||||||
|
Crees int `json:"crees"`
|
||||||
|
Doublons int `json:"doublons"`
|
||||||
|
Erreurs []importErreur `json:"erreurs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Importer des achats (CSV/JSON)
|
||||||
|
// @Tags Imports
|
||||||
|
// @Accept multipart/form-data,json
|
||||||
|
// @Produce json
|
||||||
|
// @Param boutique formData string false "Boutique (amazon, aliexpress, generic)"
|
||||||
|
// @Param fichier formData file false "Fichier CSV ou JSON"
|
||||||
|
// @Success 200 {object} importResultat
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /imports/achats [post]
|
||||||
|
func (h *Handler) ImportAchats(c *gin.Context) {
|
||||||
|
contentType := c.GetHeader("Content-Type")
|
||||||
|
if strings.Contains(contentType, "application/json") {
|
||||||
|
h.handleImportJSON(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleImportMultipart(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleImportJSON(c *gin.Context) {
|
||||||
|
raw, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "lecture JSON impossible"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "corps JSON vide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload importAchatPayload
|
||||||
|
if err := json.Unmarshal(raw, &payload); err == nil && len(payload.Achats) > 0 {
|
||||||
|
if payload.Boutique != "" {
|
||||||
|
for i := range payload.Achats {
|
||||||
|
if payload.Achats[i].Boutique == "" {
|
||||||
|
payload.Achats[i].Boutique = payload.Boutique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := h.persistImportAchats(c, payload.Achats)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var achats []importAchat
|
||||||
|
if err := json.Unmarshal(raw, &achats); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "format JSON invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := h.persistImportAchats(c, achats)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleImportMultipart(c *gin.Context) {
|
||||||
|
boutique := strings.TrimSpace(c.PostForm("boutique"))
|
||||||
|
file, header, err := c.Request.FormFile("fichier")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "fichier manquant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "lecture fichier impossible"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "fichier vide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := strings.ToLower(header.Filename)
|
||||||
|
if strings.HasSuffix(extension, ".json") {
|
||||||
|
var payload importAchatPayload
|
||||||
|
if err := json.Unmarshal(data, &payload); err == nil && len(payload.Achats) > 0 {
|
||||||
|
if boutique == "" {
|
||||||
|
boutique = payload.Boutique
|
||||||
|
}
|
||||||
|
if boutique != "" {
|
||||||
|
for i := range payload.Achats {
|
||||||
|
if payload.Achats[i].Boutique == "" {
|
||||||
|
payload.Achats[i].Boutique = boutique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := h.persistImportAchats(c, payload.Achats)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var achats []importAchat
|
||||||
|
if err := json.Unmarshal(data, &achats); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "JSON invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if boutique != "" {
|
||||||
|
for i := range achats {
|
||||||
|
if achats[i].Boutique == "" {
|
||||||
|
achats[i].Boutique = boutique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := h.persistImportAchats(c, achats)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
achats, erreurs := parseCSVImports(data, boutique)
|
||||||
|
result := h.persistImportAchats(c, achats)
|
||||||
|
result.Erreurs = append(result.Erreurs, erreurs...)
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) persistImportAchats(c *gin.Context, achats []importAchat) importResultat {
|
||||||
|
result := importResultat{Importes: len(achats)}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
for index, achat := range achats {
|
||||||
|
line := index + 1
|
||||||
|
achat.Nom = strings.TrimSpace(achat.Nom)
|
||||||
|
if achat.Nom == "" {
|
||||||
|
result.Erreurs = append(result.Erreurs, importErreur{
|
||||||
|
Ligne: line,
|
||||||
|
Message: "nom manquant",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if achat.Quantite <= 0 {
|
||||||
|
achat.Quantite = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedDate *time.Time
|
||||||
|
if achat.DateAchat != "" {
|
||||||
|
value, err := parseImportDate(achat.DateAchat)
|
||||||
|
if err != nil {
|
||||||
|
result.Erreurs = append(result.Erreurs, importErreur{
|
||||||
|
Ligne: line,
|
||||||
|
Message: "date invalide",
|
||||||
|
Nom: achat.Nom,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsedDate = &value
|
||||||
|
}
|
||||||
|
|
||||||
|
if achat.PrixAchat < 0 {
|
||||||
|
result.Erreurs = append(result.Erreurs, importErreur{
|
||||||
|
Ligne: line,
|
||||||
|
Message: "prix invalide",
|
||||||
|
Nom: achat.Nom,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate, err := h.isDuplicateAchat(ctx, achat, parsedDate)
|
||||||
|
if err != nil {
|
||||||
|
result.Erreurs = append(result.Erreurs, importErreur{
|
||||||
|
Ligne: line,
|
||||||
|
Message: "verification doublon impossible",
|
||||||
|
Nom: achat.Nom,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if duplicate {
|
||||||
|
result.Doublons++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
create := h.client.Objet.Create().
|
||||||
|
SetNom(achat.Nom).
|
||||||
|
SetQuantite(achat.Quantite)
|
||||||
|
|
||||||
|
if achat.Description != "" {
|
||||||
|
create.SetDescription(achat.Description)
|
||||||
|
}
|
||||||
|
if achat.PrixAchat > 0 {
|
||||||
|
create.SetPrixAchat(achat.PrixAchat)
|
||||||
|
}
|
||||||
|
if parsedDate != nil {
|
||||||
|
create.SetDateAchat(*parsedDate)
|
||||||
|
}
|
||||||
|
if achat.Boutique != "" {
|
||||||
|
create.SetBoutique(achat.Boutique)
|
||||||
|
}
|
||||||
|
if achat.NumeroSerie != "" {
|
||||||
|
create.SetNumeroSerie(achat.NumeroSerie)
|
||||||
|
}
|
||||||
|
if achat.NumeroModele != "" {
|
||||||
|
create.SetNumeroModele(achat.NumeroModele)
|
||||||
|
}
|
||||||
|
if achat.Fabricant != "" {
|
||||||
|
create.SetFabricant(achat.Fabricant)
|
||||||
|
}
|
||||||
|
if len(achat.Caracteristiques) > 0 {
|
||||||
|
create.SetCaracteristiques(achat.Caracteristiques)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := create.Save(ctx); err != nil {
|
||||||
|
result.Erreurs = append(result.Erreurs, importErreur{
|
||||||
|
Ligne: line,
|
||||||
|
Message: "creation impossible",
|
||||||
|
Nom: achat.Nom,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Crees++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) isDuplicateAchat(ctx context.Context, achat importAchat, date *time.Time) (bool, error) {
|
||||||
|
query := h.client.Objet.Query().Where(objet.NomEQ(achat.Nom))
|
||||||
|
if achat.Boutique != "" {
|
||||||
|
query = query.Where(objet.BoutiqueEQ(achat.Boutique))
|
||||||
|
}
|
||||||
|
if date != nil {
|
||||||
|
query = query.Where(objet.DateAchatEQ(*date))
|
||||||
|
}
|
||||||
|
if achat.PrixAchat > 0 {
|
||||||
|
query = query.Where(objet.PrixAchatEQ(achat.PrixAchat))
|
||||||
|
}
|
||||||
|
count, err := query.Count(ctx)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCSVImports(data []byte, boutique string) ([]importAchat, []importErreur) {
|
||||||
|
separator := detectCSVSeparator(data)
|
||||||
|
reader := csv.NewReader(bytes.NewReader(data))
|
||||||
|
reader.Comma = separator
|
||||||
|
reader.FieldsPerRecord = -1
|
||||||
|
reader.TrimLeadingSpace = true
|
||||||
|
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, []importErreur{{Message: "lecture entete impossible"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
headerIndex := mapCSVHeader(header, boutique)
|
||||||
|
if len(headerIndex) == 0 {
|
||||||
|
return nil, []importErreur{{Message: "entete CSV non reconnue"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var achats []importAchat
|
||||||
|
var erreurs []importErreur
|
||||||
|
line := 1
|
||||||
|
for {
|
||||||
|
record, err := reader.Read()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line++
|
||||||
|
if err != nil {
|
||||||
|
erreurs = append(erreurs, importErreur{Ligne: line, Message: "ligne invalide"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
achat, err := achatFromCSV(record, headerIndex, boutique)
|
||||||
|
if err != nil {
|
||||||
|
erreurs = append(erreurs, importErreur{Ligne: line, Message: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
achats = append(achats, achat)
|
||||||
|
}
|
||||||
|
return achats, erreurs
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectCSVSeparator(data []byte) rune {
|
||||||
|
firstLine := string(data)
|
||||||
|
if idx := strings.Index(firstLine, "\n"); idx > 0 {
|
||||||
|
firstLine = firstLine[:idx]
|
||||||
|
}
|
||||||
|
if strings.Count(firstLine, ";") > strings.Count(firstLine, ",") {
|
||||||
|
return ';'
|
||||||
|
}
|
||||||
|
return ','
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCSVHeader(header []string, boutique string) map[string]int {
|
||||||
|
index := make(map[string]int)
|
||||||
|
for i, col := range header {
|
||||||
|
key := normalizeCSVHeader(col)
|
||||||
|
switch key {
|
||||||
|
case "item name", "product name", "item", "nom", "designation":
|
||||||
|
index["nom"] = i
|
||||||
|
case "quantity", "qty", "quantite":
|
||||||
|
index["quantite"] = i
|
||||||
|
case "item price", "unit price", "price", "prix", "item total", "total price", "total":
|
||||||
|
if _, ok := index["prix"]; !ok {
|
||||||
|
index["prix"] = i
|
||||||
|
}
|
||||||
|
case "order date", "purchase date", "date", "date achat", "order time":
|
||||||
|
index["date"] = i
|
||||||
|
case "order id", "order number", "commande", "numero commande":
|
||||||
|
index["order_id"] = i
|
||||||
|
case "brand", "fabricant", "manufacturer":
|
||||||
|
index["fabricant"] = i
|
||||||
|
case "store name", "seller", "boutique":
|
||||||
|
index["boutique"] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := index["nom"]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if boutique == "" {
|
||||||
|
if _, ok := index["boutique"]; !ok {
|
||||||
|
index["boutique"] = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCSVHeader(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.ToLower(value))
|
||||||
|
value = strings.ReplaceAll(value, "\ufeff", "")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func achatFromCSV(record []string, index map[string]int, fallbackBoutique string) (importAchat, error) {
|
||||||
|
nom := csvValue(record, index["nom"])
|
||||||
|
if nom == "" {
|
||||||
|
return importAchat{}, fmt.Errorf("nom manquant")
|
||||||
|
}
|
||||||
|
|
||||||
|
quantite := parseIntOrDefault(csvValue(record, index["quantite"]), 1)
|
||||||
|
prix, err := parsePrice(csvValue(record, index["prix"]))
|
||||||
|
if err != nil && csvValue(record, index["prix"]) != "" {
|
||||||
|
return importAchat{}, fmt.Errorf("prix invalide")
|
||||||
|
}
|
||||||
|
|
||||||
|
dateValue := csvValue(record, index["date"])
|
||||||
|
if dateValue != "" {
|
||||||
|
if _, err := parseImportDate(dateValue); err != nil {
|
||||||
|
return importAchat{}, fmt.Errorf("date invalide")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boutique := fallbackBoutique
|
||||||
|
if boutique == "" {
|
||||||
|
boutique = csvValue(record, index["boutique"])
|
||||||
|
}
|
||||||
|
|
||||||
|
achat := importAchat{
|
||||||
|
Nom: nom,
|
||||||
|
Quantite: quantite,
|
||||||
|
PrixAchat: prix,
|
||||||
|
DateAchat: dateValue,
|
||||||
|
Boutique: boutique,
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID := csvValue(record, index["order_id"])
|
||||||
|
if orderID != "" {
|
||||||
|
achat.Caracteristiques = map[string]any{
|
||||||
|
"order_id": orderID,
|
||||||
|
"source": boutique,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fabricant := csvValue(record, index["fabricant"]); fabricant != "" {
|
||||||
|
achat.Fabricant = fabricant
|
||||||
|
}
|
||||||
|
return achat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func csvValue(record []string, idx int) string {
|
||||||
|
if idx < 0 || idx >= len(record) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(record[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntOrDefault(value string, fallback int) int {
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrice(value string) (float64, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
value = strings.ReplaceAll(value, "€", "")
|
||||||
|
value = strings.ReplaceAll(value, "$", "")
|
||||||
|
value = strings.ReplaceAll(value, "EUR", "")
|
||||||
|
value = strings.ReplaceAll(value, "USD", "")
|
||||||
|
value = strings.ReplaceAll(value, " ", "")
|
||||||
|
value = strings.ReplaceAll(value, ",", ".")
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return strconv.ParseFloat(value, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImportDate(value string) (time.Time, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
formats := []string{
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02",
|
||||||
|
"02/01/2006",
|
||||||
|
"01/02/2006",
|
||||||
|
"2006/01/02",
|
||||||
|
"02-01-2006",
|
||||||
|
"01-02-2006",
|
||||||
|
}
|
||||||
|
for _, format := range formats {
|
||||||
|
if parsed, err := time.Parse(format, value); err == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("format date inconnu")
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ func RegisterRoutes(r *gin.Engine, client *ent.Client) {
|
|||||||
v1.POST("/objets/:id/liens_emplacements", h.CreateLienEmplacement)
|
v1.POST("/objets/:id/liens_emplacements", h.CreateLienEmplacement)
|
||||||
v1.PUT("/liens_emplacements/:id", h.UpdateLienEmplacement)
|
v1.PUT("/liens_emplacements/:id", h.UpdateLienEmplacement)
|
||||||
v1.DELETE("/liens_emplacements/:id", h.DeleteLienEmplacement)
|
v1.DELETE("/liens_emplacements/:id", h.DeleteLienEmplacement)
|
||||||
|
v1.POST("/analyse-hardware", h.AnalyseHardware)
|
||||||
|
v1.POST("/imports/achats", h.ImportAchats)
|
||||||
v1.GET("/config", h.GetConfig)
|
v1.GET("/config", h.GetConfig)
|
||||||
v1.PUT("/config", h.UpdateConfig)
|
v1.PUT("/config", h.UpdateConfig)
|
||||||
v1.GET("/debug/logs", h.GetDebugLogs)
|
v1.GET("/debug/logs", h.GetDebugLogs)
|
||||||
|
|||||||
10
doc_dev/41_etape34_wal_footer_themes.md
Normal file
10
doc_dev/41_etape34_wal_footer_themes.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Etape 34 - WAL + footer versions + themes
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- Activation WAL SQLite via PRAGMA au demarrage.
|
||||||
|
- `/healthz` renvoie maintenant `version`.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- Footer affiche versions backend/frontend.
|
||||||
|
- Themes light/dark/monokai/gruvbox-dark via `data-theme`.
|
||||||
|
- Toggle de theme dans Settings.
|
||||||
10
doc_dev/42_etape35_imports_achats.md
Normal file
10
doc_dev/42_etape35_imports_achats.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Etape 35 - Imports achats CSV/JSON
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- Ajout `POST /v1/imports/achats` (multipart ou JSON).
|
||||||
|
- Parsing CSV/JSON (Amazon/AliExpress/generique) + normalisation prix/date.
|
||||||
|
- Dedoublonnage simple (nom + boutique + date + prix) + retour resume.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- Page `/achats` : choix boutique, upload fichier ou JSON manuel.
|
||||||
|
- Resume import (importes, crees, doublons, erreurs).
|
||||||
9
doc_dev/43_etape36_analyse_hardware.md
Normal file
9
doc_dev/43_etape36_analyse_hardware.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 36 - Analyse hardware (lspci/lsusb)
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- Ajout `POST /v1/analyse-hardware` avec detection auto lspci/lsusb.
|
||||||
|
- Parsing des sorties (slot, classe, description, driver/module, IDs USB).
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- Page `/analyse` : textarea + type + rendu JSON formate.
|
||||||
|
- Bouton copie du resultat.
|
||||||
@@ -2,6 +2,32 @@
|
|||||||
--bg: #f5f1e8;
|
--bg: #f5f1e8;
|
||||||
--text: #1f1b16;
|
--text: #1f1b16;
|
||||||
--accent: #c46b2d;
|
--accent: #c46b2d;
|
||||||
|
--card: #fffaf2;
|
||||||
|
--border: #e3d8c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--bg: #1c1a17;
|
||||||
|
--text: #f2e7d5;
|
||||||
|
--accent: #e29a4f;
|
||||||
|
--card: #26231e;
|
||||||
|
--border: #3b342c;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="monokai"] {
|
||||||
|
--bg: #272822;
|
||||||
|
--text: #f8f8f2;
|
||||||
|
--accent: #a6e22e;
|
||||||
|
--card: #2e2f28;
|
||||||
|
--border: #3d3e36;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="gruvbox-dark"] {
|
||||||
|
--bg: #282828;
|
||||||
|
--text: #ebdbb2;
|
||||||
|
--accent: #d79921;
|
||||||
|
--card: #32302f;
|
||||||
|
--border: #504945;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -11,7 +37,7 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||||
background: linear-gradient(135deg, #f5f1e8 0%, #efe6d6 100%);
|
background: linear-gradient(135deg, var(--bg) 0%, color-mix(in srgb, var(--bg) 85%, #000 15%) 100%);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +103,10 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid #e3d8c5;
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #fffaf2;
|
background: var(--card);
|
||||||
box-shadow: 0 8px 20px rgba(60, 40, 20, 0.08);
|
box-shadow: 0 8px 20px rgba(60, 40, 20, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
frontend/components/ThemeToggle.vue
Normal file
28
frontend/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card" style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<label>{{ t('theme.label') }}</label>
|
||||||
|
<select v-model="current" @change="applyTheme">
|
||||||
|
<option value="light">light</option>
|
||||||
|
<option value="dark">dark</option>
|
||||||
|
<option value="monokai">monokai</option>
|
||||||
|
<option value="gruvbox-dark">gruvbox-dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const current = ref('gruvbox-dark')
|
||||||
|
|
||||||
|
const applyTheme = () => {
|
||||||
|
document.documentElement.setAttribute('data-theme', current.value)
|
||||||
|
localStorage.setItem('theme', current.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('theme') || 'gruvbox-dark'
|
||||||
|
current.value = saved
|
||||||
|
applyTheme()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
17
frontend/composables/useVersions.ts
Normal file
17
frontend/composables/useVersions.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const useVersions = async () => {
|
||||||
|
const { apiBase } = useApi()
|
||||||
|
|
||||||
|
const frontend = '0.1.0'
|
||||||
|
let backend = 'unknown'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ version?: string }>(`${apiBase}/healthz`)
|
||||||
|
if (data?.version) {
|
||||||
|
backend = data.version
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return { frontend, backend }
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
<NuxtLink to="/objets">{{ t('nav.objets') }}</NuxtLink>
|
<NuxtLink to="/objets">{{ t('nav.objets') }}</NuxtLink>
|
||||||
<NuxtLink to="/emplacements">{{ t('nav.emplacements') }}</NuxtLink>
|
<NuxtLink to="/emplacements">{{ t('nav.emplacements') }}</NuxtLink>
|
||||||
<NuxtLink to="/categories">{{ t('nav.categories') }}</NuxtLink>
|
<NuxtLink to="/categories">{{ t('nav.categories') }}</NuxtLink>
|
||||||
|
<NuxtLink to="/achats">{{ t('nav.achats') }}</NuxtLink>
|
||||||
|
<NuxtLink to="/analyse">{{ t('nav.analyse') }}</NuxtLink>
|
||||||
<NuxtLink to="/settings">{{ t('nav.settings') }}</NuxtLink>
|
<NuxtLink to="/settings">{{ t('nav.settings') }}</NuxtLink>
|
||||||
<NuxtLink to="/debug">{{ t('nav.debug') }}</NuxtLink>
|
<NuxtLink to="/debug">{{ t('nav.debug') }}</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -20,6 +22,9 @@
|
|||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<small>{{ t('footer.text') }}</small>
|
<small>{{ t('footer.text') }}</small>
|
||||||
|
<div style="margin-top: 6px;">
|
||||||
|
<small>{{ t('footer.versions', versions) }}</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,4 +32,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const versions = ref({ frontend: '0.1.0', backend: 'unknown' })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const data = await useVersions()
|
||||||
|
versions.value = data
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"objets": "Objets",
|
"objets": "Objets",
|
||||||
"emplacements": "Emplacements",
|
"emplacements": "Emplacements",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
|
"achats": "Achats",
|
||||||
|
"analyse": "Analyse hardware",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"debug": "Debug"
|
"debug": "Debug"
|
||||||
},
|
},
|
||||||
@@ -103,6 +105,9 @@
|
|||||||
"applyTimezone": "Appliquer la timezone",
|
"applyTimezone": "Appliquer la timezone",
|
||||||
"description": "Configuration backend + frontend (config.json)."
|
"description": "Configuration backend + frontend (config.json)."
|
||||||
},
|
},
|
||||||
|
"theme": {
|
||||||
|
"label": "Theme"
|
||||||
|
},
|
||||||
"fileUploader": {
|
"fileUploader": {
|
||||||
"label": "Deposer des images, PDF ou fichiers Markdown."
|
"label": "Deposer des images, PDF ou fichiers Markdown."
|
||||||
},
|
},
|
||||||
@@ -139,6 +144,31 @@
|
|||||||
"autoRefresh": "Rafraichissement auto (5s)"
|
"autoRefresh": "Rafraichissement auto (5s)"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"text": "MatosBox - inventaire local"
|
"text": "MatosBox - inventaire local",
|
||||||
|
"versions": "Frontend {frontend} • Backend {backend}"
|
||||||
|
},
|
||||||
|
"achats": {
|
||||||
|
"mode": "Import achats",
|
||||||
|
"boutique": "Boutique",
|
||||||
|
"format": "Format",
|
||||||
|
"fileMode": "Fichier CSV/JSON",
|
||||||
|
"jsonMode": "JSON manuel",
|
||||||
|
"jsonPlaceholder": "Coller un tableau JSON d'achats ou un objet { boutique, achats }",
|
||||||
|
"generic": "Generique",
|
||||||
|
"summary": "Resume",
|
||||||
|
"imported": "Lignes importees",
|
||||||
|
"created": "Objets crees",
|
||||||
|
"duplicates": "Doublons",
|
||||||
|
"errors": "Erreurs"
|
||||||
|
},
|
||||||
|
"analyse": {
|
||||||
|
"title": "Analyse commandes hardware",
|
||||||
|
"type": "Type de commande",
|
||||||
|
"auto": "Detection auto",
|
||||||
|
"placeholder": "Coller la sortie de lspci ou lsusb",
|
||||||
|
"run": "Analyser",
|
||||||
|
"result": "Resultat",
|
||||||
|
"count": "Peripheriques detectes",
|
||||||
|
"empty": "Coller une sortie a analyser."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
150
frontend/pages/achats/index.vue
Normal file
150
frontend/pages/achats/index.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container">
|
||||||
|
<h1>{{ t('nav.achats') }}</h1>
|
||||||
|
<p v-if="message">{{ message }}</p>
|
||||||
|
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ t('achats.mode') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<label>
|
||||||
|
{{ t('achats.boutique') }}
|
||||||
|
<select v-model="boutique">
|
||||||
|
<option value="amazon">Amazon</option>
|
||||||
|
<option value="aliexpress">AliExpress</option>
|
||||||
|
<option value="generic">{{ t('achats.generic') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{{ t('achats.format') }}
|
||||||
|
<select v-model="mode">
|
||||||
|
<option value="file">{{ t('achats.fileMode') }}</option>
|
||||||
|
<option value="json">{{ t('achats.jsonMode') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="mode === 'file'" style="display: grid; gap: 8px;">
|
||||||
|
<input type="file" @change="onFileChange" />
|
||||||
|
<button class="card" type="button" :disabled="uploading" @click="uploadFile">
|
||||||
|
{{ uploading ? t('form.saving') : t('actions.upload') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else style="display: grid; gap: 8px;">
|
||||||
|
<textarea
|
||||||
|
v-model="jsonText"
|
||||||
|
rows="8"
|
||||||
|
:placeholder="t('achats.jsonPlaceholder')"
|
||||||
|
/>
|
||||||
|
<button class="card" type="button" :disabled="uploading" @click="uploadJson">
|
||||||
|
{{ uploading ? t('form.saving') : t('actions.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="result" class="card">
|
||||||
|
<h2>{{ t('achats.summary') }}</h2>
|
||||||
|
<p>{{ t('achats.imported') }}: {{ result.importes }}</p>
|
||||||
|
<p>{{ t('achats.created') }}: {{ result.crees }}</p>
|
||||||
|
<p>{{ t('achats.duplicates') }}: {{ result.doublons }}</p>
|
||||||
|
<div v-if="result.erreurs?.length" style="margin-top: 8px;">
|
||||||
|
<strong>{{ t('achats.errors') }}</strong>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(err, idx) in result.erreurs" :key="idx">
|
||||||
|
<span v-if="err.ligne">#{{ err.ligne }} </span>{{ err.message }}
|
||||||
|
<span v-if="err.nom">({{ err.nom }})</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type ImportErreur = {
|
||||||
|
ligne?: number
|
||||||
|
message: string
|
||||||
|
nom?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportResultat = {
|
||||||
|
importes: number
|
||||||
|
crees: number
|
||||||
|
doublons: number
|
||||||
|
erreurs: ImportErreur[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const boutique = ref("amazon")
|
||||||
|
const mode = ref<"file" | "json">("file")
|
||||||
|
const uploading = ref(false)
|
||||||
|
const message = ref("")
|
||||||
|
const result = ref<ImportResultat | null>(null)
|
||||||
|
const file = ref<File | null>(null)
|
||||||
|
const jsonText = ref("")
|
||||||
|
|
||||||
|
const onFileChange = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
file.value = input.files?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFile = async () => {
|
||||||
|
message.value = ""
|
||||||
|
result.value = null
|
||||||
|
if (!file.value) {
|
||||||
|
message.value = t("messages.noFiles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("boutique", boutique.value)
|
||||||
|
formData.append("fichier", file.value)
|
||||||
|
result.value = await $fetch(`${apiBase}/imports/achats`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
message.value = t("messages.uploadDone")
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t("messages.uploadError"))
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadJson = async () => {
|
||||||
|
message.value = ""
|
||||||
|
result.value = null
|
||||||
|
if (!jsonText.value.trim()) {
|
||||||
|
message.value = t("errors.invalidJson")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText.value)
|
||||||
|
let payload = parsed
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
payload = { boutique: boutique.value, achats: parsed }
|
||||||
|
} else if (parsed && typeof parsed === "object") {
|
||||||
|
if (!parsed.boutique) {
|
||||||
|
payload = { ...parsed, boutique: boutique.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.value = await $fetch(`${apiBase}/imports/achats`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
message.value = t("messages.uploadDone")
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
message.value = t("errors.invalidJson")
|
||||||
|
} else {
|
||||||
|
message.value = getErrorMessage(err, t("messages.uploadError"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
92
frontend/pages/analyse.vue
Normal file
92
frontend/pages/analyse.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container">
|
||||||
|
<h1>{{ t('nav.analyse') }}</h1>
|
||||||
|
<p v-if="message">{{ message }}</p>
|
||||||
|
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ t('analyse.title') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<label>
|
||||||
|
{{ t('analyse.type') }}
|
||||||
|
<select v-model="type">
|
||||||
|
<option value="auto">{{ t('analyse.auto') }}</option>
|
||||||
|
<option value="lspci">lspci</option>
|
||||||
|
<option value="lsusb">lsusb</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="texte"
|
||||||
|
rows="10"
|
||||||
|
:placeholder="t('analyse.placeholder')"
|
||||||
|
/>
|
||||||
|
<button class="card" type="button" :disabled="loading" @click="runAnalyse">
|
||||||
|
{{ loading ? t('form.saving') : t('analyse.run') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="result" class="card">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h2>{{ t('analyse.result') }}</h2>
|
||||||
|
<button class="card" type="button" @click="copyJson">{{ t('actions.copy') }}</button>
|
||||||
|
</div>
|
||||||
|
<p>{{ t('analyse.count') }}: {{ result.count }}</p>
|
||||||
|
<pre>{{ formatted }}</pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type AnalyseResponse = {
|
||||||
|
type: string
|
||||||
|
count: number
|
||||||
|
devices: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const type = ref("auto")
|
||||||
|
const texte = ref("")
|
||||||
|
const loading = ref(false)
|
||||||
|
const message = ref("")
|
||||||
|
const result = ref<AnalyseResponse | null>(null)
|
||||||
|
|
||||||
|
const formatted = computed(() => (result.value ? JSON.stringify(result.value, null, 2) : ""))
|
||||||
|
|
||||||
|
const runAnalyse = async () => {
|
||||||
|
message.value = ""
|
||||||
|
result.value = null
|
||||||
|
if (!texte.value.trim()) {
|
||||||
|
message.value = t("analyse.empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
type: type.value === "auto" ? "" : type.value,
|
||||||
|
texte: texte.value
|
||||||
|
}
|
||||||
|
result.value = await $fetch(`${apiBase}/analyse-hardware`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t("messages.loadError"))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyJson = async () => {
|
||||||
|
if (!formatted.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(formatted.value)
|
||||||
|
message.value = t("messages.copied")
|
||||||
|
} catch {
|
||||||
|
message.value = t("errors.copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
<I18nStub />
|
<I18nStub />
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div style="display: grid; gap: 8px; margin-bottom: 12px;">
|
<div style="display: grid; gap: 8px; margin-bottom: 12px;">
|
||||||
<label for="timezone">{{ t('settings.timezone') }}</label>
|
<label for="timezone">{{ t('settings.timezone') }}</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user