add achats import and hardware analyse

This commit is contained in:
2026-01-22 06:33:28 +01:00
parent 2b659920c2
commit b1f611d8ee
16 changed files with 1071 additions and 22 deletions

View File

@@ -2,6 +2,32 @@
--bg: #f5f1e8;
--text: #1f1b16;
--accent: #c46b2d;
--card: #fffaf2;
--border: #e3d8c5;
}
html[data-theme="dark"] {
--bg: #1c1a17;
--text: #f2e7d5;
--accent: #e29a4f;
--card: #26231e;
--border: #3b342c;
}
html[data-theme="monokai"] {
--bg: #272822;
--text: #f8f8f2;
--accent: #a6e22e;
--card: #2e2f28;
--border: #3d3e36;
}
html[data-theme="gruvbox-dark"] {
--bg: #282828;
--text: #ebdbb2;
--accent: #d79921;
--card: #32302f;
--border: #504945;
}
* {
@@ -11,7 +37,7 @@
body {
margin: 0;
font-family: "Space Grotesk", system-ui, sans-serif;
background: linear-gradient(135deg, #f5f1e8 0%, #efe6d6 100%);
background: linear-gradient(135deg, var(--bg) 0%, color-mix(in srgb, var(--bg) 85%, #000 15%) 100%);
color: var(--text);
}
@@ -77,10 +103,10 @@ a {
}
.card {
border: 1px solid #e3d8c5;
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
background: #fffaf2;
background: var(--card);
box-shadow: 0 8px 20px rgba(60, 40, 20, 0.08);
}

View File

@@ -0,0 +1,28 @@
<template>
<div class="card" style="display: flex; gap: 8px; align-items: center;">
<label>{{ t('theme.label') }}</label>
<select v-model="current" @change="applyTheme">
<option value="light">light</option>
<option value="dark">dark</option>
<option value="monokai">monokai</option>
<option value="gruvbox-dark">gruvbox-dark</option>
</select>
</div>
</template>
<script setup lang="ts">
const { t } = useI18n()
const current = ref('gruvbox-dark')
const applyTheme = () => {
document.documentElement.setAttribute('data-theme', current.value)
localStorage.setItem('theme', current.value)
}
onMounted(() => {
const saved = localStorage.getItem('theme') || 'gruvbox-dark'
current.value = saved
applyTheme()
})
</script>

View File

@@ -0,0 +1,17 @@
export const useVersions = async () => {
const { apiBase } = useApi()
const frontend = '0.1.0'
let backend = 'unknown'
try {
const data = await $fetch<{ version?: string }>(`${apiBase}/healthz`)
if (data?.version) {
backend = data.version
}
} catch {
// ignore
}
return { frontend, backend }
}

View File

@@ -7,6 +7,8 @@
<NuxtLink to="/objets">{{ t('nav.objets') }}</NuxtLink>
<NuxtLink to="/emplacements">{{ t('nav.emplacements') }}</NuxtLink>
<NuxtLink to="/categories">{{ t('nav.categories') }}</NuxtLink>
<NuxtLink to="/achats">{{ t('nav.achats') }}</NuxtLink>
<NuxtLink to="/analyse">{{ t('nav.analyse') }}</NuxtLink>
<NuxtLink to="/settings">{{ t('nav.settings') }}</NuxtLink>
<NuxtLink to="/debug">{{ t('nav.debug') }}</NuxtLink>
</nav>
@@ -20,6 +22,9 @@
<footer class="app-footer">
<div class="container">
<small>{{ t('footer.text') }}</small>
<div style="margin-top: 6px;">
<small>{{ t('footer.versions', versions) }}</small>
</div>
</div>
</footer>
</div>
@@ -27,4 +32,10 @@
<script setup lang="ts">
const { t } = useI18n()
const versions = ref({ frontend: '0.1.0', backend: 'unknown' })
onMounted(async () => {
const data = await useVersions()
versions.value = data
})
</script>

View File

@@ -3,6 +3,8 @@
"objets": "Objets",
"emplacements": "Emplacements",
"categories": "Categories",
"achats": "Achats",
"analyse": "Analyse hardware",
"settings": "Settings",
"debug": "Debug"
},
@@ -103,6 +105,9 @@
"applyTimezone": "Appliquer la timezone",
"description": "Configuration backend + frontend (config.json)."
},
"theme": {
"label": "Theme"
},
"fileUploader": {
"label": "Deposer des images, PDF ou fichiers Markdown."
},
@@ -139,6 +144,31 @@
"autoRefresh": "Rafraichissement auto (5s)"
},
"footer": {
"text": "MatosBox - inventaire local"
"text": "MatosBox - inventaire local",
"versions": "Frontend {frontend} • Backend {backend}"
},
"achats": {
"mode": "Import achats",
"boutique": "Boutique",
"format": "Format",
"fileMode": "Fichier CSV/JSON",
"jsonMode": "JSON manuel",
"jsonPlaceholder": "Coller un tableau JSON d'achats ou un objet { boutique, achats }",
"generic": "Generique",
"summary": "Resume",
"imported": "Lignes importees",
"created": "Objets crees",
"duplicates": "Doublons",
"errors": "Erreurs"
},
"analyse": {
"title": "Analyse commandes hardware",
"type": "Type de commande",
"auto": "Detection auto",
"placeholder": "Coller la sortie de lspci ou lsusb",
"run": "Analyser",
"result": "Resultat",
"count": "Peripheriques detectes",
"empty": "Coller une sortie a analyser."
}
}

