From b1f611d8eeac7f8fe6ba882f126e93f3f529c620 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Thu, 22 Jan 2026 06:33:28 +0100 Subject: [PATCH] add achats import and hardware analyse --- TODO.md | 34 +- backend/cmd/app/main.go | 12 +- backend/internal/handlers/analyse_hardware.go | 181 +++++++ backend/internal/handlers/imports.go | 471 ++++++++++++++++++ backend/internal/handlers/router.go | 2 + doc_dev/41_etape34_wal_footer_themes.md | 10 + doc_dev/42_etape35_imports_achats.md | 10 + doc_dev/43_etape36_analyse_hardware.md | 9 + frontend/assets/css/main.css | 32 +- frontend/components/ThemeToggle.vue | 28 ++ frontend/composables/useVersions.ts | 17 + frontend/layouts/default.vue | 11 + frontend/locales/fr.json | 32 +- frontend/pages/achats/index.vue | 150 ++++++ frontend/pages/analyse.vue | 92 ++++ frontend/pages/settings.vue | 2 + 16 files changed, 1071 insertions(+), 22 deletions(-) create mode 100644 backend/internal/handlers/analyse_hardware.go create mode 100644 backend/internal/handlers/imports.go create mode 100644 doc_dev/41_etape34_wal_footer_themes.md create mode 100644 doc_dev/42_etape35_imports_achats.md create mode 100644 doc_dev/43_etape36_analyse_hardware.md create mode 100644 frontend/components/ThemeToggle.vue create mode 100644 frontend/composables/useVersions.ts create mode 100644 frontend/pages/achats/index.vue create mode 100644 frontend/pages/analyse.vue diff --git a/TODO.md b/TODO.md index a1c6b13..b8da077 100644 --- a/TODO.md +++ b/TODO.md @@ -3,29 +3,29 @@ en suivant consigne: MatosBox Documentation.md ## Priorite haute (MVP) -- [ ] Creer schemas Ent en francais : Objet, Categorie, Emplacement. -- [ ] Ajouter migrations initiales (Goose ou Atlas). -- [ ] CRUD API Objets/Categories/Emplacements (Gin). -- [ ] Endpoint upload multiple `POST /v1/objets/{id}/pieces_jointes`. -- [ ] Stockage pieces jointes + table PieceJointe. -- [ ] Activer WAL SQLite. -- [ ] Base frontend Nuxt + pages principales. +- [x] Creer schemas Ent en francais : Objet, Categorie, Emplacement. +- [x] Ajouter migrations initiales (Goose ou Atlas). +- [x] CRUD API Objets/Categories/Emplacements (Gin). +- [x] Endpoint upload multiple `POST /v1/objets/{id}/pieces_jointes`. +- [x] Stockage pieces jointes + table PieceJointe. +- [x] Activer WAL SQLite. +- [x] Base frontend Nuxt + pages principales. ## Priorite moyenne -- [ ] Swagger (Swaggo) pour la doc API. -- [ ] Taskfile pour build/test/migrate. -- [ ] Docker Compose (SQLite/Postgres). -- [ ] Composants UI : FileUploader, ObjetCard. +- [x] Swagger (Swaggo) pour la doc API. +- [x] Taskfile pour build/test/migrate. +- [x] Docker Compose (SQLite/Postgres). +- [x] Composants UI : FileUploader, ObjetCard. ## Extensions -- [ ] Analyse commandes hardware (lspci/lsusb). -- [ ] Imports CSV/JSON (Amazon/AliExpress). +- [x] Analyse commandes hardware (lspci/lsusb). +- [x] Imports CSV/JSON (Amazon/AliExpress). - [ ] Garanties, prets, alertes. - [ ] Tutos Markdown (EasyMDE) + endpoints. - [ ] QR codes + backups ZIP. ## UX/Theme -- [ ] Themes light/dark/monokai/gruvbox-dark (defaut seventies). -- [ ] Footer avec versions backend/frontend. -- [ ] Onglet settings (config.json). -- [ ] Onglet debug (logs backend/frontend). +- [x] Themes light/dark/monokai/gruvbox-dark (defaut seventies). +- [x] Footer avec versions backend/frontend. +- [x] Onglet settings (config.json). +- [x] Onglet debug (logs backend/frontend). diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index fd5899b..c5c25bd 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -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. if err := client.Schema.Create(context.Background()); err != nil { log.Fatalf("creation schema impossible: %v", err) @@ -56,7 +63,10 @@ func main() { // Route de sante pour verifier que le backend repond. 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) diff --git a/backend/internal/handlers/analyse_hardware.go b/backend/internal/handlers/analyse_hardware.go new file mode 100644 index 0000000..a8eae9e --- /dev/null +++ b/backend/internal/handlers/analyse_hardware.go @@ -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 +} diff --git a/backend/internal/handlers/imports.go b/backend/internal/handlers/imports.go new file mode 100644 index 0000000..0127a27 --- /dev/null +++ b/backend/internal/handlers/imports.go @@ -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") +} diff --git a/backend/internal/handlers/router.go b/backend/internal/handlers/router.go index 12b46ab..0946786 100644 --- a/backend/internal/handlers/router.go +++ b/backend/internal/handlers/router.go @@ -28,6 +28,8 @@ func RegisterRoutes(r *gin.Engine, client *ent.Client) { v1.POST("/objets/:id/liens_emplacements", h.CreateLienEmplacement) v1.PUT("/liens_emplacements/:id", h.UpdateLienEmplacement) 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.PUT("/config", h.UpdateConfig) v1.GET("/debug/logs", h.GetDebugLogs) diff --git a/doc_dev/41_etape34_wal_footer_themes.md b/doc_dev/41_etape34_wal_footer_themes.md new file mode 100644 index 0000000..15a50ca --- /dev/null +++ b/doc_dev/41_etape34_wal_footer_themes.md @@ -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. diff --git a/doc_dev/42_etape35_imports_achats.md b/doc_dev/42_etape35_imports_achats.md new file mode 100644 index 0000000..ed7f4c3 --- /dev/null +++ b/doc_dev/42_etape35_imports_achats.md @@ -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). diff --git a/doc_dev/43_etape36_analyse_hardware.md b/doc_dev/43_etape36_analyse_hardware.md new file mode 100644 index 0000000..0bffb6b --- /dev/null +++ b/doc_dev/43_etape36_analyse_hardware.md @@ -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. diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 127140b..6136203 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -2,6 +2,32 @@ --bg: #f5f1e8; --text: #1f1b16; --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 { margin: 0; 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); } @@ -77,10 +103,10 @@ a { } .card { - border: 1px solid #e3d8c5; + border: 1px solid var(--border); border-radius: 16px; padding: 16px; - background: #fffaf2; + background: var(--card); box-shadow: 0 8px 20px rgba(60, 40, 20, 0.08); } diff --git a/frontend/components/ThemeToggle.vue b/frontend/components/ThemeToggle.vue new file mode 100644 index 0000000..3f581b3 --- /dev/null +++ b/frontend/components/ThemeToggle.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/composables/useVersions.ts b/frontend/composables/useVersions.ts new file mode 100644 index 0000000..5c433c5 --- /dev/null +++ b/frontend/composables/useVersions.ts @@ -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 } +} diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 2849585..c2ff049 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -7,6 +7,8 @@ {{ t('nav.objets') }} {{ t('nav.emplacements') }} {{ t('nav.categories') }} + {{ t('nav.achats') }} + {{ t('nav.analyse') }} {{ t('nav.settings') }} {{ t('nav.debug') }} @@ -20,6 +22,9 @@ @@ -27,4 +32,10 @@ diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 3e8ad06..08f252f 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -3,6 +3,8 @@ "objets": "Objets", "emplacements": "Emplacements", "categories": "Categories", + "achats": "Achats", + "analyse": "Analyse hardware", "settings": "Settings", "debug": "Debug" }, @@ -103,6 +105,9 @@ "applyTimezone": "Appliquer la timezone", "description": "Configuration backend + frontend (config.json)." }, + "theme": { + "label": "Theme" + }, "fileUploader": { "label": "Deposer des images, PDF ou fichiers Markdown." }, @@ -139,6 +144,31 @@ "autoRefresh": "Rafraichissement auto (5s)" }, "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." } } diff --git a/frontend/pages/achats/index.vue b/frontend/pages/achats/index.vue new file mode 100644 index 0000000..b548c79 --- /dev/null +++ b/frontend/pages/achats/index.vue @@ -0,0 +1,150 @@ +