update frontend ui, i18n, filters, and deps

This commit is contained in:
2026-01-22 06:18:04 +01:00
parent 88624f3bed
commit 2b659920c2
52 changed files with 13874 additions and 13 deletions

View File

@@ -11,7 +11,7 @@
body {
margin: 0;
font-family: "Space Grotesk", system-ui, sans-serif;
background: var(--bg);
background: linear-gradient(135deg, #f5f1e8 0%, #efe6d6 100%);
color: var(--text);
}
@@ -26,6 +26,45 @@ a {
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 {
display: grid;
gap: 12px;
@@ -42,4 +81,62 @@ a {
border-radius: 16px;
padding: 16px;
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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,7 @@
import fr from './locales/fr.json'
export default defineI18nConfig(() => ({
legacy: false,
locale: 'fr',
messages: { fr }
}))

View 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
View 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"
}
}

View File

@@ -1,5 +1,17 @@
export default defineNuxtConfig({
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: {
head: {
title: 'MatosBox',

11543
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,15 @@
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview"
"preview": "nuxt preview",
"test": "vitest run"
},
"dependencies": {
"nuxt": "3.12.3"
"@nuxtjs/i18n": "8.3.1",
"nuxt": "^3.20.2"
},
"devDependencies": {
"jsdom": "24.1.0",
"vitest": "^4.0.17"
}
}

View 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
View 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>

View File

@@ -1,6 +1,216 @@
<template>
<main class="container">
<h1>Emplacements</h1>
<p>Arborescence a connecter a l'API.</p>
<h1>{{ t('nav.emplacements') }}</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>
<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>
</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>

View File

@@ -1,12 +1,19 @@
<template>
<main class="container">
<section class="hero">
<h1>MatosBox</h1>
<p>Inventaire simple pour le materiel, les composants et les outils.</p>
<h1>{{ t('home.title') }}</h1>
<p>{{ t('home.subtitle') }}</p>
<nav class="grid">
<NuxtLink class="card" to="/objets">Voir les objets</NuxtLink>
<NuxtLink class="card" to="/emplacements">Voir les emplacements</NuxtLink>
<NuxtLink class="card" to="/objets">{{ t('nav.objets') }}</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>
</section>
</main>
</template>
<script setup lang="ts">
const { t } = useI18n()
</script>

View File

@@ -1,6 +1,371 @@
<template>
<main class="container">
<h1>Fiche objet</h1>
<p>Detail a connecter a l'API.</p>
<h1>{{ t('pages.objetDetail') }}</h1>
<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>
</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>

View File

@@ -1,6 +1,212 @@
<template>
<main class="container">
<h1>Objets</h1>
<p>Liste a connecter a l'API.</p>
<h1>{{ t('nav.objets') }}</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')" />
<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>
</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>

View 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>

View 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')
})
})

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true
}
})