View File

@@ -0,0 +1,150 @@
<template>
<main class="container">
<h1>{{ t('nav.achats') }}</h1>
<p v-if="message">{{ message }}</p>
<section class="card" style="margin-bottom: 16px;">
<h2>{{ t('achats.mode') }}</h2>
<div style="display: grid; gap: 8px;">
<label>
{{ t('achats.boutique') }}
<select v-model="boutique">
<option value="amazon">Amazon</option>
<option value="aliexpress">AliExpress</option>
<option value="generic">{{ t('achats.generic') }}</option>
</select>
</label>
<label>
{{ t('achats.format') }}
<select v-model="mode">
<option value="file">{{ t('achats.fileMode') }}</option>
<option value="json">{{ t('achats.jsonMode') }}</option>
</select>
</label>
<div v-if="mode === 'file'" style="display: grid; gap: 8px;">
<input type="file" @change="onFileChange" />
<button class="card" type="button" :disabled="uploading" @click="uploadFile">
{{ uploading ? t('form.saving') : t('actions.upload') }}
</button>
</div>
<div v-else style="display: grid; gap: 8px;">
<textarea
v-model="jsonText"
rows="8"
:placeholder="t('achats.jsonPlaceholder')"
/>
<button class="card" type="button" :disabled="uploading" @click="uploadJson">
{{ uploading ? t('form.saving') : t('actions.save') }}
</button>
</div>
</div>
</section>
<section v-if="result" class="card">
<h2>{{ t('achats.summary') }}</h2>
<p>{{ t('achats.imported') }}: {{ result.importes }}</p>
<p>{{ t('achats.created') }}: {{ result.crees }}</p>
<p>{{ t('achats.duplicates') }}: {{ result.doublons }}</p>
<div v-if="result.erreurs?.length" style="margin-top: 8px;">
<strong>{{ t('achats.errors') }}</strong>
<ul>
<li v-for="(err, idx) in result.erreurs" :key="idx">
<span v-if="err.ligne">#{{ err.ligne }} </span>{{ err.message }}
<span v-if="err.nom">({{ err.nom }})</span>
</li>
</ul>
</div>
</section>
</main>
</template>
<script setup lang="ts">
type ImportErreur = {
ligne?: number
message: string
nom?: string
}
type ImportResultat = {
importes: number
crees: number
doublons: number
erreurs: ImportErreur[]
}
const { apiBase, getErrorMessage } = useApi()
const { t } = useI18n()
const boutique = ref("amazon")
const mode = ref<"file" | "json">("file")
const uploading = ref(false)
const message = ref("")
const result = ref<ImportResultat | null>(null)
const file = ref<File | null>(null)
const jsonText = ref("")
const onFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
file.value = input.files?.[0] ?? null
}
const uploadFile = async () => {
message.value = ""
result.value = null
if (!file.value) {
message.value = t("messages.noFiles")
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append("boutique", boutique.value)
formData.append("fichier", file.value)
result.value = await $fetch(`${apiBase}/imports/achats`, {
method: "POST",
body: formData
})
message.value = t("messages.uploadDone")
} catch (err) {
message.value = getErrorMessage(err, t("messages.uploadError"))
} finally {
uploading.value = false
}
}
const uploadJson = async () => {
message.value = ""
result.value = null
if (!jsonText.value.trim()) {
message.value = t("errors.invalidJson")
return
}
uploading.value = true
try {
const parsed = JSON.parse(jsonText.value)
let payload = parsed
if (Array.isArray(parsed)) {
payload = { boutique: boutique.value, achats: parsed }
} else if (parsed && typeof parsed === "object") {
if (!parsed.boutique) {
payload = { ...parsed, boutique: boutique.value }
}
}
result.value = await $fetch(`${apiBase}/imports/achats`, {
method: "POST",
body: payload
})
message.value = t("messages.uploadDone")
} catch (err) {
if (err instanceof SyntaxError) {
message.value = t("errors.invalidJson")
} else {
message.value = getErrorMessage(err, t("messages.uploadError"))
}
} finally {
uploading.value = false
}
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<main class="container">
<h1>{{ t('nav.analyse') }}</h1>
<p v-if="message">{{ message }}</p>
<section class="card" style="margin-bottom: 16px;">
<h2>{{ t('analyse.title') }}</h2>
<div style="display: grid; gap: 8px;">
<label>
{{ t('analyse.type') }}
<select v-model="type">
<option value="auto">{{ t('analyse.auto') }}</option>
<option value="lspci">lspci</option>
<option value="lsusb">lsusb</option>
</select>
</label>
<textarea
v-model="texte"
rows="10"
:placeholder="t('analyse.placeholder')"
/>
<button class="card" type="button" :disabled="loading" @click="runAnalyse">
{{ loading ? t('form.saving') : t('analyse.run') }}
</button>
</div>
</section>
<section v-if="result" class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h2>{{ t('analyse.result') }}</h2>
<button class="card" type="button" @click="copyJson">{{ t('actions.copy') }}</button>
</div>
<p>{{ t('analyse.count') }}: {{ result.count }}</p>
<pre>{{ formatted }}</pre>
</section>
</main>
</template>
<script setup lang="ts">
type AnalyseResponse = {
type: string
count: number
devices: unknown[]
}
const { apiBase, getErrorMessage } = useApi()
const { t } = useI18n()
const type = ref("auto")
const texte = ref("")
const loading = ref(false)
const message = ref("")
const result = ref<AnalyseResponse | null>(null)
const formatted = computed(() => (result.value ? JSON.stringify(result.value, null, 2) : ""))
const runAnalyse = async () => {
message.value = ""
result.value = null
if (!texte.value.trim()) {
message.value = t("analyse.empty")
return
}
loading.value = true
try {
const payload = {
type: type.value === "auto" ? "" : type.value,
texte: texte.value
}
result.value = await $fetch(`${apiBase}/analyse-hardware`, {
method: "POST",
body: payload
})
} catch (err) {
message.value = getErrorMessage(err, t("messages.loadError"))
} finally {
loading.value = false
}
}
const copyJson = async () => {
if (!formatted.value) {
return
}
try {
await navigator.clipboard.writeText(formatted.value)
message.value = t("messages.copied")
} catch {
message.value = t("errors.copy")
}
}
</script>

View File

@@ -5,6 +5,8 @@
<I18nStub />
<ThemeToggle />
<section class="card">
<div style="display: grid; gap: 8px; margin-bottom: 12px;">
<label for="timezone">{{ t('settings.timezone') }}</label>