Files
Coltura/frontend/modules/sites/components/SiteDrawer.vue
tristan 03c761eed4 feat(sites) : barre de selection de site (ticket 3/4)
Barre horizontale en haut de l'app qui liste les sites autorises de
l'utilisateur et permet de switcher d'un click. Consomme le composant
MalioSiteSelector de @malio/layer-ui 1.4.0 (upgrade depuis 1.3.0).

Composables :
- useModules (shared) : consomme /api/modules, expose isModuleActive.
  Pattern aligne sur useSidebar.
- useCurrentSite (layer sites) : singleton state, switchSite optimistic
  avec rollback sur erreur, garde anti-double-submit, propagation au
  store auth via action setCurrentSite dediee.

Composant :
- SiteSelector.vue : wrapper thin autour de MalioSiteSelector. Texte
  blanc uniforme (conforme maquette Figma) avec taille 24px forcee via
  labelClass="text-2xl". aria-label du group via ariaGroupLabel i18n.

Integration :
- Middleware auth.global.ts : chargement parallele sidebar + modules.
- layouts/default.vue : render conditionnel si module Sites actif ET
  user.sites.length > 0.
- logout.vue : reset des 3 composables (sidebar, modules, currentSite)
  dans un try/finally.
- nuxt.config.ts : auto-detection des composables/ de chaque layer
  module (necessaire car imports.dirs explicite override les defaults
  Nuxt).

Couleurs fixtures finales : Chatellerault #056CF2, Saint-Jean #F3CB00,
Pommevic #74BF04. Charge aux admins de choisir des teintes foncees
(texte blanc non contrastable via calcul WCAG, design choisi).

Tests : 40 Vitest (color, useModules, useSidebar, useCurrentSite,
SiteSelector) incluant garde anti-regression pour useI18n hors setup.
182/182 PHPUnit backend, avec et sans module actif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:45:48 +02:00

186 lines
5.9 KiB
Vue

<template>
<MalioDrawer
:model-value="modelValue"
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
drawer-class="w-full max-w-lg"
@update:model-value="emit('update:modelValue', $event)"
>
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.name"
:label="t('admin.sites.form.name')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.street"
:label="t('admin.sites.form.street')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.complement"
:label="t('admin.sites.form.complement')"
:placeholder="t('admin.sites.form.complementPlaceholder')"
input-class="w-full"
/>
<!-- Code postal FR : masque "#####" (5 chiffres stricts) +
maxLength en double securite. La regex backend validera la
forme finale, le masque empeche juste la saisie de
caracteres non numeriques. -->
<MalioInputText
v-model="form.postalCode"
:label="t('admin.sites.form.postalCode')"
input-class="w-full"
mask="#####"
max-length="5"
required
/>
<MalioInputText
v-model="form.city"
:label="t('admin.sites.form.city')"
input-class="w-full"
required
/>
<!-- Champ couleur avec preview puce -->
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">
{{ t('admin.sites.form.color') }}
</label>
<div class="flex items-center gap-3">
<MalioInputText
v-model="form.color"
placeholder="#RRGGBB"
input-class="w-full font-mono"
required
/>
<span
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
:class="{ 'border-dashed': !isValidHex }"
/>
</div>
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
{{ t('admin.sites.form.colorInvalid') }}
</p>
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Site } from '~/shared/types/sites'
import { isValidSiteColor } from '~/shared/utils/color'
const { t } = useI18n()
const api = useApi()
const props = defineProps<{
modelValue: boolean
site: Site | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
const saving = ref(false)
const form = ref({
name: '',
street: '',
complement: '',
postalCode: '',
city: '',
color: '#000000',
})
const isEditMode = computed(() => props.site !== null)
// Validation locale du format hex #RRGGBB avant envoi backend.
const isValidHex = computed(() => isValidSiteColor(form.value.color))
// Remplir le formulaire quand le site change
watch(() => props.site, (site) => {
if (site) {
form.value.name = site.name
form.value.street = site.street
form.value.complement = site.complement ?? ''
form.value.postalCode = site.postalCode
form.value.city = site.city
form.value.color = site.color
} else {
form.value.name = ''
form.value.street = ''
form.value.complement = ''
form.value.postalCode = ''
form.value.city = ''
form.value.color = '#056CF2'
}
}, { immediate: true })
async function handleSave() {
if (!isValidHex.value) return
saving.value = true
try {
// Le champ complement est optionnel cote DB : on envoie null si vide
// pour que le backend stocke NULL plutot qu'une chaine vide.
const trimmedComplement = form.value.complement.trim()
const payload = {
name: form.value.name,
street: form.value.street,
complement: trimmedComplement === '' ? null : trimmedComplement,
postalCode: form.value.postalCode,
city: form.value.city,
color: form.value.color,
}
if (isEditMode.value && props.site) {
await api.patch(`/sites/${props.site.id}`, payload, {
toastSuccessMessage: t('admin.sites.toast.updated'),
})
} else {
await api.post('/sites', payload, {
toastSuccessMessage: t('admin.sites.toast.created'),
})
}
emit('saved')
emit('update:modelValue', false)
} finally {
saving.value = false
}
}
</script>