update frontend ui, i18n, filters, and deps
This commit is contained in:
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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user