Files
Inventory/app/pages/index.vue
matthieu 5b9c4ca09d refactor(ui) : improve styling, layout and responsive across all components
Rework CSS theme (app.css), navbar layout, dashboard page, machine detail,
catalog pages, and various form/display components for better consistency
and mobile responsiveness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:14:32 +01:00

429 lines
15 KiB
Vue

<template>
<main class="container mx-auto px-6 py-8">
<div class="my-8">
<!-- Header with Stats -->
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between mb-8">
<div>
<h2 class="text-3xl font-bold text-base-content tracking-tight">
Vue d'ensemble
</h2>
<p class="text-base-content/50 mt-1">
Machines organisées par site
</p>
</div>
<div class="flex gap-3">
<div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Sites</p>
<p class="text-2xl font-bold text-primary tracking-tight">{{ sites.length }}</p>
</div>
<div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Machines</p>
<p class="text-2xl font-bold text-secondary tracking-tight">{{ totalMachines }}</p>
</div>
</div>
</div>
<!-- Filters -->
<div class="card bg-base-100 shadow-sm mb-8">
<div class="card-body py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Rechercher</span>
</label>
<div class="relative">
<IconLucideSearch class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/30" aria-hidden="true" />
<input
v-model="searchTerm"
type="text"
placeholder="Nom de machine ou site..."
class="input input-bordered pl-10 w-full"
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
</label>
<select v-model="selectedSiteFilter" class="select select-bordered">
<option value="">
Tous les sites
</option>
<option
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ site.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg text-primary" />
</div>
<!-- Empty State -->
<div v-else-if="filteredSites.length === 0" class="text-center py-16">
<div class="max-w-sm mx-auto">
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<IconLucideFactory
class="w-8 h-8 text-base-content/30"
aria-hidden="true"
/>
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">
Aucune machine trouvée
</h3>
<p class="text-sm text-base-content/50 mb-6">
Commencez par ajouter des sites et des machines.
</p>
<div class="flex gap-2 justify-center">
<button v-if="canEdit" class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button
v-if="canEdit"
class="btn btn-ghost btn-sm"
@click="showAddMachineModal = true"
>
Ajouter une machine
</button>
</div>
</div>
</div>
<!-- Sites List -->
<div v-else class="space-y-5">
<div
v-for="site in filteredSites"
:key="site.id"
class="card bg-base-100 shadow-sm overflow-hidden"
>
<div class="card-body">
<!-- Site Header -->
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<div
class="w-11 h-11 rounded-xl bg-primary/10 grid place-items-center shrink-0"
>
<IconLucideMapPin class="w-5 h-5 text-primary" aria-hidden="true" />
</div>
<div class="min-w-0">
<h3 class="text-lg font-bold text-base-content tracking-tight">
{{ site.name }}
</h3>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1.5 text-sm text-base-content/50">
<span v-if="site.contactName" class="flex items-center gap-1.5">
<IconLucideUser class="w-3.5 h-3.5" aria-hidden="true" />
{{ site.contactName }}
</span>
<span v-if="site.contactPhone" class="flex items-center gap-1.5">
<IconLucidePhone class="w-3.5 h-3.5" aria-hidden="true" />
{{ formatPhoneDisplay(site.contactPhone) }}
</span>
<span v-if="site.contactCity" class="flex items-center gap-1.5">
<IconLucideMapPinned class="w-3.5 h-3.5" aria-hidden="true" />
{{ site.contactPostalCode }} {{ site.contactCity }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="badge badge-primary badge-sm font-semibold">
{{ site.machines?.length || 0 }}
</span>
<button
class="btn btn-ghost btn-xs btn-circle"
@click="toggleSiteCollapse(site.id)"
>
<IconLucideChevronDown
class="w-4 h-4 transition-transform duration-200"
:class="collapsedSites.includes(site.id) ? 'rotate-180' : ''"
aria-hidden="true"
/>
</button>
</div>
</div>
<!-- Machines Grid -->
<div
v-if="
!collapsedSites.includes(site.id) &&
site.machines &&
site.machines.length > 0
"
class="mt-4 pt-4 border-t border-base-200/80"
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div
v-for="machine in site.machines"
:key="machine.id"
class="group rounded-xl border border-base-300/40 bg-base-200/30 hover:bg-base-200/60 hover:border-primary/20 transition-all cursor-pointer p-4"
@click="viewMachineDetails(machine)"
>
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-sm text-base-content group-hover:text-primary transition-colors truncate">
{{ machine.name }}
</h4>
</div>
<div v-if="machine.reference" class="flex items-center gap-1.5 text-xs text-base-content/40 mb-3">
<IconLucideTag class="w-3 h-3" aria-hidden="true" />
<span>{{ machine.reference }}</span>
</div>
<div class="flex items-center gap-1.5 justify-end">
<button
v-if="canEdit"
class="btn btn-xs btn-ghost text-base-content/40 hover:text-base-content opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="editMachine(machine)"
>
Modifier
</button>
<button
v-if="canEdit"
class="btn btn-xs btn-ghost text-error/50 hover:text-error opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="confirmDeleteMachine(machine)"
>
Supprimer
</button>
<NuxtLink
:to="`/machine/${machine.id}`"
class="btn btn-xs btn-primary btn-outline opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop
>
Détails
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Empty Site State -->
<div
v-else-if="
!collapsedSites.includes(site.id) &&
(!site.machines || site.machines.length === 0)
"
class="text-center py-8 mt-4 border-t border-base-200/80"
>
<div class="w-10 h-10 rounded-xl bg-base-200 grid place-items-center mx-auto mb-3">
<IconLucideFactory class="w-5 h-5 text-base-content/25" aria-hidden="true" />
</div>
<p class="text-sm text-base-content/40 mb-3">
Aucune machine dans ce site
</p>
<button
v-if="canEdit"
class="btn btn-sm btn-primary btn-outline"
@click="addMachineToSite(site)"
>
Ajouter une machine
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Add Site Modal -->
<AddSiteModal
:open="showAddSiteModal"
:disabled="!canEdit"
@close="showAddSiteModal = false"
@create="handleCreateSite"
/>
<!-- Add Machine Modal -->
<AddMachineModal
:open="showAddMachineModal"
:sites="sites"
:disabled="!canEdit"
:preselected-site-id="preselectedSiteId"
@close="showAddMachineModal = false"
@create="handleCreateMachine"
/>
</main>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useSites } from '~/composables/useSites'
import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideUser from '~icons/lucide/user'
import IconLucidePhone from '~icons/lucide/phone'
import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideTag from '~icons/lucide/tag'
import IconLucideSearch from '~icons/lucide/search'
import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const { sites, loading, loadSites, createSite } = useSites()
const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false)
const searchTerm = ref('')
const selectedSiteFilter = ref('')
const collapsedSites = ref([])
const preselectedSiteId = ref('')
// Computed
const machinesBySiteId = computed(() => {
const map = new Map()
machines.value.forEach((machine) => {
const siteId = machine.siteId || extractRelationId(machine.site)
if (!siteId) { return }
if (!map.has(siteId)) {
map.set(siteId, [])
}
map.get(siteId).push(machine)
})
return map
})
const sitesWithMachines = computed(() => {
return sites.value.map((site) => ({
...site,
machines: machinesBySiteId.value.get(site.id) || []
}))
})
const totalMachines = computed(() => {
return sitesWithMachines.value.reduce((total, site) => {
return total + (site.machines?.length || 0)
}, 0)
})
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
const filteredSites = computed(() => {
let filtered = sitesWithMachines.value
// Filtrer par site
if (selectedSiteFilter.value) {
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
}
// Filtrer par terme de recherche
if (searchTerm.value) {
filtered = filtered.filter((site) => {
const lowerTerm = searchTerm.value.toLowerCase()
const siteMatches = [
site.name,
site.contactName,
site.contactPhone,
site.contactAddress,
site.contactPostalCode,
site.contactCity
].some((field) => {
if (!field) { return false }
return field.toLowerCase().includes(lowerTerm)
})
const machineMatches = site.machines?.some(
machine => {
const name = (machine.name || '').toLowerCase()
const reference = (machine.reference || '').toLowerCase()
return name.includes(lowerTerm) || reference.includes(lowerTerm)
}
)
return siteMatches || machineMatches
})
}
return filtered
})
// Methods
const handleCreateSite = async (data) => {
const result = await createSite(data)
if (result.success) {
showAddSiteModal.value = false
}
}
const handleCreateMachine = async (data) => {
const result = await createMachine(data)
if (result.success) {
showAddMachineModal.value = false
await loadMachines()
}
}
const toggleSiteCollapse = (siteId) => {
const index = collapsedSites.value.indexOf(siteId)
if (index > -1) {
collapsedSites.value.splice(index, 1)
} else {
collapsedSites.value.push(siteId)
}
}
const viewMachineDetails = (machine) => {
navigateTo(`/machine/${machine.id}`)
}
const editMachine = (machine) => {
navigateTo(`/machine/${machine.id}?edit=true`)
}
const { confirm: confirmDialog } = useConfirm()
const confirmDeleteMachine = async (machine) => {
const { showError, showSuccess } = useToast()
if (
await confirmDialog({
message: `Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`,
})
) {
try {
const result = await deleteMachine(machine.id)
if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
await loadMachines()
} else {
showError(`Impossible de supprimer la machine : ${result.error}`)
}
} catch (error) {
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
}
}
}
const addMachineToSite = (site) => {
preselectedSiteId.value = site.id
showAddMachineModal.value = true
}
// Lifecycle
onMounted(async () => {
await Promise.all([loadSites(), loadMachines()])
})
</script>