update frontend ui, i18n, filters, and deps
This commit is contained in:
@@ -5,6 +5,8 @@ DATABASE_URL=file:./data/matosbox.db?_fk=1
|
|||||||
ATTACHMENTS_DIR=./data/pieces_jointes
|
ATTACHMENTS_DIR=./data/pieces_jointes
|
||||||
BACKUP_DIR=./data/backups
|
BACKUP_DIR=./data/backups
|
||||||
MAX_UPLOAD_MB=50
|
MAX_UPLOAD_MB=50
|
||||||
|
CONFIG_PATH=./data/config.json
|
||||||
|
DEBUG_LOG_PATH=./data/logs/backend.log
|
||||||
|
|
||||||
# App
|
# App
|
||||||
TIMEZONE=Europe/Paris
|
TIMEZONE=Europe/Paris
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,3 +6,9 @@
|
|||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
/frontend/node_modules/
|
||||||
|
/frontend/.nuxt/
|
||||||
|
/frontend/.output/
|
||||||
|
/frontend/dist/
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ type categorieRequest struct {
|
|||||||
func (h *Handler) ListCategories(c *gin.Context) {
|
func (h *Handler) ListCategories(c *gin.Context) {
|
||||||
limit, offset, page := parsePagination(c.Query("page"), c.Query("limit"))
|
limit, offset, page := parsePagination(c.Query("page"), c.Query("limit"))
|
||||||
query := h.client.Categorie.Query()
|
query := h.client.Categorie.Query()
|
||||||
|
if nom := c.Query("nom"); nom != "" {
|
||||||
|
query = query.Where(categorie.NomContainsFold(nom))
|
||||||
|
}
|
||||||
total, err := query.Count(c.Request.Context())
|
total, err := query.Count(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"erreur": "impossible de compter les categories"})
|
c.JSON(http.StatusInternalServerError, gin.H{"erreur": "impossible de compter les categories"})
|
||||||
|
|||||||
109
backend/internal/handlers/config.go
Normal file
109
backend/internal/handlers/config.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configPayload struct {
|
||||||
|
Backend map[string]any `json:"backend"`
|
||||||
|
Frontend map[string]any `json:"frontend"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Lire la configuration
|
||||||
|
// @Tags Config
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} configPayload
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /config [get]
|
||||||
|
func (h *Handler) GetConfig(c *gin.Context) {
|
||||||
|
config, err := readConfig()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"erreur": "impossible de lire la configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Mettre a jour la configuration
|
||||||
|
// @Tags Config
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body configPayload true "Configuration"
|
||||||
|
// @Success 200 {object} configPayload
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /config [put]
|
||||||
|
func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||||
|
var payload configPayload
|
||||||
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"erreur": "donnees invalides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payload.Timezone == "" {
|
||||||
|
payload.Timezone = "Europe/Paris"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeConfig(payload); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"erreur": "impossible de sauvegarder la configuration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath() string {
|
||||||
|
value := os.Getenv("CONFIG_PATH")
|
||||||
|
if value == "" {
|
||||||
|
return "./data/config.json"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfig() configPayload {
|
||||||
|
return configPayload{
|
||||||
|
Backend: map[string]any{},
|
||||||
|
Frontend: map[string]any{},
|
||||||
|
Timezone: "Europe/Paris",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readConfig() (configPayload, error) {
|
||||||
|
path := configPath()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
if err := writeConfig(cfg); err != nil {
|
||||||
|
return configPayload{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return configPayload{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg configPayload
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return configPayload{}, err
|
||||||
|
}
|
||||||
|
if cfg.Timezone == "" {
|
||||||
|
cfg.Timezone = "Europe/Paris"
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(cfg configPayload) error {
|
||||||
|
path := configPath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
52
backend/internal/handlers/debug.go
Normal file
52
backend/internal/handlers/debug.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type debugLogsResponse struct {
|
||||||
|
Logs string `json:"logs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Lire les logs backend
|
||||||
|
// @Tags Debug
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} debugLogsResponse
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /debug/logs [get]
|
||||||
|
func (h *Handler) GetDebugLogs(c *gin.Context) {
|
||||||
|
logs, err := readLogTail()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"erreur": "impossible de lire les logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, debugLogsResponse{Logs: logs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugLogPath() string {
|
||||||
|
value := os.Getenv("DEBUG_LOG_PATH")
|
||||||
|
if value == "" {
|
||||||
|
return "./data/logs/backend.log"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLogTail() (string, error) {
|
||||||
|
path := debugLogPath()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = 20000
|
||||||
|
if len(data) > maxBytes {
|
||||||
|
data = data[len(data)-maxBytes:]
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ type emplacementRequest struct {
|
|||||||
func (h *Handler) ListEmplacements(c *gin.Context) {
|
func (h *Handler) ListEmplacements(c *gin.Context) {
|
||||||
limit, offset, page := parsePagination(c.Query("page"), c.Query("limit"))
|
limit, offset, page := parsePagination(c.Query("page"), c.Query("limit"))
|
||||||
query := h.client.Emplacement.Query()
|
query := h.client.Emplacement.Query()
|
||||||
|
if nom := c.Query("nom"); nom != "" {
|
||||||
|
query = query.Where(emplacement.NomContainsFold(nom))
|
||||||
|
}
|
||||||
total, err := query.Count(c.Request.Context())
|
total, err := query.Count(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"erreur": "impossible de compter les emplacements"})
|
c.JSON(http.StatusInternalServerError, gin.H{"erreur": "impossible de compter les emplacements"})
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ 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.GET("/config", h.GetConfig)
|
||||||
|
v1.PUT("/config", h.UpdateConfig)
|
||||||
|
v1.GET("/debug/logs", h.GetDebugLogs)
|
||||||
|
|
||||||
v1.GET("/categories", h.ListCategories)
|
v1.GET("/categories", h.ListCategories)
|
||||||
v1.POST("/categories", h.CreateCategorie)
|
v1.POST("/categories", h.CreateCategorie)
|
||||||
|
|||||||
16
doc_dev/22_etape15_settings_debug.md
Normal file
16
doc_dev/22_etape15_settings_debug.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Etape 15 - Settings + Debug
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- Endpoint config :
|
||||||
|
- `GET /v1/config`
|
||||||
|
- `PUT /v1/config`
|
||||||
|
- Endpoint debug logs :
|
||||||
|
- `GET /v1/debug/logs`
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- Page `/settings` pour edition du `config.json`.
|
||||||
|
- Page `/debug` pour affichage logs backend.
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
- `CONFIG_PATH` : chemin config (defaut `./data/config.json`).
|
||||||
|
- `DEBUG_LOG_PATH` : chemin logs (defaut `./data/logs/backend.log`).
|
||||||
12
doc_dev/23_etape16_frontend_api.md
Normal file
12
doc_dev/23_etape16_frontend_api.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Etape 16 - Frontend API (listes)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Brancher le frontend sur l'API pour les listes.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
- `frontend/pages/objets/index.vue` : GET `/v1/objets?limit=50`.
|
||||||
|
- `frontend/pages/emplacements/index.vue` : GET `/v1/emplacements?limit=50`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Gestion du chargement et des erreurs.
|
||||||
|
- Affichage simple sous forme de cartes.
|
||||||
11
doc_dev/24_etape17_frontend_detail.md
Normal file
11
doc_dev/24_etape17_frontend_detail.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Etape 17 - Frontend detail objet
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Connecter la fiche objet a l'API.
|
||||||
|
|
||||||
|
## Page
|
||||||
|
- `frontend/pages/objets/[id].vue` : GET `/v1/objets/:id`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Gestion du chargement et des erreurs.
|
||||||
|
- Affichage minimal (nom, description, quantite, statut).
|
||||||
9
doc_dev/25_etape18_objet_detail_pj_champs.md
Normal file
9
doc_dev/25_etape18_objet_detail_pj_champs.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 18 - Detail objet (pieces jointes + champs)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Afficher les pieces jointes et champs personnalises sur la fiche objet.
|
||||||
|
|
||||||
|
## Page
|
||||||
|
- `frontend/pages/objets/[id].vue`
|
||||||
|
- GET `/v1/objets/:id/pieces_jointes`
|
||||||
|
- GET `/v1/objets/:id/champs_personnalises`
|
||||||
9
doc_dev/26_etape19_objet_liens_upload_champs.md
Normal file
9
doc_dev/26_etape19_objet_liens_upload_champs.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 19 - Fiche objet (liens, upload, edition champs)
|
||||||
|
|
||||||
|
## Ajouts
|
||||||
|
- Liste et creation des liens emplacements.
|
||||||
|
- Upload multiple de pieces jointes.
|
||||||
|
- Creation, edition et suppression des champs personnalises.
|
||||||
|
|
||||||
|
## Page
|
||||||
|
- `frontend/pages/objets/[id].vue`
|
||||||
10
doc_dev/27_etape20_fileuploader_robuste.md
Normal file
10
doc_dev/27_etape20_fileuploader_robuste.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Etape 20 - FileUploader + robustesse
|
||||||
|
|
||||||
|
## Ajouts
|
||||||
|
- Composant `FileUploader` reutilisable.
|
||||||
|
- Blocage pendant upload et messages d'etat.
|
||||||
|
- Messages d'erreur/succes pour champs et liens.
|
||||||
|
|
||||||
|
## Fichiers
|
||||||
|
- `frontend/components/FileUploader.vue`
|
||||||
|
- `frontend/pages/objets/[id].vue`
|
||||||
9
doc_dev/28_etape21_fileuploader_ui_pj.md
Normal file
9
doc_dev/28_etape21_fileuploader_ui_pj.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 21 - FileUploader + UI pieces jointes
|
||||||
|
|
||||||
|
## FileUploader
|
||||||
|
- Drag & drop + preview images.
|
||||||
|
- Etat bloque si upload en cours.
|
||||||
|
|
||||||
|
## Pieces jointes
|
||||||
|
- Actions UI : definir principale, supprimer.
|
||||||
|
- Indicateur "Principale" sur la fiche objet.
|
||||||
10
doc_dev/29_etape22_ui_crud_errors.md
Normal file
10
doc_dev/29_etape22_ui_crud_errors.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Etape 22 - UI CRUD + erreurs
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- CRUD objets, categories, emplacements avec formulaires simples.
|
||||||
|
- Fiche objet : erreurs detaillees (API).
|
||||||
|
- Settings : champ timezone + injection dans config JSON.
|
||||||
|
- Debug : auto-refresh + copie des logs.
|
||||||
|
|
||||||
|
## Composables
|
||||||
|
- `frontend/composables/useApi.ts` pour base API + messages d'erreur.
|
||||||
11
doc_dev/30_etape23_tree_confirm_polish.md
Normal file
11
doc_dev/30_etape23_tree_confirm_polish.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Etape 23 - Arborescences, confirmations, polish
|
||||||
|
|
||||||
|
## Arborescences
|
||||||
|
- Vue en liste indentee pour categories et emplacements.
|
||||||
|
|
||||||
|
## Confirmations
|
||||||
|
- Boite de confirmation avant suppression (objets, categories, emplacements, champs, pieces jointes).
|
||||||
|
|
||||||
|
## Polish UI
|
||||||
|
- Amelioration styles : cartes, boutons, inputs, modal.
|
||||||
|
- Ajout composant `ConfirmDialog`.
|
||||||
9
doc_dev/31_etape24_components_tree.md
Normal file
9
doc_dev/31_etape24_components_tree.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 24 - Composants + tree
|
||||||
|
|
||||||
|
## Composants
|
||||||
|
- `ObjetForm` pour le formulaire objet.
|
||||||
|
- `TreeList` pour arborescences collapsibles.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
- `objets/index.vue` utilise `ObjetForm`.
|
||||||
|
- `categories/index.vue` et `emplacements/index.vue` utilisent `TreeList`.
|
||||||
8
doc_dev/32_etape25_emplacement_picker_i18n.md
Normal file
8
doc_dev/32_etape25_emplacement_picker_i18n.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Etape 25 - Emplacement picker + i18n stub
|
||||||
|
|
||||||
|
## Emplacement picker
|
||||||
|
- Composant `EmplacementPicker` pour choisir un emplacement par nom.
|
||||||
|
- Utilise la liste des emplacements et affiche une indentation par niveau.
|
||||||
|
|
||||||
|
## I18n
|
||||||
|
- Ajout d'un bloc d'information dans Settings (integration Weblate a venir).
|
||||||
9
doc_dev/33_etape26_i18n_layout.md
Normal file
9
doc_dev/33_etape26_i18n_layout.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 26 - i18n + layout
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
- Ajout du module `@nuxtjs/i18n`.
|
||||||
|
- Config FR par defaut + fichier `locales/fr.json`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
- Ajout d'un layout global avec header nav et footer.
|
||||||
|
- Styles header/nav/footer dans `assets/css/main.css`.
|
||||||
9
doc_dev/34_etape27_i18n_components.md
Normal file
9
doc_dev/34_etape27_i18n_components.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 27 - i18n + composants
|
||||||
|
|
||||||
|
## Composants
|
||||||
|
- `CategorieForm` et `EmplacementForm` pour factoriser les formulaires CRUD.
|
||||||
|
- `TreeList` conserve l'arborescence collapsible.
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
- Texte UI principal remplace par des cles i18n.
|
||||||
|
- Fichier `locales/fr.json` enrichi.
|
||||||
11
doc_dev/35_etape28_i18n_full.md
Normal file
11
doc_dev/35_etape28_i18n_full.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Etape 28 - i18n complet
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
- Traduction des boutons et messages dans les pages CRUD.
|
||||||
|
- i18n pour FileUploader et ConfirmDialog.
|
||||||
|
|
||||||
|
## Fichiers
|
||||||
|
- `frontend/locales/fr.json`
|
||||||
|
- pages CRUD + fiche objet
|
||||||
|
- `frontend/components/FileUploader.vue`
|
||||||
|
- `frontend/components/ConfirmDialog.vue`
|
||||||
10
doc_dev/36_etape29_i18n_filtres_pagination.md
Normal file
10
doc_dev/36_etape29_i18n_filtres_pagination.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Etape 29 - i18n complet + filtres objets
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
- Remplacement des textes restants par des cles i18n.
|
||||||
|
- Ajouts dans `locales/fr.json`.
|
||||||
|
- Composants CRUD et pages detail traduits.
|
||||||
|
|
||||||
|
## Objets
|
||||||
|
- Filtres nom/statut + limite.
|
||||||
|
- Pagination avec total (meta) et navigation.
|
||||||
9
doc_dev/37_etape30_i18n_rest_filters.md
Normal file
9
doc_dev/37_etape30_i18n_rest_filters.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 30 - i18n fin + filtres/pagination objets
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
- Traduction des textes restants (pages liste/detail, formulaires, confirmations).
|
||||||
|
- Cles supplementaires dans `locales/fr.json`.
|
||||||
|
|
||||||
|
## Objets
|
||||||
|
- Filtres nom/statut + limite.
|
||||||
|
- Pagination avec meta (page/total).
|
||||||
9
doc_dev/38_etape31_pagination_filtres_ce.md
Normal file
9
doc_dev/38_etape31_pagination_filtres_ce.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Etape 31 - Filtres + pagination categories/emplacements
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
- Filtres nom + limite.
|
||||||
|
- Pagination avec meta.
|
||||||
|
|
||||||
|
## Emplacements
|
||||||
|
- Filtres nom + limite.
|
||||||
|
- Pagination avec meta.
|
||||||
8
doc_dev/39_etape32_backend_filter_tests.md
Normal file
8
doc_dev/39_etape32_backend_filter_tests.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Etape 32 - Filtres backend + tests front
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- Filtre `nom` sur `GET /v1/categories` et `GET /v1/emplacements`.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
- Ajout de Vitest config + test basique `useApi`.
|
||||||
|
- Script `pnpm test` via `npm run test`.
|
||||||
13
doc_dev/40_etape33_npm_warnings.md
Normal file
13
doc_dev/40_etape33_npm_warnings.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Etape 33 - Warnings npm
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
Lors de `npm install` dans `frontend/`, npm a remonte des warnings de dependances.
|
||||||
|
|
||||||
|
## Warnings observes
|
||||||
|
- `whatwg-encoding@3.1.1` deprecate (suggestion: @exodus/bytes).
|
||||||
|
- `vue-i18n@9.14.5` deprecie (v9/v10 non supportees, migration vers v11 conseillee).
|
||||||
|
- `tar@6.2.1` deprecie (vulnerabilites connues, mise a jour recommandee).
|
||||||
|
|
||||||
|
## Vulnerabilites npm audit
|
||||||
|
- 13 vulnerabilites (2 low, 4 moderate, 5 high, 2 critical).
|
||||||
|
- Npm a suggere: `npm audit fix --force` (a evaluer avant execution).
|
||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
ATTACHMENTS_DIR: ./data/pieces_jointes
|
ATTACHMENTS_DIR: ./data/pieces_jointes
|
||||||
BACKUP_DIR: ./data/backups
|
BACKUP_DIR: ./data/backups
|
||||||
MAX_UPLOAD_MB: 50
|
MAX_UPLOAD_MB: 50
|
||||||
|
CONFIG_PATH: ./data/config.json
|
||||||
|
DEBUG_LOG_PATH: ./data/logs/backend.log
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||||
background: var(--bg);
|
background: linear-gradient(135deg, #f5f1e8 0%, #efe6d6 100%);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,45 @@ a {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(245, 241, 232, 0.9);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border-bottom: 1px solid #e3d8c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #3a2a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.router-link-active {
|
||||||
|
border-color: #c46b2d;
|
||||||
|
background: #fffaf2;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -42,4 +81,62 @@ a {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
|
box-shadow: 0 8px 20px rgba(60, 40, 20, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
border-top: 1px solid #e3d8c5;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.card:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.card:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 24px rgba(60, 40, 20, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #d9c9b2;
|
||||||
|
font: inherit;
|
||||||
|
background: #fffaf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 15, 10, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: #fffaf2;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e3d8c5;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
61
frontend/components/CategorieForm.vue
Normal file
61
frontend/components/CategorieForm.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ mode === 'edit' ? t('form.editCategorie') : t('form.createCategorie') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<input v-model="localForm.nom" :placeholder="t('form.nom')" />
|
||||||
|
<input v-model="localForm.parent_id" :placeholder="t('form.parentIdOpt')" />
|
||||||
|
<input v-model="localForm.slug" :placeholder="t('form.slug')" />
|
||||||
|
<input v-model="localForm.icone" :placeholder="t('form.icone')" />
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" :disabled="saving" @click="emitSave">
|
||||||
|
{{ saving ? t('form.saving') : t('form.save') }}
|
||||||
|
</button>
|
||||||
|
<button class="card" type="button" @click="emitCancel">{{ t('form.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="message">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type CategorieFormPayload = {
|
||||||
|
nom: string
|
||||||
|
parent_id: string
|
||||||
|
slug: string
|
||||||
|
icone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: CategorieFormPayload
|
||||||
|
saving: boolean
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
message?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: CategorieFormPayload): void
|
||||||
|
(e: 'save'): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localForm = reactive({ ...props.modelValue })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
Object.assign(localForm, value)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
localForm,
|
||||||
|
() => emit('update:modelValue', { ...localForm }),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const emitSave = () => emit('save')
|
||||||
|
const emitCancel = () => emit('cancel')
|
||||||
|
</script>
|
||||||
27
frontend/components/ConfirmDialog.vue
Normal file
27
frontend/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3>{{ title || t('confirm.title') }}</h3>
|
||||||
|
<p>{{ message || t('confirm.message') }}</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="card" type="button" @click="cancel">{{ t('actions.cancel') }}</button>
|
||||||
|
<button class="card" type="button" @click="confirm">{{ t('actions.confirm') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
message: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
|
|
||||||
|
const confirm = () => emit('confirm')
|
||||||
|
const cancel = () => emit('cancel')
|
||||||
|
</script>
|
||||||
63
frontend/components/EmplacementForm.vue
Normal file
63
frontend/components/EmplacementForm.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ mode === 'edit' ? t('form.editEmplacement') : t('form.createEmplacement') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<input v-model="localForm.nom" :placeholder="t('form.nom')" />
|
||||||
|
<input v-model="localForm.parent_id" :placeholder="t('form.parentIdOpt')" />
|
||||||
|
<input v-model="localForm.piece" :placeholder="t('form.piece')" />
|
||||||
|
<input v-model="localForm.meuble" :placeholder="t('form.meuble')" />
|
||||||
|
<input v-model="localForm.numero_boite" :placeholder="t('form.numeroBoite')" />
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" :disabled="saving" @click="emitSave">
|
||||||
|
{{ saving ? t('form.saving') : t('form.save') }}
|
||||||
|
</button>
|
||||||
|
<button class="card" type="button" @click="emitCancel">{{ t('form.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="message">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type EmplacementFormPayload = {
|
||||||
|
nom: string
|
||||||
|
parent_id: string
|
||||||
|
piece: string
|
||||||
|
meuble: string
|
||||||
|
numero_boite: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: EmplacementFormPayload
|
||||||
|
saving: boolean
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
message?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: EmplacementFormPayload): void
|
||||||
|
(e: 'save'): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localForm = reactive({ ...props.modelValue })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
Object.assign(localForm, value)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
localForm,
|
||||||
|
() => emit('update:modelValue', { ...localForm }),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const emitSave = () => emit('save')
|
||||||
|
const emitCancel = () => emit('cancel')
|
||||||
|
</script>
|
||||||
58
frontend/components/EmplacementPicker.vue
Normal file
58
frontend/components/EmplacementPicker.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<label style="display: grid; gap: 6px;">
|
||||||
|
<span>{{ t('labels.emplacement') }}</span>
|
||||||
|
<select v-model="selectedId">
|
||||||
|
<option value="">{{ t('labels.chooseEmplacement') }}</option>
|
||||||
|
<option v-for="opt in options" :key="opt.id" :value="opt.id">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type Emplacement = {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
parent_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option = { id: string; label: string }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: Emplacement[]
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedId = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = computed<Option[]>(() => {
|
||||||
|
const map = new Map(props.items.map((item) => [item.id, item]))
|
||||||
|
const cache = new Map<string, number>()
|
||||||
|
|
||||||
|
const depthOf = (item: Emplacement): number => {
|
||||||
|
if (!item.parent_id) return 0
|
||||||
|
if (cache.has(item.id)) return cache.get(item.id) || 0
|
||||||
|
const parent = map.get(item.parent_id)
|
||||||
|
const depth = parent ? depthOf(parent) + 1 : 0
|
||||||
|
cache.set(item.id, depth)
|
||||||
|
return depth
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.items
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: `${' '.repeat(depthOf(item) * 2)}${item.nom}`
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
94
frontend/components/FileUploader.vue
Normal file
94
frontend/components/FileUploader.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="card"
|
||||||
|
:style="dropStyle"
|
||||||
|
@dragenter.prevent="onDragEnter"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragleave.prevent="onDragLeave"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<p>{{ label || t('fileUploader.label') }}</p>
|
||||||
|
<input :disabled="disabled" type="file" multiple @change="onFilesSelected" />
|
||||||
|
<button class="card" type="button" :disabled="disabled" @click="emitUpload">
|
||||||
|
{{ buttonText || t('actions.upload') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="previews.length" class="grid" style="margin-top: 12px;">
|
||||||
|
<div v-for="preview in previews" :key="preview.name" class="card">
|
||||||
|
<img v-if="preview.url" :src="preview.url" :alt="preview.name" style="max-width: 100%;" />
|
||||||
|
<p>{{ preview.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
buttonText: { type: String, default: '' },
|
||||||
|
label: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'upload', files: FileList): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const files = ref<FileList | null>(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const previews = ref<{ name: string; url?: string }[]>([])
|
||||||
|
|
||||||
|
const dropStyle = computed(() => ({
|
||||||
|
border: isDragging.value ? '2px dashed #c46b2d' : '1px dashed #d9c9b2',
|
||||||
|
padding: '16px',
|
||||||
|
cursor: props.disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: props.disabled ? '0.6' : '1'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onFilesSelected = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
files.value = target.files
|
||||||
|
buildPreviews(target.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitUpload = () => {
|
||||||
|
if (!files.value || files.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('upload', files.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnter = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = () => {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (event: DragEvent) => {
|
||||||
|
if (props.disabled) return
|
||||||
|
isDragging.value = false
|
||||||
|
if (event.dataTransfer?.files) {
|
||||||
|
files.value = event.dataTransfer.files
|
||||||
|
buildPreviews(event.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPreviews = (fileList: FileList | null) => {
|
||||||
|
previews.value = []
|
||||||
|
if (!fileList) return
|
||||||
|
Array.from(fileList).forEach((file) => {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
previews.value.push({ name: file.name, url })
|
||||||
|
} else {
|
||||||
|
previews.value.push({ name: file.name })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
frontend/components/I18nStub.vue
Normal file
10
frontend/components/I18nStub.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card" style="margin-bottom: 16px;">
|
||||||
|
<h3>{{ t('i18n.title') }}</h3>
|
||||||
|
<p>{{ t('i18n.description') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
66
frontend/components/ObjetForm.vue
Normal file
66
frontend/components/ObjetForm.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ mode === 'edit' ? t('form.editObjet') : t('form.createObjet') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<input v-model="localForm.nom" :placeholder="t('form.nom')" />
|
||||||
|
<textarea v-model="localForm.description" rows="3" :placeholder="t('form.description')" />
|
||||||
|
<input v-model.number="localForm.quantite" type="number" :placeholder="t('form.quantite')" />
|
||||||
|
<select v-model="localForm.statut">
|
||||||
|
<option value="en_stock">en_stock</option>
|
||||||
|
<option value="pret">pret</option>
|
||||||
|
<option value="hors_service">hors_service</option>
|
||||||
|
<option value="archive">archive</option>
|
||||||
|
</select>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" :disabled="saving" @click="emitSave">
|
||||||
|
{{ saving ? t('form.saving') : t('form.save') }}
|
||||||
|
</button>
|
||||||
|
<button class="card" type="button" @click="emitCancel">{{ t('form.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="message">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
type ObjetFormPayload = {
|
||||||
|
nom: string
|
||||||
|
description: string
|
||||||
|
quantite: number
|
||||||
|
statut: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: ObjetFormPayload
|
||||||
|
saving: boolean
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
message?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: ObjetFormPayload): void
|
||||||
|
(e: 'save'): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localForm = reactive({ ...props.modelValue })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
Object.assign(localForm, value)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
localForm,
|
||||||
|
() => emit('update:modelValue', { ...localForm }),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const emitSave = () => emit('save')
|
||||||
|
const emitCancel = () => emit('cancel')
|
||||||
|
</script>
|
||||||
94
frontend/components/TreeList.vue
Normal file
94
frontend/components/TreeList.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<ul class="tree-list">
|
||||||
|
<li v-for="node in flatNodes" :key="node.id" :style="{ paddingLeft: `${node.depth * 12}px` }">
|
||||||
|
<button
|
||||||
|
v-if="node.hasChildren"
|
||||||
|
class="card"
|
||||||
|
type="button"
|
||||||
|
style="margin-right: 6px;"
|
||||||
|
@click="toggle(node.id)"
|
||||||
|
>
|
||||||
|
{{ isCollapsed(node.id) ? '+' : '-' }}
|
||||||
|
</button>
|
||||||
|
<span>{{ node.nom }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type TreeItem = {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
parent_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlatNode = TreeItem & {
|
||||||
|
depth: number
|
||||||
|
hasChildren: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ items: TreeItem[] }>()
|
||||||
|
|
||||||
|
const collapsed = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const childrenMap = computed(() => {
|
||||||
|
const map = new Map<string | null, TreeItem[]>()
|
||||||
|
props.items.forEach((item) => {
|
||||||
|
const key = item.parent_id || null
|
||||||
|
const list = map.get(key) || []
|
||||||
|
list.push(item)
|
||||||
|
map.set(key, list)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasChildren = (id: string) => {
|
||||||
|
const list = childrenMap.value.get(id)
|
||||||
|
return !!(list && list.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFlat = (parentId: string | null, depth: number, acc: FlatNode[]) => {
|
||||||
|
const children = childrenMap.value.get(parentId) || []
|
||||||
|
children
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.nom.localeCompare(b.nom))
|
||||||
|
.forEach((child) => {
|
||||||
|
acc.push({
|
||||||
|
...child,
|
||||||
|
depth,
|
||||||
|
hasChildren: hasChildren(child.id)
|
||||||
|
})
|
||||||
|
if (!collapsed.value.has(child.id)) {
|
||||||
|
buildFlat(child.id, depth + 1, acc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatNodes = computed(() => {
|
||||||
|
const acc: FlatNode[] = []
|
||||||
|
buildFlat(null, 0, acc)
|
||||||
|
return acc
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(collapsed.value)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
collapsed.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCollapsed = (id: string) => collapsed.value.has(id)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
frontend/composables/useApi.ts
Normal file
13
frontend/composables/useApi.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const useApi = () => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const apiBase = config.public.apiBase
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown, fallback: string) => {
|
||||||
|
const anyErr = err as { data?: { erreur?: string }; message?: string }
|
||||||
|
if (anyErr?.data?.erreur) return anyErr.data.erreur
|
||||||
|
if (anyErr?.message) return anyErr.message
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apiBase, getErrorMessage }
|
||||||
|
}
|
||||||
7
frontend/i18n.config.ts
Normal file
7
frontend/i18n.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import fr from './locales/fr.json'
|
||||||
|
|
||||||
|
export default defineI18nConfig(() => ({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'fr',
|
||||||
|
messages: { fr }
|
||||||
|
}))
|
||||||
30
frontend/layouts/default.vue
Normal file
30
frontend/layouts/default.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="container header-content">
|
||||||
|
<NuxtLink class="brand" to="/">MatosBox</NuxtLink>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<NuxtLink to="/objets">{{ t('nav.objets') }}</NuxtLink>
|
||||||
|
<NuxtLink to="/emplacements">{{ t('nav.emplacements') }}</NuxtLink>
|
||||||
|
<NuxtLink to="/categories">{{ t('nav.categories') }}</NuxtLink>
|
||||||
|
<NuxtLink to="/settings">{{ t('nav.settings') }}</NuxtLink>
|
||||||
|
<NuxtLink to="/debug">{{ t('nav.debug') }}</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<NuxtPage />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="container">
|
||||||
|
<small>{{ t('footer.text') }}</small>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
144
frontend/locales/fr.json
Normal file
144
frontend/locales/fr.json
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"objets": "Objets",
|
||||||
|
"emplacements": "Emplacements",
|
||||||
|
"categories": "Categories",
|
||||||
|
"settings": "Settings",
|
||||||
|
"debug": "Debug"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "MatosBox",
|
||||||
|
"subtitle": "Inventaire simple pour le materiel, les composants et les outils."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open": "Ouvrir",
|
||||||
|
"edit": "Editer",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"reload": "Recharger",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"copy": "Copier",
|
||||||
|
"confirm": "Confirmer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"upload": "Uploader",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"setPrimary": "Principale",
|
||||||
|
"apply": "Appliquer"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"name": "Nom",
|
||||||
|
"status": "Statut",
|
||||||
|
"limit": "Limite",
|
||||||
|
"reset": "Reinitialiser"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"page": "Page",
|
||||||
|
"prev": "Precedent",
|
||||||
|
"next": "Suivant"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"nom": "Nom",
|
||||||
|
"description": "Description",
|
||||||
|
"quantite": "Quantite",
|
||||||
|
"statut": "Statut",
|
||||||
|
"parentIdOpt": "Parent ID (optionnel)",
|
||||||
|
"slug": "Slug",
|
||||||
|
"icone": "Icone",
|
||||||
|
"piece": "Piece",
|
||||||
|
"meuble": "Meuble",
|
||||||
|
"numeroBoite": "Numero boite",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saving": "En cours...",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"createObjet": "Creer un objet",
|
||||||
|
"editObjet": "Modifier un objet",
|
||||||
|
"createCategorie": "Creer une categorie",
|
||||||
|
"editCategorie": "Modifier une categorie",
|
||||||
|
"createEmplacement": "Creer un emplacement",
|
||||||
|
"editEmplacement": "Modifier un emplacement"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"name": "Nom",
|
||||||
|
"description": "Description",
|
||||||
|
"value": "Valeur",
|
||||||
|
"unit": "Unite",
|
||||||
|
"emplacementId": "ID emplacement",
|
||||||
|
"parentIdOpt": "Parent ID (optionnel)"
|
||||||
|
},
|
||||||
|
"states": {
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"empty": "Aucun element."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load": "Erreur de chargement.",
|
||||||
|
"copy": "Impossible de copier.",
|
||||||
|
"invalidJson": "Erreur: JSON invalide."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"requiredName": "Le nom est obligatoire.",
|
||||||
|
"created": "Cree.",
|
||||||
|
"updated": "Mis a jour.",
|
||||||
|
"deleted": "Supprime.",
|
||||||
|
"uploadDone": "Upload termine.",
|
||||||
|
"noFiles": "Aucun fichier selectionne.",
|
||||||
|
"uploadError": "Erreur: upload impossible.",
|
||||||
|
"saveError": "Erreur lors de la sauvegarde.",
|
||||||
|
"deleteError": "Erreur lors de la suppression.",
|
||||||
|
"loadError": "Impossible de charger les donnees.",
|
||||||
|
"copied": "Logs copies."
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"objetDetail": "Fiche objet"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"piecesJointes": "Pieces jointes",
|
||||||
|
"champs": "Champs personnalises",
|
||||||
|
"liensEmplacements": "Liens emplacements"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"title": "Arborescence"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"configJson": "Configuration JSON",
|
||||||
|
"timezone": "Timezone",
|
||||||
|
"applyTimezone": "Appliquer la timezone",
|
||||||
|
"description": "Configuration backend + frontend (config.json)."
|
||||||
|
},
|
||||||
|
"fileUploader": {
|
||||||
|
"label": "Deposer des images, PDF ou fichiers Markdown."
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"title": "I18n (bientot)",
|
||||||
|
"description": "Integration Weblate et traduction UI a planifier."
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"emplacement": "Emplacement",
|
||||||
|
"chooseEmplacement": "Choisir un emplacement",
|
||||||
|
"slug": "Slug",
|
||||||
|
"icone": "Icone",
|
||||||
|
"parent": "Parent",
|
||||||
|
"piece": "Piece",
|
||||||
|
"meuble": "Meuble",
|
||||||
|
"numeroBoite": "Boite"
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"title": "Confirmation",
|
||||||
|
"message": "Confirmer la suppression ?",
|
||||||
|
"deleteObjetTitle": "Supprimer l'objet",
|
||||||
|
"deleteObjetMessage": "Confirmer la suppression de l'objet ?",
|
||||||
|
"deleteCategorieTitle": "Supprimer la categorie",
|
||||||
|
"deleteCategorieMessage": "Confirmer la suppression de la categorie ?",
|
||||||
|
"deleteEmplacementTitle": "Supprimer l'emplacement",
|
||||||
|
"deleteEmplacementMessage": "Confirmer la suppression de l'emplacement ?",
|
||||||
|
"deletePieceTitle": "Supprimer la piece jointe",
|
||||||
|
"deletePieceMessage": "Confirmer la suppression de la piece jointe ?",
|
||||||
|
"deleteChampTitle": "Supprimer le champ",
|
||||||
|
"deleteChampMessage": "Confirmer la suppression du champ personnalise ?"
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"readonly": "Logs backend (lecture seule).",
|
||||||
|
"autoRefresh": "Rafraichissement auto (5s)"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"text": "MatosBox - inventaire local"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: 'http://localhost:8080/v1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modules: ['@nuxtjs/i18n'],
|
||||||
|
i18n: {
|
||||||
|
strategy: 'no_prefix',
|
||||||
|
defaultLocale: 'fr',
|
||||||
|
locales: [{ code: 'fr', name: 'Francais' }],
|
||||||
|
vueI18n: './i18n.config.ts'
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
title: 'MatosBox',
|
title: 'MatosBox',
|
||||||
|
|||||||
11543
frontend/package-lock.json
generated
Normal file
11543
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"preview": "nuxt preview"
|
"preview": "nuxt preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nuxt": "3.12.3"
|
"@nuxtjs/i18n": "8.3.1",
|
||||||
|
"nuxt": "^3.20.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "24.1.0",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
212
frontend/pages/categories/index.vue
Normal file
212
frontend/pages/categories/index.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container">
|
||||||
|
<h1>{{ t('nav.categories') }}</h1>
|
||||||
|
<p v-if="errorMessage">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ t('filters.name') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<input v-model="filterNom" :placeholder="t('placeholders.name')" />
|
||||||
|
<label>
|
||||||
|
{{ t('filters.limit') }}
|
||||||
|
<select v-model.number="limit">
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="25">25</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" @click="resetFilters">
|
||||||
|
{{ t('filters.reset') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CategorieForm
|
||||||
|
v-model="form"
|
||||||
|
:saving="saving"
|
||||||
|
:message="message"
|
||||||
|
:mode="editingId ? 'edit' : 'create'"
|
||||||
|
@save="saveCategorie"
|
||||||
|
@cancel="resetForm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="pending">{{ t('states.loading') }}</p>
|
||||||
|
<p v-else-if="items.length === 0">{{ t('states.empty') }}</p>
|
||||||
|
<section v-else class="grid">
|
||||||
|
<article v-for="item in items" :key="item.id" class="card">
|
||||||
|
<h3>{{ item.nom }}</h3>
|
||||||
|
<p v-if="item.slug">{{ t('labels.slug') }}: {{ item.slug }}</p>
|
||||||
|
<p v-if="item.icone">{{ t('labels.icone') }}: {{ item.icone }}</p>
|
||||||
|
<small v-if="item.parent_id">{{ t('labels.parent') }}: {{ item.parent_id }}</small>
|
||||||
|
<div style="margin-top: 8px; display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" @click="editCategorie(item)">{{ t('actions.edit') }}</button>
|
||||||
|
<button class="card" type="button" @click="confirmDelete(item.id)">{{ t('actions.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="items.length" class="card" style="margin-top: 16px;">
|
||||||
|
<h2>{{ t('tree.title') }}</h2>
|
||||||
|
<TreeList :items="items" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 16px;">
|
||||||
|
<p>{{ t('pagination.page') }} {{ page }} / {{ totalPages }}</p>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" :disabled="page <= 1" @click="page--">
|
||||||
|
{{ t('pagination.prev') }}
|
||||||
|
</button>
|
||||||
|
<button class="card" type="button" :disabled="page >= totalPages" @click="page++">
|
||||||
|
{{ t('pagination.next') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmOpen"
|
||||||
|
:title="t('confirm.deleteCategorieTitle')"
|
||||||
|
:message="t('confirm.deleteCategorieMessage')"
|
||||||
|
@confirm="runConfirm"
|
||||||
|
@cancel="closeConfirm"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type Categorie = {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
parent_id?: string | null
|
||||||
|
slug?: string | null
|
||||||
|
icone?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(50)
|
||||||
|
const filterNom = ref('')
|
||||||
|
|
||||||
|
const { data, pending, error, refresh } = await useFetch<{
|
||||||
|
items: Categorie[]
|
||||||
|
meta?: { total: number; page: number; limit: number }
|
||||||
|
}>(`${apiBase}/categories`, {
|
||||||
|
query: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
nom: filterNom
|
||||||
|
},
|
||||||
|
watch: [page, limit, filterNom]
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = computed(() => data.value?.items ?? [])
|
||||||
|
const total = computed(() => data.value?.meta?.total ?? items.value.length)
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
|
||||||
|
const errorMessage = computed(() =>
|
||||||
|
error.value ? getErrorMessage(error.value, t('messages.loadError')) : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const editingId = ref<string | null>(null)
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const confirmAction = ref<null | (() => Promise<void>)>(null)
|
||||||
|
const form = ref({
|
||||||
|
nom: '',
|
||||||
|
parent_id: '',
|
||||||
|
slug: '',
|
||||||
|
icone: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([filterNom, limit], () => {
|
||||||
|
page.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filterNom.value = ''
|
||||||
|
limit.value = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = { nom: '', parent_id: '', slug: '', icone: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const editCategorie = (item: Categorie) => {
|
||||||
|
editingId.value = item.id
|
||||||
|
form.value = {
|
||||||
|
nom: item.nom,
|
||||||
|
parent_id: item.parent_id || '',
|
||||||
|
slug: item.slug || '',
|
||||||
|
icone: item.icone || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCategorie = async () => {
|
||||||
|
message.value = ''
|
||||||
|
if (!form.value.nom) {
|
||||||
|
message.value = t('messages.requiredName')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
nom: form.value.nom,
|
||||||
|
parent_id: form.value.parent_id || undefined,
|
||||||
|
slug: form.value.slug || undefined,
|
||||||
|
icone: form.value.icone || undefined
|
||||||
|
}
|
||||||
|
if (editingId.value) {
|
||||||
|
await $fetch(`${apiBase}/categories/${editingId.value}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
message.value = t('messages.updated')
|
||||||
|
} else {
|
||||||
|
await $fetch(`${apiBase}/categories`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
message.value = t('messages.created')
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCategorie = async (id: string) => {
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/categories/${id}`, { method: 'DELETE' })
|
||||||
|
message.value = t('messages.deleted')
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('messages.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (id: string) => {
|
||||||
|
confirmAction.value = () => deleteCategorie(id)
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConfirm = () => {
|
||||||
|
confirmOpen.value = false
|
||||||
|
confirmAction.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const runConfirm = async () => {
|
||||||
|
if (confirmAction.value) {
|
||||||
|
await confirmAction.value()
|
||||||
|
}
|
||||||
|
closeConfirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
64
frontend/pages/debug.vue
Normal file
64
frontend/pages/debug.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container">
|
||||||
|
<h1>{{ t('nav.debug') }}</h1>
|
||||||
|
<p>{{ t('debug.readonly') }}</p>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<p v-if="message">{{ message }}</p>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<input type="checkbox" v-model="autoRefresh" />
|
||||||
|
{{ t('debug.autoRefresh') }}
|
||||||
|
</label>
|
||||||
|
<pre style="white-space: pre-wrap;">{{ logs }}</pre>
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
<button class="card" type="button" @click="reload">{{ t('actions.reload') }}</button>
|
||||||
|
<button class="card" type="button" @click="copyLogs">{{ t('actions.copy') }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const logs = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const autoRefresh = ref(false)
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ logs: string }>(`${apiBase}/debug/logs`)
|
||||||
|
logs.value = data?.logs || ''
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('errors.load'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyLogs = async () => {
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(logs.value || '')
|
||||||
|
message.value = t('messages.copied')
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('errors.copy'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(autoRefresh, (enabled) => {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = null
|
||||||
|
}
|
||||||
|
if (enabled) {
|
||||||
|
intervalId = setInterval(reload, 5000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(reload)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (intervalId) clearInterval(intervalId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,216 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<h1>Emplacements</h1>
|
<h1>{{ t('nav.emplacements') }}</h1>
|
||||||
<p>Arborescence a connecter a l'API.</p>
|
<p v-if="errorMessage">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ t('filters.name') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<input v-model="filterNom" :placeholder="t('placeholders.name')" />
|
||||||
|
<label>
|
||||||
|
{{ t('filters.limit') }}
|
||||||
|
<select v-model.number="limit">
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="25">25</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" @click="resetFilters">
|
||||||
|
{{ t('filters.reset') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<EmplacementForm
|
||||||
|
v-model="form"
|
||||||
|
:saving="saving"
|
||||||
|
:message="message"
|
||||||
|
:mode="editingId ? 'edit' : 'create'"
|
||||||
|
@save="saveEmplacement"
|
||||||
|
@cancel="resetForm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="pending">{{ t('states.loading') }}</p>
|
||||||
|
<p v-else-if="items.length === 0">{{ t('states.empty') }}</p>
|
||||||
|
<section v-else class="grid">
|
||||||
|
<article v-for="item in items" :key="item.id" class="card">
|
||||||
|
<h3>{{ item.nom }}</h3>
|
||||||
|
<p v-if="item.piece">{{ t('labels.piece') }}: {{ item.piece }}</p>
|
||||||
|
<p v-if="item.meuble">{{ t('labels.meuble') }}: {{ item.meuble }}</p>
|
||||||
|
<small v-if="item.numero_boite">{{ t('labels.numeroBoite') }}: {{ item.numero_boite }}</small>
|
||||||
|
<div style="margin-top: 8px; display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" @click="editEmplacement(item)">{{ t('actions.edit') }}</button>
|
||||||
|
<button class="card" type="button" @click="confirmDelete(item.id)">{{ t('actions.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="items.length" class="card" style="margin-top: 16px;">
|
||||||
|
<h2>{{ t('tree.title') }}</h2>
|
||||||
|
<TreeList :items="items" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 16px;">
|
||||||
|
<p>{{ t('pagination.page') }} {{ page }} / {{ totalPages }}</p>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" :disabled="page <= 1" @click="page--">
|
||||||
|
{{ t('pagination.prev') }}
|
||||||
|
</button>
|
||||||
|
<button class="card" type="button" :disabled="page >= totalPages" @click="page++">
|
||||||
|
{{ t('pagination.next') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmOpen"
|
||||||
|
:title="t('confirm.deleteEmplacementTitle')"
|
||||||
|
:message="t('confirm.deleteEmplacementMessage')"
|
||||||
|
@confirm="runConfirm"
|
||||||
|
@cancel="closeConfirm"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type Emplacement = {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
parent_id?: string | null
|
||||||
|
piece?: string | null
|
||||||
|
meuble?: string | null
|
||||||
|
numero_boite?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(50)
|
||||||
|
const filterNom = ref('')
|
||||||
|
|
||||||
|
const { data, pending, error, refresh } = await useFetch<{
|
||||||
|
items: Emplacement[]
|
||||||
|
meta?: { total: number; page: number; limit: number }
|
||||||
|
}>(`${apiBase}/emplacements`, {
|
||||||
|
query: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
nom: filterNom
|
||||||
|
},
|
||||||
|
watch: [page, limit, filterNom]
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = computed(() => data.value?.items ?? [])
|
||||||
|
const total = computed(() => data.value?.meta?.total ?? items.value.length)
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
|
||||||
|
const errorMessage = computed(() =>
|
||||||
|
error.value ? getErrorMessage(error.value, t('messages.loadError')) : ""
|
||||||
|
)
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const message = ref("")
|
||||||
|
const editingId = ref<string | null>(null)
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const confirmAction = ref<null | (() => Promise<void>)>(null)
|
||||||
|
const form = ref({
|
||||||
|
nom: "",
|
||||||
|
parent_id: "",
|
||||||
|
piece: "",
|
||||||
|
meuble: "",
|
||||||
|
numero_boite: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([filterNom, limit], () => {
|
||||||
|
page.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filterNom.value = ''
|
||||||
|
limit.value = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = { nom: "", parent_id: "", piece: "", meuble: "", numero_boite: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const editEmplacement = (item: Emplacement) => {
|
||||||
|
editingId.value = item.id
|
||||||
|
form.value = {
|
||||||
|
nom: item.nom,
|
||||||
|
parent_id: item.parent_id || "",
|
||||||
|
piece: item.piece || "",
|
||||||
|
meuble: item.meuble || "",
|
||||||
|
numero_boite: item.numero_boite || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEmplacement = async () => {
|
||||||
|
message.value = ""
|
||||||
|
if (!form.value.nom) {
|
||||||
|
message.value = t('messages.requiredName')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
nom: form.value.nom,
|
||||||
|
parent_id: form.value.parent_id || undefined,
|
||||||
|
piece: form.value.piece || undefined,
|
||||||
|
meuble: form.value.meuble || undefined,
|
||||||
|
numero_boite: form.value.numero_boite || undefined
|
||||||
|
}
|
||||||
|
if (editingId.value) {
|
||||||
|
await $fetch(`${apiBase}/emplacements/${editingId.value}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
message.value = t('messages.updated')
|
||||||
|
} else {
|
||||||
|
await $fetch(`${apiBase}/emplacements`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
message.value = t('messages.created')
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteEmplacement = async (id: string) => {
|
||||||
|
message.value = ""
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/emplacements/${id}`, { method: "DELETE" })
|
||||||
|
message.value = t('messages.deleted')
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('messages.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (id: string) => {
|
||||||
|
confirmAction.value = () => deleteEmplacement(id)
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConfirm = () => {
|
||||||
|
confirmOpen.value = false
|
||||||
|
confirmAction.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const runConfirm = async () => {
|
||||||
|
if (confirmAction.value) {
|
||||||
|
await confirmAction.value()
|
||||||
|
}
|
||||||
|
closeConfirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>MatosBox</h1>
|
<h1>{{ t('home.title') }}</h1>
|
||||||
<p>Inventaire simple pour le materiel, les composants et les outils.</p>
|
<p>{{ t('home.subtitle') }}</p>
|
||||||
<nav class="grid">
|
<nav class="grid">
|
||||||
<NuxtLink class="card" to="/objets">Voir les objets</NuxtLink>
|
<NuxtLink class="card" to="/objets">{{ t('nav.objets') }}</NuxtLink>
|
||||||
<NuxtLink class="card" to="/emplacements">Voir les emplacements</NuxtLink>
|
<NuxtLink class="card" to="/emplacements">{{ t('nav.emplacements') }}</NuxtLink>
|
||||||
|
<NuxtLink class="card" to="/categories">{{ t('nav.categories') }}</NuxtLink>
|
||||||
|
<NuxtLink class="card" to="/settings">{{ t('nav.settings') }}</NuxtLink>
|
||||||
|
<NuxtLink class="card" to="/debug">{{ t('nav.debug') }}</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,371 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<h1>Fiche objet</h1>
|
<h1>{{ t('pages.objetDetail') }}</h1>
|
||||||
<p>Detail a connecter a l'API.</p>
|
<p v-if="errorMessage">{{ errorMessage }}</p>
|
||||||
|
<p v-else-if="pending">{{ t('states.loading') }}</p>
|
||||||
|
<section v-else class="card">
|
||||||
|
<h2>{{ item?.nom }}</h2>
|
||||||
|
<p v-if="item?.description">{{ item?.description }}</p>
|
||||||
|
<p>{{ t('form.quantite') }}: {{ item?.quantite ?? 0 }}</p>
|
||||||
|
<p>{{ t('form.statut') }}: {{ item?.statut }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!pending" class="card" style="margin-top: 16px;">
|
||||||
|
<h3>{{ t('sections.piecesJointes') }}</h3>
|
||||||
|
<div style="margin: 8px 0 12px;">
|
||||||
|
<FileUploader
|
||||||
|
:disabled="isUploading"
|
||||||
|
:label="t('fileUploader.label')"
|
||||||
|
@upload="uploadFiles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadMessage">{{ uploadMessage }}</p>
|
||||||
|
<p v-if="piecesPending">{{ t('states.loading') }}</p>
|
||||||
|
<p v-else-if="piecesError">{{ piecesErrorMessage }}</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="pj in piecesJointes" :key="pj.id">
|
||||||
|
{{ pj.nom_fichier }} ({{ pj.categorie }})
|
||||||
|
<span v-if="pj.est_principale" style="margin-left: 8px;">[Principale]</span>
|
||||||
|
<button
|
||||||
|
class="card"
|
||||||
|
type="button"
|
||||||
|
style="margin-left: 8px;"
|
||||||
|
@click="setPrincipale(pj.id)"
|
||||||
|
>
|
||||||
|
{{ t('actions.setPrimary') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="card"
|
||||||
|
type="button"
|
||||||
|
style="margin-left: 8px;"
|
||||||
|
@click="confirmDeletePieceJointe(pj.id)"
|
||||||
|
>
|
||||||
|
{{ t('actions.delete') }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!pending" class="card" style="margin-top: 16px;">
|
||||||
|
<h3>{{ t('sections.champs') }}</h3>
|
||||||
|
<p v-if="champMessage">{{ champMessage }}</p>
|
||||||
|
<div style="margin: 8px 0 12px; display: grid; gap: 8px;">
|
||||||
|
<input v-model="newChamp.nom_champ" :placeholder="t('placeholders.name')" />
|
||||||
|
<input v-model="newChamp.valeur" :placeholder="t('placeholders.value')" />
|
||||||
|
<input v-model="newChamp.unite" :placeholder="t('placeholders.unit')" />
|
||||||
|
<select v-model="newChamp.type_champ">
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="int">int</option>
|
||||||
|
<option value="bool">bool</option>
|
||||||
|
<option value="date">date</option>
|
||||||
|
</select>
|
||||||
|
<button class="card" type="button" @click="createChamp">{{ t('actions.add') }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="champsPending">{{ t('states.loading') }}</p>
|
||||||
|
<p v-else-if="champsError">{{ champsErrorMessage }}</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="champ in champsEditable" :key="champ.id">
|
||||||
|
<div style="display: grid; gap: 6px; margin-bottom: 10px;">
|
||||||
|
<input v-model="champ.nom_champ" />
|
||||||
|
<input v-model="champ.valeur" />
|
||||||
|
<input v-model="champ.unite" />
|
||||||
|
<select v-model="champ.type_champ">
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="int">int</option>
|
||||||
|
<option value="bool">bool</option>
|
||||||
|
<option value="date">date</option>
|
||||||
|
</select>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" @click="updateChamp(champ)">{{ t('actions.save') }}</button>
|
||||||
|
<button class="card" type="button" @click="confirmDeleteChamp(champ.id)">{{ t('actions.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="!pending" class="card" style="margin-top: 16px;">
|
||||||
|
<h3>{{ t('sections.liensEmplacements') }}</h3>
|
||||||
|
<p v-if="lienMessage">{{ lienMessage }}</p>
|
||||||
|
<div style="margin: 8px 0 12px; display: grid; gap: 8px;">
|
||||||
|
<EmplacementPicker v-model="newLien.emplacement_id" :items="emplacements" />
|
||||||
|
<select v-model="newLien.type">
|
||||||
|
<option value="stocke">stocke</option>
|
||||||
|
<option value="utilise_dans">utilise_dans</option>
|
||||||
|
</select>
|
||||||
|
<button class="card" type="button" @click="createLien">{{ t('actions.add') }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="liensPending">{{ t('states.loading') }}</p>
|
||||||
|
<p v-else-if="liensError">{{ liensErrorMessage }}</p>
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="lien in liens" :key="lien.id">
|
||||||
|
{{ emplacementLabel(lien.emplacement_id) }} ({{ lien.type }})
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmOpen"
|
||||||
|
:title="confirmTitle"
|
||||||
|
:message="confirmMessage"
|
||||||
|
@confirm="runConfirm"
|
||||||
|
@cancel="closeConfirm"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type Objet = {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
description?: string | null
|
||||||
|
quantite: number
|
||||||
|
statut: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PieceJointe = {
|
||||||
|
id: string
|
||||||
|
nom_fichier: string
|
||||||
|
categorie: string
|
||||||
|
est_principale?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChampPersonnalise = {
|
||||||
|
id: string
|
||||||
|
nom_champ: string
|
||||||
|
valeur?: string | null
|
||||||
|
unite?: string | null
|
||||||
|
type_champ: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LienEmplacement = {
|
||||||
|
id: string
|
||||||
|
emplacement_id: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Emplacement = {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
parent_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const objetId = route.params.id
|
||||||
|
|
||||||
|
const { data, pending, error } = await useFetch<Objet>(
|
||||||
|
`${apiBase}/objets/${objetId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: piecesData, pending: piecesPending, error: piecesError, refresh: refreshPieces } =
|
||||||
|
await useFetch<{ items: PieceJointe[] }>(
|
||||||
|
`${apiBase}/objets/${objetId}/pieces_jointes?limit=50`
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: champsData, pending: champsPending, error: champsError, refresh: refreshChamps } =
|
||||||
|
await useFetch<{ items: ChampPersonnalise[] }>(
|
||||||
|
`${apiBase}/objets/${objetId}/champs_personnalises?limit=50`
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: liensData, pending: liensPending, error: liensError, refresh: refreshLiens } =
|
||||||
|
await useFetch<{ items: LienEmplacement[] }>(
|
||||||
|
`${apiBase}/objets/${objetId}/liens_emplacements?limit=50`
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: emplacementsData } = await useFetch<{ items: Emplacement[] }>(
|
||||||
|
`${apiBase}/emplacements?limit=200`
|
||||||
|
)
|
||||||
|
|
||||||
|
const item = computed(() => data.value ?? null)
|
||||||
|
const errorMessage = computed(() =>
|
||||||
|
error.value ? getErrorMessage(error.value, t('messages.loadError')) : ""
|
||||||
|
)
|
||||||
|
const piecesErrorMessage = computed(() =>
|
||||||
|
piecesError.value ? getErrorMessage(piecesError.value, t('messages.loadError')) : ""
|
||||||
|
)
|
||||||
|
const champsErrorMessage = computed(() =>
|
||||||
|
champsError.value ? getErrorMessage(champsError.value, t('messages.loadError')) : ""
|
||||||
|
)
|
||||||
|
const liensErrorMessage = computed(() =>
|
||||||
|
liensError.value ? getErrorMessage(liensError.value, t('messages.loadError')) : ""
|
||||||
|
)
|
||||||
|
const piecesJointes = computed(() => piecesData.value?.items ?? [])
|
||||||
|
const champsPersonnalises = computed(() => champsData.value?.items ?? [])
|
||||||
|
const liens = computed(() => liensData.value?.items ?? [])
|
||||||
|
const emplacements = computed(() => emplacementsData.value?.items ?? [])
|
||||||
|
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const uploadMessage = ref('')
|
||||||
|
const champMessage = ref('')
|
||||||
|
const lienMessage = ref('')
|
||||||
|
const newChamp = ref({
|
||||||
|
nom_champ: "",
|
||||||
|
valeur: "",
|
||||||
|
unite: "",
|
||||||
|
type_champ: "string"
|
||||||
|
})
|
||||||
|
const champsEditable = ref<ChampPersonnalise[]>([])
|
||||||
|
const newLien = ref({
|
||||||
|
emplacement_id: "",
|
||||||
|
type: "stocke"
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(champsPersonnalises, (items) => {
|
||||||
|
champsEditable.value = items.map((champ) => ({ ...champ }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadFiles = async (files: FileList) => {
|
||||||
|
uploadMessage.value = ''
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
uploadMessage.value = t('messages.noFiles')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isUploading.value = true
|
||||||
|
const formData = new FormData()
|
||||||
|
Array.from(files).forEach((file) => {
|
||||||
|
formData.append("fichiers", file)
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/objets/${objetId}/pieces_jointes`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
uploadMessage.value = t('messages.uploadDone')
|
||||||
|
await refreshPieces()
|
||||||
|
} catch (err) {
|
||||||
|
uploadMessage.value = getErrorMessage(err, t('messages.uploadError'))
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createChamp = async () => {
|
||||||
|
champMessage.value = ''
|
||||||
|
if (!newChamp.value.nom_champ) {
|
||||||
|
champMessage.value = t('messages.requiredName')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/objets/${objetId}/champs_personnalises`, {
|
||||||
|
method: "POST",
|
||||||
|
body: newChamp.value
|
||||||
|
})
|
||||||
|
newChamp.value = { nom_champ: "", valeur: "", unite: "", type_champ: "string" }
|
||||||
|
champMessage.value = t('messages.created')
|
||||||
|
await refreshChamps()
|
||||||
|
} catch (err) {
|
||||||
|
champMessage.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChamp = async (champ: ChampPersonnalise) => {
|
||||||
|
champMessage.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/champs_personnalises/${champ.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: champ
|
||||||
|
})
|
||||||
|
champMessage.value = t('messages.updated')
|
||||||
|
await refreshChamps()
|
||||||
|
} catch (err) {
|
||||||
|
champMessage.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteChamp = async (id: string) => {
|
||||||
|
champMessage.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/champs_personnalises/${id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
champMessage.value = t('messages.deleted')
|
||||||
|
await refreshChamps()
|
||||||
|
} catch (err) {
|
||||||
|
champMessage.value = getErrorMessage(err, t('messages.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createLien = async () => {
|
||||||
|
lienMessage.value = ''
|
||||||
|
if (!newLien.value.emplacement_id) {
|
||||||
|
lienMessage.value = t('messages.requiredName')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/objets/${objetId}/liens_emplacements`, {
|
||||||
|
method: "POST",
|
||||||
|
body: newLien.value
|
||||||
|
})
|
||||||
|
newLien.value = { emplacement_id: "", type: "stocke" }
|
||||||
|
lienMessage.value = t('messages.created')
|
||||||
|
await refreshLiens()
|
||||||
|
} catch (err) {
|
||||||
|
lienMessage.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePieceJointe = async (id: string) => {
|
||||||
|
uploadMessage.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/pieces_jointes/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
uploadMessage.value = t('messages.deleted')
|
||||||
|
await refreshPieces()
|
||||||
|
} catch (err) {
|
||||||
|
uploadMessage.value = getErrorMessage(err, t('messages.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPrincipale = async (id: string) => {
|
||||||
|
uploadMessage.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/pieces_jointes/${id}/principale`, {
|
||||||
|
method: 'PUT'
|
||||||
|
})
|
||||||
|
uploadMessage.value = t('messages.updated')
|
||||||
|
await refreshPieces()
|
||||||
|
} catch (err) {
|
||||||
|
uploadMessage.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const confirmTitle = ref('Confirmation')
|
||||||
|
const confirmMessage = ref('Confirmer la suppression ?')
|
||||||
|
const confirmAction = ref<null | (() => Promise<void>)>(null)
|
||||||
|
|
||||||
|
const confirmDeletePieceJointe = (id: string) => {
|
||||||
|
confirmTitle.value = t('confirm.deletePieceTitle')
|
||||||
|
confirmMessage.value = t('confirm.deletePieceMessage')
|
||||||
|
confirmAction.value = () => deletePieceJointe(id)
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteChamp = (id: string) => {
|
||||||
|
confirmTitle.value = t('confirm.deleteChampTitle')
|
||||||
|
confirmMessage.value = t('confirm.deleteChampMessage')
|
||||||
|
confirmAction.value = () => deleteChamp(id)
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConfirm = () => {
|
||||||
|
confirmOpen.value = false
|
||||||
|
confirmAction.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const runConfirm = async () => {
|
||||||
|
if (confirmAction.value) {
|
||||||
|
await confirmAction.value()
|
||||||
|
}
|
||||||
|
closeConfirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const emplacementLabel = (id: string) => {
|
||||||
|
const item = emplacements.value.find((e) => e.id === id)
|
||||||
|
if (!item) return id
|
||||||
|
return item.nom
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,212 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<h1>Objets</h1>
|
<h1>{{ t('nav.objets') }}</h1>
|
||||||
<p>Liste a connecter a l'API.</p>
|
<p v-if="errorMessage">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<section class="card" style="margin-bottom: 16px;">
|
||||||
|
<h2>{{ t('filters.name') }}</h2>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
<input v-model="filterNom" :placeholder="t('placeholders.name')" />
|
||||||
|
<select v-model="filterStatut">
|
||||||
|
<option value="">{{ t('filters.status') }}</option>
|
||||||
|
<option value="en_stock">en_stock</option>
|
||||||
|
<option value="pret">pret</option>
|
||||||
|
<option value="hors_service">hors_service</option>
|
||||||
|
<option value="archive">archive</option>
|
||||||
|
</select>
|
||||||
|
<label>
|
||||||
|
{{ t('filters.limit') }}
|
||||||
|
<select v-model.number="limit">
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="25">25</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" @click="resetFilters">
|
||||||
|
{{ t('filters.reset') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ObjetForm
|
||||||
|
v-model="form"
|
||||||
|
:saving="saving"
|
||||||
|
:message="message"
|
||||||
|
:mode="editingId ? 'edit' : 'create'"
|
||||||
|
@save="saveObjet"
|
||||||
|
@cancel="resetForm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="pending">{{ t('states.loading') }}</p>
|
||||||
|
<p v-else-if="items.length === 0">{{ t('states.empty') }}</p>
|
||||||
|
<section v-else class="grid">
|
||||||
|
<article v-for="item in items" :key="item.id" class="card">
|
||||||
|
<h3>{{ item.nom }}</h3>
|
||||||
|
<p v-if="item.description">{{ item.description }}</p>
|
||||||
|
<small>{{ t('form.statut') }}: {{ item.statut }}</small>
|
||||||
|
<div style="margin-top: 8px; display: flex; gap: 8px;">
|
||||||
|
<NuxtLink class="card" :to="`/objets/${item.id}`">{{ t('actions.open') }}</NuxtLink>
|
||||||
|
<button class="card" type="button" @click="editObjet(item)">{{ t('actions.edit') }}</button>
|
||||||
|
<button class="card" type="button" @click="confirmDelete(item.id)">
|
||||||
|
{{ t('actions.delete') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 16px;">
|
||||||
|
<p>{{ t('pagination.page') }} {{ page }} / {{ totalPages }}</p>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="card" type="button" :disabled="page <= 1" @click="page--">
|
||||||
|
{{ t('pagination.prev') }}
|
||||||
|
</button>
|
||||||
|
<button class="card" type="button" :disabled="page >= totalPages" @click="page++">
|
||||||
|
{{ t('pagination.next') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmOpen"
|
||||||
|
:title="t('confirm.deleteObjetTitle')"
|
||||||
|
:message="t('confirm.deleteObjetMessage')"
|
||||||
|
@confirm="runConfirm"
|
||||||
|
@cancel="closeConfirm"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type Objet = {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
description?: string | null
|
||||||
|
quantite: number
|
||||||
|
statut: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(50)
|
||||||
|
const filterNom = ref('')
|
||||||
|
const filterStatut = ref('')
|
||||||
|
|
||||||
|
const { data, pending, error, refresh } = await useFetch<{
|
||||||
|
items: Objet[]
|
||||||
|
meta?: { total: number; page: number; limit: number }
|
||||||
|
}>(`${apiBase}/objets`, {
|
||||||
|
query: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
nom: filterNom,
|
||||||
|
statut: filterStatut
|
||||||
|
},
|
||||||
|
watch: [page, limit, filterNom, filterStatut]
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = computed(() => data.value?.items ?? [])
|
||||||
|
const total = computed(() => data.value?.meta?.total ?? items.value.length)
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
|
||||||
|
const errorMessage = computed(() =>
|
||||||
|
error.value ? getErrorMessage(error.value, t('messages.loadError')) : ""
|
||||||
|
)
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const message = ref("")
|
||||||
|
const editingId = ref<string | null>(null)
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const confirmAction = ref<null | (() => Promise<void>)>(null)
|
||||||
|
const form = ref({
|
||||||
|
nom: "",
|
||||||
|
description: "",
|
||||||
|
quantite: 0,
|
||||||
|
statut: "en_stock"
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([filterNom, filterStatut, limit], () => {
|
||||||
|
page.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filterNom.value = ''
|
||||||
|
filterStatut.value = ''
|
||||||
|
limit.value = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = { nom: "", description: "", quantite: 0, statut: "en_stock" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const editObjet = (item: Objet) => {
|
||||||
|
editingId.value = item.id
|
||||||
|
form.value = {
|
||||||
|
nom: item.nom,
|
||||||
|
description: item.description || "",
|
||||||
|
quantite: item.quantite,
|
||||||
|
statut: item.statut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveObjet = async () => {
|
||||||
|
message.value = ""
|
||||||
|
if (!form.value.nom) {
|
||||||
|
message.value = t('messages.requiredName')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await $fetch(`${apiBase}/objets/${editingId.value}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: form.value
|
||||||
|
})
|
||||||
|
message.value = t('messages.updated')
|
||||||
|
} else {
|
||||||
|
await $fetch(`${apiBase}/objets`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form.value
|
||||||
|
})
|
||||||
|
message.value = t('messages.created')
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteObjet = async (id: string) => {
|
||||||
|
message.value = ""
|
||||||
|
try {
|
||||||
|
await $fetch(`${apiBase}/objets/${id}`, { method: "DELETE" })
|
||||||
|
message.value = t('messages.deleted')
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('messages.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (id: string) => {
|
||||||
|
confirmAction.value = () => deleteObjet(id)
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConfirm = () => {
|
||||||
|
confirmOpen.value = false
|
||||||
|
confirmAction.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const runConfirm = async () => {
|
||||||
|
if (confirmAction.value) {
|
||||||
|
await confirmAction.value()
|
||||||
|
}
|
||||||
|
closeConfirm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
80
frontend/pages/settings.vue
Normal file
80
frontend/pages/settings.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container">
|
||||||
|
<h1>{{ t('nav.settings') }}</h1>
|
||||||
|
<p>{{ t('settings.description') }}</p>
|
||||||
|
|
||||||
|
<I18nStub />
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div style="display: grid; gap: 8px; margin-bottom: 12px;">
|
||||||
|
<label for="timezone">{{ t('settings.timezone') }}</label>
|
||||||
|
<input id="timezone" v-model="timezoneInput" placeholder="Europe/Paris" />
|
||||||
|
<button class="card" type="button" @click="applyTimezone">
|
||||||
|
{{ t('settings.applyTimezone') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label for="config">{{ t('settings.configJson') }}</label>
|
||||||
|
<textarea
|
||||||
|
id="config"
|
||||||
|
v-model="configText"
|
||||||
|
rows="14"
|
||||||
|
style="width: 100%; margin-top: 8px; font-family: monospace;"
|
||||||
|
/>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 12px;">
|
||||||
|
<button class="card" type="button" @click="reload">{{ t('actions.reload') }}</button>
|
||||||
|
<button class="card" type="button" @click="save">{{ t('actions.save') }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="message" style="margin-top: 8px;">{{ message }}</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { apiBase, getErrorMessage } = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const configText = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const timezoneInput = ref('Europe/Paris')
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
const data = await $fetch(`${apiBase}/config`)
|
||||||
|
configText.value = JSON.stringify(data, null, 2)
|
||||||
|
timezoneInput.value = data?.timezone || 'Europe/Paris'
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('errors.load'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configText.value)
|
||||||
|
if (timezoneInput.value) {
|
||||||
|
parsed.timezone = timezoneInput.value
|
||||||
|
}
|
||||||
|
const data = await $fetch(`${apiBase}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: parsed
|
||||||
|
})
|
||||||
|
configText.value = JSON.stringify(data, null, 2)
|
||||||
|
message.value = 'Configuration sauvegardee.'
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('messages.saveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTimezone = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configText.value || '{}')
|
||||||
|
parsed.timezone = timezoneInput.value || 'Europe/Paris'
|
||||||
|
configText.value = JSON.stringify(parsed, null, 2)
|
||||||
|
} catch (err) {
|
||||||
|
message.value = getErrorMessage(err, t('errors.invalidJson'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(reload)
|
||||||
|
</script>
|
||||||
12
frontend/tests/useApi.test.ts
Normal file
12
frontend/tests/useApi.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { useApi } from '../composables/useApi'
|
||||||
|
|
||||||
|
// Note: test simple pour valider la presence de getErrorMessage.
|
||||||
|
|
||||||
|
describe('useApi', () => {
|
||||||
|
it('retourne un message par defaut', () => {
|
||||||
|
;(globalThis as any).useRuntimeConfig = () => ({ public: { apiBase: '' } })
|
||||||
|
const { getErrorMessage } = useApi()
|
||||||
|
expect(getErrorMessage(undefined, 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
})
|
||||||
8
frontend/vitest.config.ts
Normal file
8
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user