feat: add profile management flow
This commit is contained in:
221
app/app.vue
221
app/app.vue
@@ -10,13 +10,81 @@
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><NuxtLink to="/">Dashboard</NuxtLink></li>
|
||||
<li><NuxtLink to="/machines">Machines</NuxtLink></li>
|
||||
<li><NuxtLink to="/types">Types de Machines</NuxtLink></li>
|
||||
<li><NuxtLink to="/sites">Sites</NuxtLink></li>
|
||||
<li><NuxtLink to="/generator">Générateur</NuxtLink></li>
|
||||
<li><NuxtLink to="/documents">Documents</NuxtLink></li>
|
||||
<li><NuxtLink to="/constructeurs">Constructeurs</NuxtLink></li>
|
||||
<li class="pt-1 pb-2 lg:hidden">
|
||||
<button
|
||||
@click="openDisplaySettings"
|
||||
class="w-full flex items-center gap-2 rounded-md px-2 py-1 transition-colors text-base-content hover:bg-primary/10 hover:text-primary"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
Paramètres d'affichage
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="isActive('/') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Dashboard
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="isActive('/machines') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/types"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="isActive('/types') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Types de Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="isActive('/sites') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/generator"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="isActive('/generator') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Générateur
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="isActive('/documents') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
:class="isActive('/constructeurs') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Constructeurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -32,13 +100,69 @@
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><NuxtLink to="/" class="link link-hover">Dashboard</NuxtLink></li>
|
||||
<li><NuxtLink to="/machines" class="link link-hover">Machines</NuxtLink></li>
|
||||
<li><NuxtLink to="/types" class="link link-hover">Types de Machines</NuxtLink></li>
|
||||
<li><NuxtLink to="/sites" class="link link-hover">Sites</NuxtLink></li>
|
||||
<li><NuxtLink to="/generator" class="link link-hover">Générateur</NuxtLink></li>
|
||||
<li><NuxtLink to="/documents" class="link link-hover">Documents</NuxtLink></li>
|
||||
<li><NuxtLink to="/constructeurs" class="link link-hover">Constructeurs</NuxtLink></li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="isActive('/') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Dashboard
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/machines"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="isActive('/machines') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/types"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="isActive('/types') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Types de Machines
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/sites"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="isActive('/sites') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/generator"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="isActive('/generator') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Générateur
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/documents"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="isActive('/documents') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Documents
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/constructeurs"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
:class="isActive('/constructeurs') ? 'bg-primary text-primary-content font-semibold shadow-sm' : 'text-base-content hover:bg-primary/10 hover:text-primary'"
|
||||
>
|
||||
Constructeurs
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
@@ -46,7 +170,7 @@
|
||||
<!-- Bouton paramètres d'affichage -->
|
||||
<button
|
||||
@click="openDisplaySettings"
|
||||
class="btn btn-ghost btn-circle"
|
||||
class="btn btn-ghost btn-circle hidden lg:inline-flex"
|
||||
title="Paramètres d'affichage"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -91,6 +215,39 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<div class="dropdown dropdown-end" v-if="activeProfile">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder">
|
||||
<div class="bg-secondary text-secondary-content rounded-full w-10">
|
||||
<span class="text-sm font-semibold">{{ activeProfileInitials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64">
|
||||
<li class="px-2 py-1 text-sm text-base-content/70">
|
||||
Connecté en tant que<br />
|
||||
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
||||
</li>
|
||||
<li><hr class="my-1" /></li>
|
||||
<li>
|
||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
||||
Gestion des profils
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="text-error justify-between" @click="handleLogout">
|
||||
Déconnexion
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H7a2 2 0 01-2-2V7a2 2 0 012-2h4a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,10 +275,22 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, navigateTo } from '#imports'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
|
||||
// État du modal des paramètres d'affichage
|
||||
const displaySettingsOpen = ref(false)
|
||||
const { activeProfile, ensureSession, logout } = useProfileSession()
|
||||
|
||||
// Route active pour souligner l'onglet sélectionné dans la navbar
|
||||
const route = useRoute()
|
||||
const isActive = (path) => {
|
||||
if (path === '/') {
|
||||
return route.path === '/'
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
// Ouvrir les paramètres d'affichage
|
||||
const openDisplaySettings = () => {
|
||||
@@ -137,4 +306,24 @@ const closeDisplaySettings = () => {
|
||||
const handleSettingsUpdate = (settings) => {
|
||||
console.log('Paramètres d\'affichage mis à jour:', settings)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
await navigateTo('/profiles')
|
||||
}
|
||||
|
||||
const activeProfileLabel = computed(() => {
|
||||
if (!activeProfile.value) return 'Profil inconnu'
|
||||
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
|
||||
})
|
||||
|
||||
const activeProfileInitials = computed(() => {
|
||||
if (!activeProfile.value) return '??'
|
||||
const { firstName = '', lastName = '' } = activeProfile.value
|
||||
return `${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureSession()
|
||||
})
|
||||
</script>
|
||||
|
||||
136
app/components/MachinePrintSelectionModal.vue
Normal file
136
app/components/MachinePrintSelectionModal.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<form method="dialog" class="modal-close" @submit.prevent></form>
|
||||
<h3 class="font-bold text-lg mb-2">Préparer l'impression</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
Choisissez les sections à inclure avant de lancer l'impression.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button type="button" class="btn btn-xs btn-outline" @click="emit('select-all')">
|
||||
Tout sélectionner
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-outline" @click="emit('deselect-all')">
|
||||
Tout désélectionner
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[420px] overflow-y-auto pr-2 space-y-6">
|
||||
<section class="bg-base-200/50 rounded-xl p-4 space-y-3">
|
||||
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
|
||||
Machine
|
||||
</h4>
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
v-model="selection.machine.info"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">Informations générales</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Nom, emplacement, site et constructeur de la machine.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
v-model="selection.machine.customFields"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">Champs personnalisés</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Valeurs spécifiques configurées pour cette machine.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
v-model="selection.machine.documents"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">Documents</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Pièces jointes liées directement à la machine.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="bg-base-200/30 rounded-xl p-4 space-y-3" v-if="hasComponents">
|
||||
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
|
||||
Composants & pièces
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<MachinePrintSelectionNode
|
||||
v-for="component in componentsList"
|
||||
:key="component.id"
|
||||
:component="component"
|
||||
:selection="selection"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-base-200/30 rounded-xl p-4 space-y-3" v-if="hasPieces">
|
||||
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
|
||||
Pièces indépendantes
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="piece in piecesList"
|
||||
:key="piece.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary mt-1"
|
||||
v-model="selection.pieces[piece.id]"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">{{ piece.name }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ piece.reference || 'Référence inconnue' }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click="emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="emit('confirm')">
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue'
|
||||
import MachinePrintSelectionNode from '~/components/MachinePrintSelectionNode.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
selection: { type: Object, required: true },
|
||||
components: { type: Array, default: () => [] },
|
||||
pieces: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'confirm', 'select-all', 'deselect-all'])
|
||||
|
||||
const selection = toRef(props, 'selection')
|
||||
const componentsList = computed(() => props.components || [])
|
||||
const piecesList = computed(() => props.pieces || [])
|
||||
|
||||
const hasComponents = computed(() => componentsList.value.length > 0)
|
||||
const hasPieces = computed(() => piecesList.value.length > 0)
|
||||
</script>
|
||||
63
app/components/MachinePrintSelectionNode.vue
Normal file
63
app/components/MachinePrintSelectionNode.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-base-300 bg-base-100/80 p-3 space-y-3">
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
v-model="selection.components[component.id]"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">{{ component.name }}</p>
|
||||
<p v-if="component.reference" class="text-xs text-base-content/60">
|
||||
{{ component.reference }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="childPieces.length" class="pl-6 space-y-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">
|
||||
Pièces
|
||||
</p>
|
||||
<label
|
||||
v-for="piece in childPieces"
|
||||
:key="piece.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary mt-1"
|
||||
v-model="selection.pieces[piece.id]"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">{{ piece.name }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ piece.reference || 'Référence inconnue' }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="childComponents.length" class="pl-6 space-y-3 border-l border-base-200">
|
||||
<MachinePrintSelectionNode
|
||||
v-for="child in childComponents"
|
||||
:key="child.id"
|
||||
:component="child"
|
||||
:selection="selection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({ name: 'MachinePrintSelectionNode' })
|
||||
|
||||
const props = defineProps({
|
||||
component: { type: Object, required: true },
|
||||
selection: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const childComponents = computed(() => props.component.subComponents || [])
|
||||
const childPieces = computed(() => props.component.pieces || [])
|
||||
</script>
|
||||
79
app/components/PageHero.vue
Normal file
79
app/components/PageHero.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<section :class="sectionClasses">
|
||||
<div :class="contentClasses">
|
||||
<div :class="['space-y-4', maxWidthClass]">
|
||||
<component :is="headingTag" v-if="title" class="text-4xl font-bold">
|
||||
{{ title }}
|
||||
</component>
|
||||
<p v-if="subtitle" class="text-sm opacity-90">{{ subtitle }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
gradientFrom: {
|
||||
type: String,
|
||||
default: 'from-primary',
|
||||
},
|
||||
gradientTo: {
|
||||
type: String,
|
||||
default: 'to-secondary',
|
||||
},
|
||||
minHeight: {
|
||||
type: String,
|
||||
default: 'min-h-[25vh]',
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: 'max-w-xl',
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
alignment: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: (value) => ['center', 'start', 'end'].includes(value),
|
||||
},
|
||||
headingTag: {
|
||||
type: String,
|
||||
default: 'h1',
|
||||
},
|
||||
})
|
||||
|
||||
const sectionClasses = computed(() => {
|
||||
const classes = ['hero', 'bg-gradient-to-r', props.gradientFrom, props.gradientTo, props.minHeight]
|
||||
if (props.rounded) {
|
||||
classes.push('rounded-lg')
|
||||
}
|
||||
return classes
|
||||
})
|
||||
|
||||
const contentClasses = computed(() => {
|
||||
const base = ['hero-content', 'text-neutral-content']
|
||||
if (props.alignment === 'center') {
|
||||
base.push('text-center')
|
||||
} else if (props.alignment === 'start') {
|
||||
base.push('justify-start', 'text-left')
|
||||
} else if (props.alignment === 'end') {
|
||||
base.push('justify-end', 'text-right')
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const maxWidthClass = computed(() => props.maxWidth)
|
||||
</script>
|
||||
37
app/components/TypeEditActionsBar.vue
Normal file
37
app/components/TypeEditActionsBar.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<div class="card-actions justify-end">
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-outline">
|
||||
Réinitialiser
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
<svg
|
||||
v-if="saving"
|
||||
class="w-5 h-5 mr-2 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
{{ saving ? 'Sauvegarde...' : 'Sauvegarder les modifications' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['reset'])
|
||||
</script>
|
||||
103
app/components/TypeEditBaseInfoSection.vue
Normal file
103
app/components/TypeEditBaseInfoSection.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Informations de base</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du type</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="nameModel"
|
||||
type="text"
|
||||
placeholder="Nom du type de machine"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="categoryModel"
|
||||
type="text"
|
||||
placeholder="Catégorie du type"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="descriptionModel"
|
||||
placeholder="Description du type de machine"
|
||||
class="textarea textarea-bordered h-24"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fréquence de maintenance</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="maintenanceModel"
|
||||
type="text"
|
||||
placeholder="ex: Mensuelle, Trimestrielle"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
maintenanceFrequency: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:name', 'update:category', 'update:description', 'update:maintenanceFrequency'])
|
||||
|
||||
const nameModel = computed({
|
||||
get: () => props.name,
|
||||
set: (value) => emit('update:name', value),
|
||||
})
|
||||
|
||||
const categoryModel = computed({
|
||||
get: () => props.category,
|
||||
set: (value) => emit('update:category', value),
|
||||
})
|
||||
|
||||
const descriptionModel = computed({
|
||||
get: () => props.description,
|
||||
set: (value) => emit('update:description', value),
|
||||
})
|
||||
|
||||
const maintenanceModel = computed({
|
||||
get: () => props.maintenanceFrequency,
|
||||
set: (value) => emit('update:maintenanceFrequency', value),
|
||||
})
|
||||
</script>
|
||||
294
app/components/TypeEditComponentsSection.vue
Normal file
294
app/components/TypeEditComponentsSection.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm p-1"
|
||||
@click="toggleSection"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': expanded }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="card-title text-lg">Composants</h3>
|
||||
<span class="badge badge-accent">{{ components.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="space-y-4">
|
||||
<div
|
||||
v-for="(component, index) in components"
|
||||
:key="index"
|
||||
class="border border-gray-200 rounded-lg p-4 bg-gray-50 space-y-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-1"
|
||||
@click="toggleComponent(index)"
|
||||
title="Plier / déplier le composant"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': isComponentExpanded(index) }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path>
|
||||
</svg>
|
||||
<h5 class="text-sm font-medium">Composant {{ index + 1 }}</h5>
|
||||
<span v-if="!isComponentExpanded(index)" class="text-xs text-gray-500 truncate max-w-[160px]">
|
||||
{{ component.name || 'Sans nom' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeComponent(index)"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
title="Supprimer ce composant"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isComponentExpanded(index)" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du composant</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="component.name"
|
||||
type="text"
|
||||
placeholder="Nom du composant"
|
||||
class="input input-bordered input-sm"
|
||||
required
|
||||
@input="updateComponent(index, { name: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
:value="component.reference"
|
||||
type="text"
|
||||
placeholder="Référence"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateComponent(index, { reference: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Constructeur</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
:model-value="component.constructeurId || component.constructeur?.id || null"
|
||||
placeholder="Rechercher un constructeur..."
|
||||
@update:modelValue="(value) => setComponentConstructeur(index, value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Emplacement</span>
|
||||
</label>
|
||||
<input
|
||||
:value="component.emplacement"
|
||||
type="text"
|
||||
placeholder="Emplacement"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateComponent(index, { emplacement: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TypeEditCustomFieldsSection
|
||||
:model-value="component.customFields || []"
|
||||
:all-expanded="allExpanded"
|
||||
:expand-all-trigger="componentExpandTrigger"
|
||||
@update:model-value="(value) => updateComponent(index, { customFields: value })"
|
||||
/>
|
||||
|
||||
<TypeMachinePieceForm
|
||||
:model-value="component.pieces || []"
|
||||
:all-expanded="allExpanded"
|
||||
:expand-all-trigger="componentExpandTrigger"
|
||||
@update:model-value="(value) => updateComponent(index, { pieces: value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" @click="addComponent" class="btn btn-primary btn-sm">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Ajouter un composant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-end">
|
||||
<button type="button" @click="addComponent" class="btn btn-primary btn-sm">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Ajouter un composant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
|
||||
import TypeMachinePieceForm from '~/components/TypeMachinePieceForm.vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
expandAllTrigger: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const components = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const expanded = ref(false)
|
||||
const expandedComponents = ref([])
|
||||
const componentExpandTrigger = computed(() => props.expandAllTrigger)
|
||||
|
||||
watch(
|
||||
() => props.expandAllTrigger,
|
||||
() => {
|
||||
expanded.value = props.allExpanded
|
||||
expandedComponents.value = components.value.map(() => props.allExpanded)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => components.value.length,
|
||||
(length) => {
|
||||
expandedComponents.value = Array.from({ length }, (_, index) => expandedComponents.value[index] ?? props.allExpanded)
|
||||
}
|
||||
)
|
||||
|
||||
const toggleSection = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const ensureComponentState = (index) => {
|
||||
if (expandedComponents.value[index] === undefined) {
|
||||
expandedComponents.value[index] = props.allExpanded
|
||||
}
|
||||
}
|
||||
|
||||
const isComponentExpanded = (index) => {
|
||||
ensureComponentState(index)
|
||||
return expandedComponents.value[index]
|
||||
}
|
||||
|
||||
const toggleComponent = (index) => {
|
||||
ensureComponentState(index)
|
||||
expandedComponents.value[index] = !expandedComponents.value[index]
|
||||
}
|
||||
|
||||
const createComponent = () => ({
|
||||
name: '',
|
||||
reference: '',
|
||||
constructeur: null,
|
||||
constructeurId: null,
|
||||
emplacement: '',
|
||||
prix: null,
|
||||
customFields: [],
|
||||
pieces: [],
|
||||
})
|
||||
|
||||
const addComponent = () => {
|
||||
components.value = [...components.value, createComponent()]
|
||||
expanded.value = true
|
||||
expandedComponents.value.push(true)
|
||||
}
|
||||
|
||||
const removeComponent = (index) => {
|
||||
components.value = components.value.filter((_, i) => i !== index)
|
||||
expandedComponents.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const updateComponent = (index, patch) => {
|
||||
components.value = components.value.map((component, i) =>
|
||||
i === index ? { ...component, ...patch } : component
|
||||
)
|
||||
}
|
||||
|
||||
const findConstructeurById = (id) => constructeurs.value.find(item => item.id === id) || null
|
||||
|
||||
const hydrateComponents = () => {
|
||||
components.value.forEach((component) => {
|
||||
if (component.constructeurId && (!component.constructeur || component.constructeur.id !== component.constructeurId)) {
|
||||
component.constructeur = findConstructeurById(component.constructeurId)
|
||||
}
|
||||
(component.pieces || []).forEach((piece) => {
|
||||
if (piece.constructeurId && (!piece.constructeur || piece.constructeur.id !== piece.constructeurId)) {
|
||||
piece.constructeur = findConstructeurById(piece.constructeurId)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const setComponentConstructeur = (index, constructeurId) => {
|
||||
updateComponent(index, {
|
||||
constructeurId,
|
||||
constructeur: constructeurId ? findConstructeurById(constructeurId) : null,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => components.value,
|
||||
() => hydrateComponents(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => constructeurs.value.length,
|
||||
() => hydrateComponents()
|
||||
)
|
||||
|
||||
hydrateComponents()
|
||||
</script>
|
||||
263
app/components/TypeEditCustomFieldsSection.vue
Normal file
263
app/components/TypeEditCustomFieldsSection.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm p-1"
|
||||
@click="toggleSection"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': expanded }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="card-title text-lg">Champs personnalisés du type</h3>
|
||||
<span class="badge badge-primary">{{ fields.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="space-y-4">
|
||||
<div
|
||||
v-for="(field, fieldIndex) in fields"
|
||||
:key="fieldIndex"
|
||||
class="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-1"
|
||||
@click="toggleField(fieldIndex)"
|
||||
title="Plier / déplier le champ"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': isFieldExpanded(fieldIndex) }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
|
||||
</svg>
|
||||
<h5 class="text-sm font-medium">Champ personnalisé {{ fieldIndex + 1 }}</h5>
|
||||
<span v-if="!isFieldExpanded(fieldIndex)" class="text-xs text-gray-500 truncate max-w-[160px]">
|
||||
{{ field.name || 'Sans nom' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeField(fieldIndex)"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
title="Supprimer ce champ"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isFieldExpanded(fieldIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du champ</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:value="field.name"
|
||||
type="text"
|
||||
placeholder="Nom du champ"
|
||||
class="input input-bordered input-sm"
|
||||
required
|
||||
@input="updateField(fieldIndex, { name: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type de champ</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered select-sm"
|
||||
required
|
||||
:value="field.type"
|
||||
@change="updateField(fieldIndex, { type: $event.target.value })"
|
||||
>
|
||||
<option value="">Sélectionner un type</option>
|
||||
<option value="text">Texte</option>
|
||||
<option value="number">Nombre</option>
|
||||
<option value="select">Liste déroulante</option>
|
||||
<option value="boolean">Oui/Non</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isFieldExpanded(fieldIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="field.required"
|
||||
@change="updateField(fieldIndex, { required: $event.target.checked })"
|
||||
/>
|
||||
<span class="text-sm">Champ obligatoire</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Valeur par défaut</span>
|
||||
</label>
|
||||
<input
|
||||
:value="field.defaultValue"
|
||||
type="text"
|
||||
placeholder="Valeur par défaut"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateField(fieldIndex, { defaultValue: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFieldExpanded(fieldIndex) && field.type === 'select'"
|
||||
class="mt-3"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">Options de la liste</span>
|
||||
<span class="label-text-alt">Une option par ligne</span>
|
||||
</label>
|
||||
<textarea
|
||||
:value="field.optionsText || ''"
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
class="textarea textarea-bordered textarea-sm w-full h-20"
|
||||
@input="updateOptions(fieldIndex, $event.target.value)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="button" @click="addField" class="btn btn-primary btn-sm">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-end">
|
||||
<button type="button" @click="addField" class="btn btn-primary btn-sm">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
expandAllTrigger: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const fields = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const expanded = ref(false)
|
||||
const expandedFields = ref([])
|
||||
|
||||
watch(
|
||||
() => props.expandAllTrigger,
|
||||
() => {
|
||||
expanded.value = props.allExpanded
|
||||
expandedFields.value = fields.value.map(() => props.allExpanded)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => fields.value.length,
|
||||
(length) => {
|
||||
expandedFields.value = Array.from({ length }, (_, index) => expandedFields.value[index] ?? props.allExpanded)
|
||||
}
|
||||
)
|
||||
|
||||
const toggleSection = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const ensureFieldState = (index) => {
|
||||
if (expandedFields.value[index] === undefined) {
|
||||
expandedFields.value[index] = props.allExpanded
|
||||
}
|
||||
}
|
||||
|
||||
const isFieldExpanded = (index) => {
|
||||
ensureFieldState(index)
|
||||
return expandedFields.value[index]
|
||||
}
|
||||
|
||||
const toggleField = (index) => {
|
||||
ensureFieldState(index)
|
||||
expandedFields.value[index] = !expandedFields.value[index]
|
||||
}
|
||||
|
||||
const addField = () => {
|
||||
fields.value = [
|
||||
...fields.value,
|
||||
{
|
||||
name: '',
|
||||
type: '',
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
optionsText: '',
|
||||
},
|
||||
]
|
||||
expandedFields.value.push(true)
|
||||
expanded.value = true
|
||||
}
|
||||
|
||||
const removeField = (index) => {
|
||||
fields.value = fields.value.filter((_, i) => i !== index)
|
||||
expandedFields.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const updateField = (index, patch) => {
|
||||
fields.value = fields.value.map((field, i) => (i === index ? { ...field, ...patch } : field))
|
||||
}
|
||||
|
||||
const updateOptions = (index, value) => {
|
||||
updateField(index, {
|
||||
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
78
app/components/TypeEditMachinePiecesSection.vue
Normal file
78
app/components/TypeEditMachinePiecesSection.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm p-1"
|
||||
@click="toggleSection"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': expanded }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="card-title text-lg">Pièces principales</h3>
|
||||
<span class="badge badge-secondary">{{ pieces.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded">
|
||||
<TypeMachinePieceForm
|
||||
v-model="internalPieces"
|
||||
:all-expanded="allExpanded"
|
||||
:expand-all-trigger="expandAllTrigger"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import TypeMachinePieceForm from '~/components/TypeMachinePieceForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
expandAllTrigger: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.expandAllTrigger,
|
||||
() => {
|
||||
expanded.value = props.allExpanded
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const internalPieces = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const pieces = computed(() => internalPieces.value)
|
||||
|
||||
const toggleSection = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
</script>
|
||||
34
app/components/TypeEditToolbar.vue
Normal file
34
app/components/TypeEditToolbar.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="$emit('toggle')">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-if="allExpanded"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 12H6"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v12m6-6H6"
|
||||
/>
|
||||
</svg>
|
||||
{{ allExpanded ? 'Tout plier' : 'Tout déplier' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
@@ -89,11 +89,10 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Constructeur</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="piece.constructeur"
|
||||
type="text"
|
||||
placeholder="Constructeur"
|
||||
class="input input-bordered input-sm"
|
||||
<ConstructeurSelect
|
||||
:model-value="piece.constructeurId || piece.constructeur?.id || null"
|
||||
placeholder="Rechercher un constructeur..."
|
||||
@update:modelValue="(value) => setPieceConstructeur(index, value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
@@ -235,22 +234,44 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
allExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
expandAllTrigger: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const pieces = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
pieces.value = newValue
|
||||
initializeExpansionState()
|
||||
})
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
pieces.value = newValue
|
||||
initializeExpansionState()
|
||||
hydrateConstructeurs()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.expandAllTrigger,
|
||||
() => {
|
||||
setAllExpanded(props.allExpanded)
|
||||
}
|
||||
)
|
||||
|
||||
const allExpanded = ref(false)
|
||||
const expandedPieces = ref([])
|
||||
@@ -314,11 +335,12 @@ const toggleAllPieces = () => {
|
||||
|
||||
const initializeExpansionState = () => {
|
||||
clearExpansionState()
|
||||
setAllExpanded(false)
|
||||
setAllExpanded(props.allExpanded)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeExpansionState()
|
||||
hydrateConstructeurs()
|
||||
})
|
||||
|
||||
// Méthodes pour les pièces
|
||||
@@ -326,7 +348,8 @@ const addPiece = () => {
|
||||
pieces.value.push({
|
||||
name: '',
|
||||
reference: '',
|
||||
constructeur: '',
|
||||
constructeur: null,
|
||||
constructeurId: null,
|
||||
emplacement: '',
|
||||
prix: null,
|
||||
customFields: []
|
||||
@@ -382,4 +405,27 @@ const updateFieldOptions = (pieceIndex, fieldIndex) => {
|
||||
pieces.value[pieceIndex].customFields[fieldIndex].optionsText = pieces.value[pieceIndex].customFields[fieldIndex].optionsText.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
}
|
||||
|
||||
const findConstructeurById = (id) => constructeurs.value.find(item => item.id === id) || null
|
||||
|
||||
const hydrateConstructeurs = () => {
|
||||
pieces.value.forEach((piece) => {
|
||||
if (piece.constructeurId && (!piece.constructeur || piece.constructeur.id !== piece.constructeurId)) {
|
||||
piece.constructeur = findConstructeurById(piece.constructeurId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setPieceConstructeur = (index, constructeurId) => {
|
||||
const piece = pieces.value[index]
|
||||
if (!piece) return
|
||||
piece.constructeurId = constructeurId
|
||||
piece.constructeur = constructeurId ? findConstructeurById(constructeurId) : null
|
||||
emit('update:modelValue', pieces.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => constructeurs.value.length,
|
||||
() => hydrateConstructeurs()
|
||||
)
|
||||
</script>
|
||||
|
||||
81
app/composables/useProfileSession.js
Normal file
81
app/composables/useProfileSession.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
||||
|
||||
const buildUrl = (path) => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = config.public.apiBaseUrl?.replace(/\/$/, '') || ''
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfileSession() {
|
||||
const activeProfile = useState('profileSession:active', () => null)
|
||||
const sessionLoaded = useState('profileSession:loaded', () => false)
|
||||
const loading = useState('profileSession:loading', () => false)
|
||||
|
||||
const getSessionHeaders = () => {
|
||||
if (!process.server) return undefined
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
||||
}
|
||||
|
||||
const fetchCurrentProfile = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
activeProfile.value = await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
activeProfile.value = null
|
||||
} else {
|
||||
console.error('Erreur lors du chargement du profil actif', error)
|
||||
activeProfile.value = null
|
||||
}
|
||||
} finally {
|
||||
sessionLoaded.value = true
|
||||
loading.value = false
|
||||
}
|
||||
return activeProfile.value
|
||||
}
|
||||
|
||||
const ensureSession = () => {
|
||||
if (!sessionLoaded.value) {
|
||||
return fetchCurrentProfile()
|
||||
}
|
||||
return Promise.resolve(activeProfile.value)
|
||||
}
|
||||
|
||||
const activateProfile = async (profileId) => {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { profileId },
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
await fetchCurrentProfile()
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
} finally {
|
||||
activeProfile.value = null
|
||||
sessionLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeProfile,
|
||||
loading,
|
||||
sessionLoaded,
|
||||
ensureSession,
|
||||
fetchCurrentProfile,
|
||||
activateProfile,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
67
app/composables/useProfiles.js
Normal file
67
app/composables/useProfiles.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
||||
|
||||
const buildUrl = (path) => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = config.public.apiBaseUrl?.replace(/\/$/, '') || ''
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfiles() {
|
||||
const profiles = useState('profiles:list', () => [])
|
||||
const loadingProfiles = useState('profiles:loading', () => false)
|
||||
const profilesLoaded = useState('profiles:loaded', () => false)
|
||||
|
||||
const getSessionHeaders = () => {
|
||||
if (!process.server) return undefined
|
||||
const headers = useRequestHeaders(['cookie'])
|
||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
||||
}
|
||||
|
||||
const fetchProfiles = async () => {
|
||||
loadingProfiles.value = true
|
||||
try {
|
||||
profiles.value = await $fetch(buildUrl('/profiles'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
profilesLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des profils', error)
|
||||
profiles.value = []
|
||||
profilesLoaded.value = false
|
||||
} finally {
|
||||
loadingProfiles.value = false
|
||||
}
|
||||
return profiles.value
|
||||
}
|
||||
|
||||
const createProfile = async ({ firstName, lastName }) => {
|
||||
const profile = await $fetch(buildUrl('/profiles'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { firstName, lastName },
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
await fetchProfiles()
|
||||
return profile
|
||||
}
|
||||
|
||||
const deleteProfile = async (profileId) => {
|
||||
await $fetch(buildUrl(`/profiles/${profileId}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getSessionHeaders(),
|
||||
})
|
||||
await fetchProfiles()
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
loadingProfiles,
|
||||
profilesLoaded,
|
||||
fetchProfiles,
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
}
|
||||
}
|
||||
12
app/middleware/profile.global.ts
Normal file
12
app/middleware/profile.global.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useProfileSession } from '#imports'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { ensureSession, activeProfile } = useProfileSession()
|
||||
await ensureSession()
|
||||
|
||||
const isProfilesRoute = to.path.startsWith('/profiles')
|
||||
|
||||
if (!activeProfile.value && !isProfilesRoute) {
|
||||
return navigateTo('/profiles')
|
||||
}
|
||||
})
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8 space-y-8">
|
||||
<div class="hero min-h-[25vh] bg-gradient-to-r from-primary to-secondary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-xl">
|
||||
<h1 class="mb-3 text-4xl font-bold">Gestion documentaire</h1>
|
||||
<p class="text-sm opacity-90">
|
||||
Consultez tous les documents liés à vos sites, machines et composants. Recherchez et filtrez pour retrouver rapidement l'information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Gestion documentaire"
|
||||
subtitle="Consultez tous les documents liés à vos sites, machines et composants. Recherchez et filtrez pour retrouver rapidement l'information."
|
||||
/>
|
||||
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
@@ -129,6 +123,7 @@ import { useDocuments } from '~/composables/useDocuments'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const { documents, loading, loadDocuments } = useDocuments()
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8 space-y-8">
|
||||
<div class="hero min-h-[30vh] bg-gradient-to-r from-primary to-secondary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">Générateur de Types</h1>
|
||||
<p class="mb-5">
|
||||
Créez rapidement un nouveau type de machine avec sa structure complète.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Générateur de Types"
|
||||
subtitle="Créez rapidement un nouveau type de machine avec sa structure complète."
|
||||
min-height="min-h-[18vh]"
|
||||
max-width="max-w-md"
|
||||
/>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body space-y-6">
|
||||
@@ -89,6 +85,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
||||
const { showError } = useToast()
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-[30vh] bg-gradient-to-r from-primary to-secondary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">Dashboard Inventaire</h1>
|
||||
<p class="mb-5">
|
||||
Vue hiérarchique de vos sites et machines industrielles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Dashboard Inventaire"
|
||||
subtitle="Vue hiérarchique de vos sites et machines industrielles."
|
||||
min-height="min-h-[24vh]"
|
||||
max-width="max-w-md"
|
||||
/>
|
||||
|
||||
<!-- Hierarchical View -->
|
||||
<div class="my-8">
|
||||
@@ -434,6 +430,7 @@ import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const { sites, loading, loadSites, createSite } = useSites()
|
||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Machine Details -->
|
||||
<div v-else-if="machine" class="space-y-8">
|
||||
<div v-else-if="machine" ref="printAreaRef" class="space-y-8">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
@@ -14,22 +14,37 @@
|
||||
/>
|
||||
|
||||
<!-- Header with Edit Button -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<h1 class="text-3xl font-bold">Détails de la machine</h1>
|
||||
<button
|
||||
@click="toggleEditMode"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-outline': isEditMode }"
|
||||
>
|
||||
<svg v-if="!isEditMode" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||
<button
|
||||
@click="toggleEditMode"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-outline': isEditMode }"
|
||||
>
|
||||
<svg v-if="!isEditMode" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isEditMode"
|
||||
@click="openPrintModal"
|
||||
type="button"
|
||||
class="btn btn-outline btn-secondary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2V9a2 2 0 00-2-2h-2V4a1 1 0 00-1-1H8a1 1 0 00-1 1v3H5a2 2 0 00-2 2v6a2 2 0 002 2h2">
|
||||
</path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17H7v5h10v-5z"></path>
|
||||
</svg>
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug info -->
|
||||
@@ -40,19 +55,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="hero bg-gradient-to-r from-primary to-secondary min-h-[20vh] rounded-lg">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">{{ machine.name }}</h1>
|
||||
<p class="mb-5">{{ machine.description || machine.typeMachine?.description }}</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<div class="badge badge-outline">{{ machine.typeMachine?.category || 'N/A' }}</div>
|
||||
<div class="badge badge-outline">{{ machine.site?.name }}</div>
|
||||
<div v-if="machine.reference" class="badge badge-outline">{{ machine.reference }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
:title="machine.name"
|
||||
:subtitle="machine.description || machine.typeMachine?.description"
|
||||
min-height="min-h-[20vh]"
|
||||
max-width="max-w-md"
|
||||
rounded
|
||||
>
|
||||
<div class="flex justify-center gap-4">
|
||||
<div class="badge badge-outline">{{ machine.typeMachine?.category || 'N/A' }}</div>
|
||||
<div class="badge badge-outline">{{ machine.site?.name }}</div>
|
||||
<div v-if="machine.reference" class="badge badge-outline">{{ machine.reference }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageHero>
|
||||
|
||||
<!-- Machine Info Card -->
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
@@ -367,10 +382,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<MachinePrintSelectionModal
|
||||
:open="printModalOpen"
|
||||
:selection="printSelection"
|
||||
:components="components"
|
||||
:pieces="machinePieces"
|
||||
@close="closePrintModal"
|
||||
@confirm="handlePrintConfirm"
|
||||
@select-all="setAllPrintSelection(true)"
|
||||
@deselect-all="setAllPrintSelection(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
@@ -386,6 +412,9 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
|
||||
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
|
||||
|
||||
const route = useRoute()
|
||||
const machineId = route.params.id
|
||||
@@ -420,6 +449,7 @@ const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
const components = ref([])
|
||||
const pieces = ref([])
|
||||
const printAreaRef = ref(null)
|
||||
|
||||
const { constructeurs, loadConstructeurs } = useConstructeurs()
|
||||
|
||||
@@ -442,6 +472,16 @@ const machineDocumentsUploading = ref(false)
|
||||
const machineDocumentsLoaded = ref(false)
|
||||
const previewDocument = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
const printModalOpen = ref(false)
|
||||
const printSelection = reactive({
|
||||
machine: {
|
||||
info: true,
|
||||
customFields: true,
|
||||
documents: true,
|
||||
},
|
||||
components: {},
|
||||
pieces: {},
|
||||
})
|
||||
|
||||
const handleMachineConstructeurChange = async (value) => {
|
||||
machineConstructeurId.value = value
|
||||
@@ -567,6 +607,114 @@ const closePreview = () => {
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const ensurePrintSelectionEntries = () => {
|
||||
printSelection.machine.info ??= true
|
||||
printSelection.machine.customFields ??= true
|
||||
printSelection.machine.documents ??= true
|
||||
|
||||
const ensureComponent = (component) => {
|
||||
if (component?.id !== undefined && printSelection.components[component.id] === undefined) {
|
||||
printSelection.components[component.id] = true
|
||||
}
|
||||
;(component.pieces || []).forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id] === undefined) {
|
||||
printSelection.pieces[piece.id] = true
|
||||
}
|
||||
})
|
||||
;(component.subComponents || []).forEach(ensureComponent)
|
||||
}
|
||||
|
||||
components.value.forEach(ensureComponent)
|
||||
machinePieces.value.forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id] === undefined) {
|
||||
printSelection.pieces[piece.id] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setAllPrintSelection = (value) => {
|
||||
ensurePrintSelectionEntries()
|
||||
printSelection.machine.info = value
|
||||
printSelection.machine.customFields = value
|
||||
printSelection.machine.documents = value
|
||||
Object.keys(printSelection.components).forEach((key) => {
|
||||
printSelection.components[key] = value
|
||||
})
|
||||
Object.keys(printSelection.pieces).forEach((key) => {
|
||||
printSelection.pieces[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
const openPrintModal = () => {
|
||||
ensurePrintSelectionEntries()
|
||||
printModalOpen.value = true
|
||||
}
|
||||
|
||||
const closePrintModal = () => {
|
||||
printModalOpen.value = false
|
||||
}
|
||||
|
||||
const handlePrintConfirm = async () => {
|
||||
closePrintModal()
|
||||
await nextTick()
|
||||
printMachine(printSelection)
|
||||
}
|
||||
|
||||
|
||||
const printMachine = (currentSelection = printSelection) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const context = buildMachinePrintContext({
|
||||
machine: machine.value,
|
||||
machineName: machineName.value,
|
||||
machineReference: machineReference.value,
|
||||
machineEmplacement: machineEmplacement.value,
|
||||
machinePieces: machinePieces.value,
|
||||
components: components.value,
|
||||
selection: currentSelection,
|
||||
})
|
||||
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
|
||||
.map(node => node.outerHTML)
|
||||
.join('')
|
||||
|
||||
const htmlContent = buildMachinePrintHtml(context, styles)
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.position = 'fixed'
|
||||
iframe.style.right = '0'
|
||||
iframe.style.bottom = '0'
|
||||
iframe.style.width = '0'
|
||||
iframe.style.height = '0'
|
||||
iframe.style.border = '0'
|
||||
iframe.setAttribute('title', 'print-frame')
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const iframeWindow = iframe.contentWindow
|
||||
const iframeDocument = iframe.contentDocument || iframeWindow?.document
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
iframe.remove()
|
||||
return
|
||||
}
|
||||
|
||||
iframeDocument.open()
|
||||
iframeDocument.write(htmlContent)
|
||||
iframeDocument.close()
|
||||
|
||||
const triggerPrint = () => {
|
||||
iframeWindow.focus()
|
||||
iframeWindow.print()
|
||||
setTimeout(() => {
|
||||
iframe.remove()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
if (iframeDocument.readyState === 'complete') {
|
||||
setTimeout(triggerPrint, 50)
|
||||
} else {
|
||||
iframe.onload = () => setTimeout(triggerPrint, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) return '—'
|
||||
if (size === 0) return '0 B'
|
||||
@@ -868,6 +1016,14 @@ const toggleEditMode = () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [components.value.length, machinePieces.value.length],
|
||||
() => {
|
||||
ensurePrintSelectionEntries()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadMachineData()
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-[30vh] bg-gradient-to-r from-primary to-secondary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">Gestion des Machines</h1>
|
||||
<p class="mb-5">
|
||||
Gérez vos machines industrielles par site et par type.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Gestion des Machines"
|
||||
subtitle="Gérez vos machines industrielles par site et par type."
|
||||
min-height="min-h-[24vh]"
|
||||
max-width="max-w-md"
|
||||
/>
|
||||
|
||||
<!-- Machines Management -->
|
||||
<div class="my-8">
|
||||
@@ -250,15 +246,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const { machines, loading, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||
const route = useRoute()
|
||||
|
||||
// Data
|
||||
const showAddMachineModal = ref(false)
|
||||
@@ -367,13 +365,16 @@ onMounted(async () => {
|
||||
loadSites(),
|
||||
loadMachineTypes()
|
||||
])
|
||||
|
||||
// Vérifier si on doit ouvrir automatiquement la modale d'ajout
|
||||
const route = useRoute()
|
||||
if (route.query.add === 'true') {
|
||||
showAddMachineModal.value = true
|
||||
// Nettoyer l'URL
|
||||
await navigateTo('/machines', { replace: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
watch(
|
||||
() => route.query.add,
|
||||
async (shouldOpen) => {
|
||||
if (shouldOpen === 'true') {
|
||||
showAddMachineModal.value = true
|
||||
await navigateTo('/machines', { replace: true })
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
90
app/pages/profiles/index.vue
Normal file
90
app/pages/profiles/index.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||
<div class="w-full max-w-2xl">
|
||||
<div class="card bg-base-100 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<h1 class="text-2xl font-bold mb-2">Choisir un profil</h1>
|
||||
<p class="text-sm text-base-content/70 mb-6">
|
||||
Sélectionnez votre profil pour accéder à l'application. La création et la gestion se font via le menu utilisateur.
|
||||
</p>
|
||||
|
||||
<section class="space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Profils disponibles</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="refreshProfiles"
|
||||
:disabled="loadingProfiles"
|
||||
>
|
||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs"></span>
|
||||
<span v-else>Rafraîchir</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto" v-if="profiles.length">
|
||||
<button
|
||||
v-for="profile in profiles"
|
||||
:key="profile.id"
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm w-full justify-between"
|
||||
@click="selectProfile(profile.id)"
|
||||
>
|
||||
<span>{{ profile.firstName }} {{ profile.lastName }}</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm text-base-content/60">Aucun profil enregistré.</p>
|
||||
</section>
|
||||
|
||||
<footer class="mt-6 flex justify-between items-center" v-if="activeProfile">
|
||||
<div class="text-sm text-base-content/70">
|
||||
Profil actuel :
|
||||
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
|
||||
Déconnexion
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProfiles } from '#imports'
|
||||
import { useProfileSession } from '#imports'
|
||||
|
||||
const router = useRouter()
|
||||
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
|
||||
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
||||
|
||||
const refreshProfiles = async () => {
|
||||
await fetchProfiles()
|
||||
}
|
||||
|
||||
const selectProfile = async (profileId) => {
|
||||
try {
|
||||
await activateProfile(profileId)
|
||||
await fetchProfiles()
|
||||
await router.push('/')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sélection du profil', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
await router.push('/profiles')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchProfiles()
|
||||
await fetchCurrentProfile()
|
||||
})
|
||||
</script>
|
||||
174
app/pages/profiles/manage.vue
Normal file
174
app/pages/profiles/manage.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<main class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Gestion des profils</h1>
|
||||
<p class="text-sm text-base-content/70">Sélectionnez, créez ou supprimez des profils.</p>
|
||||
</div>
|
||||
<NuxtLink to="/" class="btn btn-ghost btn-sm">Retour</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<section class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="card-title text-lg">Profils existants</h2>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="refresh" :disabled="loadingProfiles">
|
||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs"></span>
|
||||
<span v-else>Rafraîchir</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="space-y-2 max-h-80 overflow-y-auto" v-if="profiles.length">
|
||||
<div
|
||||
v-for="profile in profiles"
|
||||
:key="profile.id"
|
||||
class="flex items-center justify-between rounded-lg border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ profile.firstName }} {{ profile.lastName }}</p>
|
||||
<p class="text-xs text-base-content/60">ID : {{ profile.id }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="profile.id === activeProfile?.id ? 'btn-primary' : 'btn-outline'"
|
||||
@click="select(profile.id)"
|
||||
>
|
||||
{{ profile.id === activeProfile?.id ? 'Actif' : 'Activer' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm"
|
||||
@click="remove(profile.id)"
|
||||
>
|
||||
<span v-if="deleting === profile.id" class="loading loading-spinner loading-xs"></span>
|
||||
<span v-else>Supprimer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-base-content/60">Aucun profil enregistré.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<h2 class="card-title text-lg">Créer un profil</h2>
|
||||
<form class="space-y-3" @submit.prevent="create">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Prénom</span></label>
|
||||
<input
|
||||
v-model="createForm.firstName"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
placeholder="Prénom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Nom</span></label>
|
||||
<input
|
||||
v-model="createForm.lastName"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
placeholder="Nom"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full" :disabled="creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-sm"></span>
|
||||
<span v-else>Créer et activer</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center justify-between bg-base-100 shadow-lg rounded-lg p-4" v-if="activeProfile">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Profil actif :</p>
|
||||
<p class="font-semibold text-base-content">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline" @click="handleLogout">
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProfiles, useProfileSession } from '#imports'
|
||||
|
||||
const router = useRouter()
|
||||
const { profiles, loadingProfiles, fetchProfiles, createProfile, deleteProfile } = useProfiles()
|
||||
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
||||
|
||||
const createForm = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
})
|
||||
|
||||
const creating = ref(false)
|
||||
const deleting = ref(null)
|
||||
|
||||
const refresh = async () => {
|
||||
await fetchProfiles()
|
||||
await fetchCurrentProfile()
|
||||
}
|
||||
|
||||
const select = async (profileId) => {
|
||||
try {
|
||||
await activateProfile(profileId)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sélection du profil', error)
|
||||
}
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
const profile = await createProfile({
|
||||
firstName: createForm.firstName,
|
||||
lastName: createForm.lastName,
|
||||
})
|
||||
createForm.firstName = ''
|
||||
createForm.lastName = ''
|
||||
await activateProfile(profile.id)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du profil', error)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (profileId) => {
|
||||
if (!confirm('Supprimer ce profil ?')) return
|
||||
deleting.value = profileId
|
||||
try {
|
||||
await deleteProfile(profileId)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du profil', error)
|
||||
} finally {
|
||||
deleting.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
await refresh()
|
||||
await router.push('/profiles')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
})
|
||||
</script>
|
||||
@@ -6,16 +6,12 @@
|
||||
@close="closePreview"
|
||||
/>
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-[30vh] bg-gradient-to-r from-primary to-secondary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">Gestion des Sites</h1>
|
||||
<p class="mb-5">
|
||||
Gérez vos sites industriels et leurs emplacements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Gestion des Sites"
|
||||
subtitle="Gérez vos sites industriels et leurs emplacements."
|
||||
min-height="min-h-[24vh]"
|
||||
max-width="max-w-md"
|
||||
/>
|
||||
|
||||
<!-- Sites Management -->
|
||||
<div class="my-8">
|
||||
@@ -374,7 +370,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
@@ -382,9 +378,11 @@ import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const { sites, loading, loadSites, createSite, updateSite, deleteSite } = useSites()
|
||||
const { uploadDocuments, deleteDocument, loadDocumentsBySite } = useDocuments()
|
||||
const route = useRoute()
|
||||
|
||||
// Data
|
||||
const showAddSiteModal = ref(false)
|
||||
@@ -617,13 +615,16 @@ const confirmDeleteSite = async (site) => {
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await loadSites()
|
||||
|
||||
// Vérifier si on doit ouvrir automatiquement la modale d'ajout
|
||||
const route = useRoute()
|
||||
if (route.query.add === 'true') {
|
||||
showAddSiteModal.value = true
|
||||
// Nettoyer l'URL
|
||||
await navigateTo('/sites', { replace: true })
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.add,
|
||||
async (shouldOpen) => {
|
||||
if (shouldOpen === 'true') {
|
||||
showAddSiteModal.value = true
|
||||
await navigateTo('/sites', { replace: true })
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-[30vh] bg-gradient-to-r from-secondary to-primary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">Modifier le Type</h1>
|
||||
<p class="mb-5">
|
||||
Ajoutez des éléments au type de machine existant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Modifier le Type"
|
||||
subtitle="Ajoutez des éléments au type de machine existant."
|
||||
gradient-from="from-secondary"
|
||||
gradient-to="to-primary"
|
||||
min-height="min-h-[24vh]"
|
||||
max-width="max-w-md"
|
||||
/>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="my-8 text-center">
|
||||
@@ -122,6 +120,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { getMachineTypeById } = useMachineTypesApi()
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-[30vh] bg-gradient-to-r from-secondary to-primary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">Modifier le Type</h1>
|
||||
<p class="mb-5">
|
||||
Modifiez les informations du type de machine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Modifier le Type"
|
||||
subtitle="Modifiez les informations du type de machine."
|
||||
gradient-from="from-secondary"
|
||||
gradient-to="to-primary"
|
||||
min-height="min-h-[24vh]"
|
||||
max-width="max-w-md"
|
||||
/>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="my-8 text-center">
|
||||
@@ -62,6 +60,7 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -218,4 +217,4 @@ onMounted(async () => {
|
||||
console.log('Loading finished, loading.value:', loading.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero min-h-[30vh] bg-gradient-to-r from-primary to-secondary">
|
||||
<div class="hero-content text-center text-neutral-content">
|
||||
<div class="max-w-md">
|
||||
<h1 class="mb-5 text-4xl font-bold">Types de Machines</h1>
|
||||
<p class="mb-5">
|
||||
Consultez et gérez les différents types de machines avec leur structure hiérarchique.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHero
|
||||
title="Types de Machines"
|
||||
subtitle="Consultez et gérez les différents types de machines avec leur structure hiérarchique."
|
||||
min-height="min-h-[24vh]"
|
||||
max-width="max-w-md"
|
||||
/>
|
||||
|
||||
<!-- Machine Types List -->
|
||||
<div class="my-8">
|
||||
@@ -102,6 +98,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
|
||||
const { machineTypes, loading, loadMachineTypes, deleteMachineType } = useMachineTypesApi()
|
||||
|
||||
@@ -144,4 +141,4 @@ const confirmDeleteType = async (type) => {
|
||||
onMounted(async () => {
|
||||
await loadMachineTypes()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
567
app/utils/printTemplates/machineReport.js
Normal file
567
app/utils/printTemplates/machineReport.js
Normal file
@@ -0,0 +1,567 @@
|
||||
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) return '—'
|
||||
if (size === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const renderPrintField = (label, value, fallback = '—') => {
|
||||
const display = value !== undefined && value !== null && value !== '' ? value : fallback
|
||||
return `<div class="print-field"><label>${label}</label><span>${display}</span></div>`
|
||||
}
|
||||
|
||||
const renderPrintCustomFields = (fields = [], title, sectionClass = 'print-section') => {
|
||||
if (!fields.length) return ''
|
||||
const items = fields
|
||||
.map((field) => `<div class="print-field"><label>${field.label}</label><span>${field.value || '—'}</span></div>`)
|
||||
.join('')
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>${title}</h3>
|
||||
<div class="print-grid">
|
||||
${items}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const renderPrintDocuments = (documents = [], title, sectionClass = 'print-section') => {
|
||||
if (!documents.length) return ''
|
||||
const rows = documents
|
||||
.map((doc) => `<tr><td>${doc.name}</td><td>${doc.type}</td><td>${doc.size}</td></tr>`)
|
||||
.join('')
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>${title}</h3>
|
||||
<table class="print-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Taille</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const renderPrintPieces = (
|
||||
pieces = [],
|
||||
title = 'Pièces indépendantes',
|
||||
sectionClass = 'print-section print-section--pieces',
|
||||
) => {
|
||||
if (!pieces.length) return ''
|
||||
|
||||
const cards = pieces
|
||||
.map((piece, idx) => {
|
||||
const indexLabel = piece.indexPath ? piece.indexPath.join('.') : `${idx + 1}`
|
||||
const constructeurBadge = piece.constructeur?.name
|
||||
? `<span class="print-badge print-badge--subtle">Constructeur: ${piece.constructeur.name}</span>`
|
||||
: ''
|
||||
|
||||
const customFields = (piece.customFields || [])
|
||||
.filter((field) => field.value && field.value !== '—' && field.value !== '')
|
||||
.map(
|
||||
(field) => `
|
||||
<li>
|
||||
<span class="print-list-label">${field.label}</span>
|
||||
<span class="print-list-value">${field.value}</span>
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join('')
|
||||
|
||||
const customFieldsBlock = customFields
|
||||
? `<div class="print-piece-section"><h4>Champs personnalisés</h4><ul class="print-list">${customFields}</ul></div>`
|
||||
: ''
|
||||
|
||||
const documentsBlock = (piece.documents || []).length
|
||||
? `<div class="print-piece-section"><h4>Documents</h4><ul class="print-list">${piece.documents
|
||||
.map((doc) => `<li>${doc.name} <span class="print-list-hint">(${doc.type} • ${doc.size})</span></li>`)
|
||||
.join('')}</ul></div>`
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="print-piece-card">
|
||||
<div class="print-piece-header">
|
||||
<span class="print-index print-index--piece">${indexLabel}</span>
|
||||
<div class="print-piece-heading">
|
||||
<div class="print-piece-title">${piece.name}</div>
|
||||
<div class="print-piece-subtitle">${piece.reference || 'Référence non définie'}</div>
|
||||
</div>
|
||||
${constructeurBadge}
|
||||
</div>
|
||||
${piece.description ? `<p class="print-piece-description">${piece.description}</p>` : ''}
|
||||
<div class="print-piece-meta">
|
||||
<div class="print-field-mini">
|
||||
<label>Constructeur</label>
|
||||
<span>${piece.constructeur?.name || '—'}</span>
|
||||
</div>
|
||||
<div class="print-field-mini">
|
||||
<label>Contact</label>
|
||||
<span>${piece.constructeur?.contact || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
${customFieldsBlock}
|
||||
${documentsBlock}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>${title}</h3>
|
||||
<div class="print-piece-grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
|
||||
if (!components.length) return ''
|
||||
return components
|
||||
.map((component, idx) => {
|
||||
const badges = []
|
||||
if (component.constructeur?.name) {
|
||||
badges.push(`Constructeur: ${component.constructeur.name}`)
|
||||
}
|
||||
const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}`
|
||||
const currentIndex = [...indexPath, idx + 1]
|
||||
const indexLabel = currentIndex.join('.')
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>
|
||||
<span class="print-index print-index--component">${indexLabel}</span>
|
||||
<span>Composant : ${component.name}</span>
|
||||
</h3>
|
||||
${component.description ? `<p class="print-muted">${component.description}</p>` : ''}
|
||||
${badges.length ? `<div class="badge-group">${badges.map((badge) => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''}
|
||||
${renderPrintCustomFields(
|
||||
component.customFields,
|
||||
'Champs personnalisés',
|
||||
'print-section print-subsection print-section--custom-fields',
|
||||
)}
|
||||
${renderPrintPieces(
|
||||
(component.pieces || []).map((piece, pieceIdx) => ({ ...piece, indexPath: [...currentIndex, pieceIdx + 1] })),
|
||||
'Pièces du composant',
|
||||
'print-section print-subsection print-section--pieces',
|
||||
)}
|
||||
${renderPrintDocuments(
|
||||
component.documents,
|
||||
'Documents du composant',
|
||||
'print-section print-subsection print-section--documents',
|
||||
)}
|
||||
${renderPrintComponents(component.subComponents || [], depth + 1, currentIndex)}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const normalizeDocuments = (docs = []) => {
|
||||
return docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name || doc.filename || 'Document',
|
||||
type: doc.mimeType || doc.type || '—',
|
||||
size: formatSize(doc.size),
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizeCustomFields = (values = []) => {
|
||||
return values.map((value) => ({
|
||||
id: value.id,
|
||||
label: value.customField?.name || 'Champ',
|
||||
value: value.value || value.customField?.defaultValue || '—',
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizeConstructeur = (constructeur) => {
|
||||
if (!constructeur) return null
|
||||
return {
|
||||
name: constructeur.name || '—',
|
||||
contact: [constructeur.email, constructeur.phone].filter(Boolean).join(' • ') || '—',
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePiece = (piece) => ({
|
||||
id: piece.id,
|
||||
name: piece.name || 'Pièce sans nom',
|
||||
description: piece.description || '',
|
||||
reference: piece.reference || '',
|
||||
customFields: normalizeCustomFields(piece.customFieldValues || []),
|
||||
documents: normalizeDocuments(piece.documents || []),
|
||||
constructeur: normalizeConstructeur(piece.constructeur),
|
||||
indexPath: piece.indexPath || null,
|
||||
})
|
||||
|
||||
const normalizeComponent = (component) => ({
|
||||
id: component.id,
|
||||
name: component.name || 'Composant sans nom',
|
||||
description: component.description || '',
|
||||
customFields: normalizeCustomFields(component.customFieldValues || []),
|
||||
documents: normalizeDocuments(component.documents || []),
|
||||
pieces: (component.pieces || []).map(normalizePiece),
|
||||
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
|
||||
constructeur: normalizeConstructeur(component.constructeur),
|
||||
})
|
||||
|
||||
export const buildMachinePrintContext = ({
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machineEmplacement,
|
||||
machinePieces = [],
|
||||
components = [],
|
||||
selection,
|
||||
}) => {
|
||||
const selectionState = selection || {}
|
||||
const machineSelection = selectionState.machine || {}
|
||||
const componentSelection = selectionState.components || {}
|
||||
const pieceSelection = selectionState.pieces || {}
|
||||
|
||||
const includeMachineInfo = machineSelection.info !== false
|
||||
const includeMachineCustomFields = machineSelection.customFields !== false
|
||||
const includeMachineDocuments = machineSelection.documents !== false
|
||||
|
||||
const isComponentSelected = (id) => {
|
||||
if (!id) return true
|
||||
if (Object.prototype.hasOwnProperty.call(componentSelection, id)) {
|
||||
return componentSelection[id]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const isPieceSelected = (id) => {
|
||||
if (!id) return true
|
||||
if (Object.prototype.hasOwnProperty.call(pieceSelection, id)) {
|
||||
return pieceSelection[id]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const machineBadges = []
|
||||
if (machine?.typeMachine?.category) {
|
||||
machineBadges.push(machine.typeMachine.category)
|
||||
}
|
||||
if (machine?.site?.name) {
|
||||
machineBadges.push(`Site: ${machine.site.name}`)
|
||||
}
|
||||
if (machineReference) {
|
||||
machineBadges.push(`Ref: ${machineReference}`)
|
||||
}
|
||||
|
||||
const normalizedPieces = machinePieces
|
||||
.map(normalizePiece)
|
||||
.filter((piece) => isPieceSelected(piece.id))
|
||||
.map((piece, idx) => ({
|
||||
...piece,
|
||||
indexPath: [idx + 1],
|
||||
}))
|
||||
|
||||
const normalizedComponents = components.map(normalizeComponent)
|
||||
|
||||
const filterComponentTree = (component) => {
|
||||
const filteredPieces = (component.pieces || []).filter((piece) => isPieceSelected(piece.id))
|
||||
const filteredSubComponents = (component.subComponents || [])
|
||||
.map(filterComponentTree)
|
||||
.filter(Boolean)
|
||||
|
||||
const includeSelf = isComponentSelected(component.id)
|
||||
const shouldInclude = includeSelf || filteredPieces.length > 0 || filteredSubComponents.length > 0
|
||||
|
||||
if (!shouldInclude) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...component,
|
||||
pieces: filteredPieces,
|
||||
subComponents: filteredSubComponents,
|
||||
}
|
||||
}
|
||||
|
||||
const filteredComponents = normalizedComponents
|
||||
.map(filterComponentTree)
|
||||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toLocaleString('fr-FR'),
|
||||
machine: {
|
||||
id: machine?.id || null,
|
||||
name: machineName,
|
||||
description: machine?.description || '',
|
||||
typeDescription: machine?.typeMachine?.description || '',
|
||||
reference: machineReference,
|
||||
emplacement: machineEmplacement,
|
||||
site: machine?.site?.name || '',
|
||||
category: machine?.typeMachine?.category || '',
|
||||
badges: machineBadges,
|
||||
constructeur: normalizeConstructeur(machine?.constructeur),
|
||||
includeInfo: includeMachineInfo,
|
||||
customFields: includeMachineCustomFields
|
||||
? normalizeCustomFields(machine?.customFieldValues || [])
|
||||
: [],
|
||||
documents: includeMachineDocuments
|
||||
? normalizeDocuments(machine?.documents || [])
|
||||
: [],
|
||||
},
|
||||
components: filteredComponents,
|
||||
pieces: normalizedPieces,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMachinePrintHtml = (context, styles) => {
|
||||
const title = context.machine.name ? `Impression - ${context.machine.name}` : 'Impression machine'
|
||||
const badgesHtml = context.machine.badges
|
||||
.map((badge) => `<span class="print-badge">${badge}</span>`)
|
||||
.join('')
|
||||
const sections = []
|
||||
|
||||
sections.push(`
|
||||
<div class="print-metadata">
|
||||
<span>Généré le ${context.generatedAt}</span>
|
||||
<span>Machine ID: ${context.machine.id || '—'}</span>
|
||||
</div>
|
||||
<div class="print-header">
|
||||
<div class="title-block">
|
||||
<div class="print-title">${context.machine.name || 'Machine sans nom'}</div>
|
||||
<div class="print-subtitle">${
|
||||
context.machine.description || context.machine.typeDescription || 'Aucune description disponible'
|
||||
}</div>
|
||||
</div>
|
||||
<div class="badge-group">${badgesHtml}</div>
|
||||
</div>
|
||||
`)
|
||||
|
||||
if (context.machine.includeInfo) {
|
||||
sections.push(`
|
||||
<div class="print-section print-section--machine">
|
||||
<h3>Informations générales</h3>
|
||||
<div class="print-grid">
|
||||
${renderPrintField('Nom', context.machine.name)}
|
||||
${renderPrintField('Référence', context.machine.reference, 'Non définie')}
|
||||
${renderPrintField('Emplacement', context.machine.emplacement, 'Non défini')}
|
||||
${renderPrintField('Site', context.machine.site, 'Non défini')}
|
||||
${renderPrintField('Constructeur', context.machine.constructeur?.name, 'Non défini')}
|
||||
${renderPrintField('Contact Constructeur', context.machine.constructeur?.contact, 'Non défini')}
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
const customFieldsSection = renderPrintCustomFields(
|
||||
context.machine.customFields,
|
||||
'Champs personnalisés de la machine',
|
||||
'print-section print-section--custom-fields',
|
||||
)
|
||||
if (customFieldsSection) {
|
||||
sections.push(customFieldsSection)
|
||||
}
|
||||
|
||||
const documentsSection = renderPrintDocuments(
|
||||
context.machine.documents,
|
||||
'Documents liés à la machine',
|
||||
'print-section print-section--documents',
|
||||
)
|
||||
if (documentsSection) {
|
||||
sections.push(documentsSection)
|
||||
}
|
||||
|
||||
const componentsSection = renderPrintComponents(context.components)
|
||||
if (componentsSection) {
|
||||
sections.push(componentsSection)
|
||||
}
|
||||
|
||||
const piecesSection = renderPrintPieces(
|
||||
context.pieces,
|
||||
'Pièces indépendantes',
|
||||
'print-section print-section--pieces',
|
||||
)
|
||||
if (piecesSection) {
|
||||
sections.push(piecesSection)
|
||||
}
|
||||
|
||||
sections.push(`
|
||||
<div class="print-section print-muted">
|
||||
Rapport généré automatiquement par Inventaire Pro.
|
||||
</div>
|
||||
`)
|
||||
|
||||
const content = sections.join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light only" />
|
||||
<title>${title}</title>
|
||||
${styles}
|
||||
<style>
|
||||
/* ============ Design tokens (sobre & pro) ============ */
|
||||
:root{
|
||||
--bg: #ffffff;
|
||||
--ink: #0f172a; /* slate-900 */
|
||||
--muted: #475569; /* slate-600 */
|
||||
--line: #cbd5e1; /* slate-300 */
|
||||
--line-soft: #e2e8f0; /* slate-200 */
|
||||
--accent: #1f2937; /* gray-800 */
|
||||
--accent-ink: #111827; /* gray-900 */
|
||||
--brand: #0ea5e9; /* sky-500 (léger) */
|
||||
--brand-ink: #075985; /* sky-800 */
|
||||
--ok: #059669; /* emerald-600 */
|
||||
--warn: #ea580c; /* orange-600 */
|
||||
--comp: #be185d; /* pink-700 */
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--shadow-0: none;
|
||||
--shadow-1: 0 1px 0 rgba(15,23,42,.04);
|
||||
--shadow-2: 0 2px 8px rgba(15,23,42,.06);
|
||||
}
|
||||
|
||||
/* ============ Base ============ */
|
||||
@page { margin: 16mm 14mm; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
background: #F9FAFB;
|
||||
color: var(--ink);
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
.print-layout { max-width: 1120px; margin: 0 auto; padding: 28px; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-metadata {
|
||||
font-size: 11px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
letter-spacing: .04em; text-transform: uppercase; border-bottom: 1px solid var(--line); padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.print-header {
|
||||
display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;
|
||||
background: var(--bg); padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.print-header .title-block { flex: 1; }
|
||||
.print-title { font-size: 26px; font-weight: 800; margin: 0 0 6px; color: var(--accent-ink); }
|
||||
.print-subtitle { font-size: 14px; color: var(--muted); margin: 0; line-height: 1.5; }
|
||||
|
||||
.badge-group { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
|
||||
.print-badge {
|
||||
display: inline-flex; align-items: center; padding: 4px 8px; border-radius: var(--radius-sm);
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: #e0f2fe; color: var(--brand-ink); border: 1px solid #bae6fd;
|
||||
}
|
||||
.print-badge--subtle { background: #f1f5f9; border-color: var(--line); color: #334155; }
|
||||
|
||||
.print-index {
|
||||
display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px;
|
||||
border-radius: var(--radius-sm); font-size: 12px; font-weight: 700; margin-right: 8px;
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.print-index--component { background: #fde2f2; color: var(--comp); }
|
||||
.print-index--piece { background: #ffedd5; color: var(--warn); }
|
||||
|
||||
/* ============ Sections ============ */
|
||||
.print-section, .print-section.print-subsection, .print-piece-card { break-inside: avoid; page-break-inside: avoid; }
|
||||
.print-section {
|
||||
margin: 0; padding: 20px 22px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line); background: var(--bg); box-shadow: var(--shadow-2); position: relative;
|
||||
}
|
||||
.print-section + .print-section { margin-top: 8px; }
|
||||
.print-section h3 {
|
||||
font-size: 16px; font-weight: 800; margin: 0 0 14px; letter-spacing: .02em; color: var(--accent);
|
||||
border-left: 3px solid var(--accent); padding-left: 10px;
|
||||
}
|
||||
|
||||
.print-section--machine { border-left: 3px solid var(--brand); }
|
||||
.print-section--custom-fields { border-left: 3px solid #7c3aed; }
|
||||
.print-section--documents { border-left: 3px solid var(--ok); }
|
||||
.print-section--component { border-left: 3px solid var(--comp); }
|
||||
.print-section--pieces { border-left: 3px solid var(--warn); }
|
||||
|
||||
.print-section.print-subsection {
|
||||
margin-top: 14px; margin-bottom: 10px; padding: 16px 18px; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-1); background: #fcfcfd;
|
||||
}
|
||||
|
||||
/* ============ Fields (grid) ============ */
|
||||
.print-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.print-field {
|
||||
display: flex; flex-direction: column; padding: 10px 12px; border-radius: var(--radius-sm);
|
||||
background: #fafbfc; border: 1px solid var(--line-soft); min-height: 72px;
|
||||
}
|
||||
.print-field label { font-size: 10px; text-transform: uppercase; color: #64748b; letter-spacing: .08em; margin-bottom: 8px; }
|
||||
.print-field span { font-size: 14px; color: var(--ink); font-weight: 600; line-height: 1.45; }
|
||||
|
||||
/* ============ Pieces ============ */
|
||||
.print-piece-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.print-piece-card {
|
||||
padding: 14px 16px; border-radius: var(--radius-md); border: 1px solid #fed7aa; background: var(--bg); box-shadow: var(--shadow-1);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.print-piece-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.print-piece-heading { flex: 1 1 160px; }
|
||||
.print-piece-title { font-size: 15px; font-weight: 800; color: #7c2d12; }
|
||||
.print-piece-subtitle { font-size: 11px; color: #a16207; text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; }
|
||||
.print-piece-description { font-size: 12px; color: #7c2d12; margin: 0; }
|
||||
|
||||
.print-piece-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
|
||||
.print-field-mini {
|
||||
padding: 8px 10px; border-radius: var(--radius-sm); background: #fff7ed; border: 1px solid #fed7aa; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.print-field-mini label { font-size: 9px; text-transform: uppercase; color: #9a3412; letter-spacing: .06em; }
|
||||
.print-field-mini span { font-size: 12px; font-weight: 700; color: #7c2d12; }
|
||||
.print-piece-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.print-piece-section h4 { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: #7c2d12; margin: 0; }
|
||||
.print-list { margin: 0; padding-left: 16px; display: grid; gap: 4px; }
|
||||
.print-list li { font-size: 12px; color: #475569; break-inside: avoid; }
|
||||
.print-list-label { font-weight: 700; color: #7c2d12; margin-right: 4px; }
|
||||
.print-list-value { color: var(--ink); }
|
||||
.print-list-hint { color: #64748B; }
|
||||
|
||||
/* ============ Tables ============ */
|
||||
.print-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 13px; border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.print-table th, .print-table td { border: 1px solid var(--line-soft); padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||
.print-table thead th { background: #f1f5f9; font-weight: 800; letter-spacing: .02em; text-transform: uppercase; color: var(--accent); font-size: 12px; }
|
||||
.print-table tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.print-section--documents .print-table thead th { background: #ecfdf5; color: #065f46; }
|
||||
.print-section--pieces .print-table thead th { background: #fffbeb; color: #92400e; }
|
||||
.print-section--component .print-table thead th { background: #fdf2f8; color: #831843; }
|
||||
|
||||
/* ============ Utilities ============ */
|
||||
.print-muted { color: var(--muted); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
||||
|
||||
/* ============ Print media ============ */
|
||||
@media print {
|
||||
body { background: white; }
|
||||
.print-layout { padding: 0; max-width: none; }
|
||||
.print-header, .print-section, .print-section.print-subsection, .print-piece-card, .print-table { box-shadow: var(--shadow-0); }
|
||||
.print-section, .print-section.print-subsection, .print-piece-card { break-inside: avoid-page; page-break-inside: avoid; }
|
||||
.badge-group { justify-content: flex-start; }
|
||||
.print-metadata { border-bottom-color: var(--line); }
|
||||
/* Force couleurs d’accent légères pour économie d’encre */
|
||||
.print-badge { background: #e5f3fb !important; border-color: #d1e7f8 !important; }
|
||||
.print-index--component { background: #fbe7f3 !important; }
|
||||
.print-index--piece { background: #fff1e6 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-layout">
|
||||
${content}
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
Reference in New Issue
Block a user