feat(permissions) : add role-based UI guards and readonly mode for viewers
- Add usePermissions composable (isAdmin, canEdit, canView) - Password-protected profile login with modal on profiles page - Disable all form fields for ROLE_VIEWER across edit/create pages - Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers - Add readonly prop to ModelTypeForm for category pages - Disable modal fields (sites, constructeurs) for viewers - Guard /admin routes in middleware Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -186,9 +186,9 @@
|
|||||||
Connecté en tant que<br />
|
Connecté en tant que<br />
|
||||||
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li v-if="isAdmin">
|
||||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
<NuxtLink to="/admin" class="justify-between">
|
||||||
Gestion des profils
|
Administration
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -214,6 +214,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from '#imports'
|
import { useRoute } from '#imports'
|
||||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
import { useProfileSession } from '~/composables/useProfileSession'
|
import { useProfileSession } from '~/composables/useProfileSession'
|
||||||
import IconLucideMenu from '~icons/lucide/menu'
|
import IconLucideMenu from '~icons/lucide/menu'
|
||||||
import IconLucideSettings from '~icons/lucide/settings'
|
import IconLucideSettings from '~icons/lucide/settings'
|
||||||
@@ -288,6 +289,7 @@ const navGroups: NavGroup[] = [
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||||
const { activeProfile } = useProfileSession()
|
const { activeProfile } = useProfileSession()
|
||||||
|
const { isAdmin } = usePermissions()
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
|
|||||||
import IconLucideEye from '~icons/lucide/eye'
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
import IconLucidePrinter from '~icons/lucide/printer'
|
import IconLucidePrinter from '~icons/lucide/printer'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
isDetailsView: boolean
|
isDetailsView: boolean
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
:dir="dir"
|
:dir="dir"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:show-category-tabs="allowCategorySwitch"
|
:show-category-tabs="allowCategorySwitch"
|
||||||
|
:can-edit="canEdit"
|
||||||
@update:category="onCategoryChange"
|
@update:category="onCategoryChange"
|
||||||
@update:search="onSearchInput"
|
@update:search="onSearchInput"
|
||||||
@update:sort="onSortChange"
|
@update:sort="onSortChange"
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
:category="selectedCategory"
|
:category="selectedCategory"
|
||||||
|
:can-edit="canEdit"
|
||||||
@related="openRelatedModal"
|
@related="openRelatedModal"
|
||||||
@edit="openEditPage"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
@@ -169,6 +171,7 @@ let activeController: AbortController | null = null;
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
const { get } = useApi();
|
const { get } = useApi();
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading);
|
||||||
const descriptionText = computed(
|
const descriptionText = computed(
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
name="category"
|
name="category"
|
||||||
required
|
required
|
||||||
:disabled="lockCategory"
|
:disabled="lockCategory || isReadonly"
|
||||||
>
|
>
|
||||||
<option value="COMPONENT">Composants</option>
|
<option value="COMPONENT">Composants</option>
|
||||||
<option value="PIECE">Pièces</option>
|
<option value="PIECE">Pièces</option>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
<button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
||||||
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
{{ submitLabel }}
|
{{ submitLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disableSubmitMessage?: string
|
disableSubmitMessage?: string
|
||||||
restrictedMode?: boolean
|
restrictedMode?: boolean
|
||||||
restrictedModeMessage?: string
|
restrictedModeMessage?: string
|
||||||
|
readonly?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
initialData: null,
|
initialData: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -187,6 +188,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disableSubmitMessage: '',
|
disableSubmitMessage: '',
|
||||||
restrictedMode: false,
|
restrictedMode: false,
|
||||||
restrictedModeMessage: '',
|
restrictedModeMessage: '',
|
||||||
|
readonly: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -209,7 +211,8 @@ const disableSubmitMessage = computed(() =>
|
|||||||
? props.disableSubmitMessage
|
? props.disableSubmitMessage
|
||||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||||
)
|
)
|
||||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
const isReadonly = computed(() => props.readonly === true)
|
||||||
|
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
|
||||||
const restrictedModeMessage = computed(() =>
|
const restrictedModeMessage = computed(() =>
|
||||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
||||||
? props.restrictedModeMessage
|
? props.restrictedModeMessage
|
||||||
@@ -291,7 +294,7 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
@@ -308,6 +311,7 @@ const validate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
if (isReadonly.value) return
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="showConvertButton"
|
v-if="canEdit && showConvertButton"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm text-warning"
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
@click="emit('convert', item)"
|
@click="emit('convert', item)"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="showConvertButton"
|
v-if="canEdit && showConvertButton"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm text-warning"
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
@click="emit('convert', item)"
|
@click="emit('convert', item)"
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -146,6 +146,7 @@ const props = defineProps<{
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
category?: ModelCategory;
|
category?: ModelCategory;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const props = defineProps<{
|
|||||||
dir: SortDirection;
|
dir: SortDirection;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
showCategoryTabs?: boolean;
|
showCategoryTabs?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
||||||
Modifier
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error" @click="emit('delete', site)">
|
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +55,8 @@ import IconLucidePhone from '~icons/lucide/phone'
|
|||||||
import IconLucideUser from '~icons/lucide/user'
|
import IconLucideUser from '~icons/lucide/user'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
site: {
|
site: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom et prénom"
|
placeholder="Nom et prénom"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldPhone v-model="contactPhone" required />
|
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Adresse complète"
|
placeholder="Adresse complète"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Code postal"
|
placeholder="Code postal"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ville"
|
placeholder="Ville"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +81,10 @@ const props = defineProps({
|
|||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = toRef(props, 'form')
|
const form = toRef(props, 'form')
|
||||||
|
|||||||
@@ -12,17 +12,18 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Usine principale"
|
placeholder="Ex: Usine principale"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="siteRef" />
|
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||||
Créer le site
|
Créer le site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +54,10 @@ const props = defineProps({
|
|||||||
site: {
|
site: {
|
||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if="visible" class="modal modal-open">
|
<div v-if="visible" class="modal modal-open">
|
||||||
<div class="modal-box max-w-md">
|
<div class="modal-box max-w-md">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
Modifier le site
|
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
|
||||||
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="emit('submit')">
|
<form class="space-y-4" @submit.prevent="emit('submit')">
|
||||||
@@ -15,11 +15,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom du site"
|
placeholder="Nom du site"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="props.form" />
|
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
|
v-if="!disabled"
|
||||||
v-model="selectedFilesModel"
|
v-model="selectedFilesModel"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
<button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +105,7 @@
|
|||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments">
|
<button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
|
||||||
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -155,6 +157,10 @@ const props = defineProps({
|
|||||||
formatSize: {
|
formatSize: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
80
app/composables/useAdminProfiles.ts
Normal file
80
app/composables/useAdminProfiles.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
|
||||||
|
export interface AdminProfile {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string | null
|
||||||
|
isActive: boolean
|
||||||
|
hasPassword: boolean
|
||||||
|
roles: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminProfiles() {
|
||||||
|
const { get, post, put } = useApi()
|
||||||
|
const profiles = ref<AdminProfile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await get<AdminProfile[]>('/admin/profiles')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
profiles.value = result.data
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProfile = async (data: {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
role?: string
|
||||||
|
}) => {
|
||||||
|
const result = await post<AdminProfile>('/admin/profiles', data)
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRole = async (id: string, role: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPassword = async (id: string, password: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateProfile = async (id: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles,
|
||||||
|
loading,
|
||||||
|
fetchAll,
|
||||||
|
createProfile,
|
||||||
|
updateRole,
|
||||||
|
setPassword,
|
||||||
|
deactivateProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,9 @@ export function useApi() {
|
|||||||
const text = await response.text().catch(() => '')
|
const text = await response.text().catch(() => '')
|
||||||
errorData = text ? { message: text } : {}
|
errorData = text ? { message: text } : {}
|
||||||
}
|
}
|
||||||
const errorMessage = (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
const errorMessage = response.status === 403
|
||||||
|
? 'Permissions insuffisantes pour cette action.'
|
||||||
|
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
||||||
showError(errorMessage)
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage, status: response.status }
|
return { success: false, error: errorMessage, status: response.status }
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/composables/usePermissions.ts
Normal file
41
app/composables/usePermissions.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useProfileSession } from './useProfileSession'
|
||||||
|
|
||||||
|
const ROLE_HIERARCHY: Record<string, string[]> = {
|
||||||
|
ROLE_ADMIN: ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_GESTIONNAIRE: ['ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_VIEWER: ['ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_USER: ['ROLE_USER'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const { activeProfile } = useProfileSession()
|
||||||
|
|
||||||
|
const effectiveRoles = computed<string[]>(() => {
|
||||||
|
const roles = (activeProfile.value?.roles as string[] | undefined) ?? ['ROLE_USER']
|
||||||
|
const all = new Set<string>()
|
||||||
|
for (const role of roles) {
|
||||||
|
const inherited = ROLE_HIERARCHY[role] ?? [role]
|
||||||
|
for (const r of inherited) {
|
||||||
|
all.add(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...all]
|
||||||
|
})
|
||||||
|
|
||||||
|
const isGranted = (role: string): boolean => {
|
||||||
|
return effectiveRoles.value.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = computed(() => isGranted('ROLE_ADMIN'))
|
||||||
|
const canEdit = computed(() => isGranted('ROLE_GESTIONNAIRE'))
|
||||||
|
const canView = computed(() => isGranted('ROLE_VIEWER'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
canEdit,
|
||||||
|
canView,
|
||||||
|
isGranted,
|
||||||
|
effectiveRoles,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
import { useState, useRuntimeConfig } from '#imports'
|
||||||
import type { Profile } from './useProfiles'
|
import type { Profile } from './useProfiles'
|
||||||
|
|
||||||
const buildUrl = (path: string): string => {
|
const buildUrl = (path: string): string => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const baseUrl = import.meta.server
|
const base = ((config.public.apiBaseUrl as string) || '').replace(/\/$/, '')
|
||||||
? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
|
|
||||||
: ((config.public.apiBaseUrl as string) || '')
|
|
||||||
const base = baseUrl.replace(/\/$/, '')
|
|
||||||
return `${base}${path}`
|
return `${base}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,19 +12,12 @@ export function useProfileSession() {
|
|||||||
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
||||||
const loading = useState<boolean>('profileSession:loading', () => false)
|
const loading = useState<boolean>('profileSession:loading', () => false)
|
||||||
|
|
||||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
|
||||||
if (!import.meta.server) { return undefined }
|
|
||||||
const headers = useRequestHeaders(['cookie'])
|
|
||||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { status?: number }
|
const err = error as { status?: number }
|
||||||
@@ -51,12 +41,15 @@ export function useProfileSession() {
|
|||||||
return Promise.resolve(activeProfile.value)
|
return Promise.resolve(activeProfile.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activateProfile = async (profileId: string): Promise<void> => {
|
const activateProfile = async (profileId: string, password?: string): Promise<void> => {
|
||||||
|
const body: Record<string, string> = { profileId }
|
||||||
|
if (password) {
|
||||||
|
body.password = password
|
||||||
|
}
|
||||||
await $fetch(buildUrl('/session/profile'), {
|
await $fetch(buildUrl('/session/profile'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: { profileId },
|
body,
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
await fetchCurrentProfile()
|
await fetchCurrentProfile()
|
||||||
}
|
}
|
||||||
@@ -66,7 +59,6 @@ export function useProfileSession() {
|
|||||||
await $fetch(buildUrl('/session/profile'), {
|
await $fetch(buildUrl('/session/profile'), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
activeProfile.value = null
|
activeProfile.value = null
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
import { useState, useRuntimeConfig } from '#imports'
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string
|
id: string
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
|
email?: string | null
|
||||||
|
isActive?: boolean
|
||||||
|
hasPassword?: boolean
|
||||||
|
roles?: string[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,19 +22,12 @@ export function useProfiles() {
|
|||||||
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
||||||
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
||||||
|
|
||||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
|
||||||
if (!import.meta.server) { return undefined }
|
|
||||||
const headers = useRequestHeaders(['cookie'])
|
|
||||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchProfiles = async (): Promise<Profile[]> => {
|
const fetchProfiles = async (): Promise<Profile[]> => {
|
||||||
loadingProfiles.value = true
|
loadingProfiles.value = true
|
||||||
try {
|
try {
|
||||||
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
profilesLoaded.value = true
|
profilesLoaded.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -43,32 +40,10 @@ export function useProfiles() {
|
|||||||
return profiles.value
|
return profiles.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const createProfile = async ({ firstName, lastName }: { firstName: string; lastName: string }): Promise<Profile> => {
|
|
||||||
const profile = await $fetch<Profile>(buildUrl('/session/profiles'), {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: { firstName, lastName },
|
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
|
||||||
await fetchProfiles()
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteProfile = async (profileId: string): Promise<void> => {
|
|
||||||
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
|
||||||
await fetchProfiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profiles,
|
profiles,
|
||||||
loadingProfiles,
|
loadingProfiles,
|
||||||
profilesLoaded,
|
profilesLoaded,
|
||||||
fetchProfiles,
|
fetchProfiles,
|
||||||
createProfile,
|
|
||||||
deleteProfile,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useProfileSession } from "#imports";
|
import { useProfileSession, usePermissions } from "#imports";
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
const { ensureSession, fetchCurrentProfile, activeProfile } =
|
const { ensureSession, activeProfile } = useProfileSession();
|
||||||
useProfileSession();
|
|
||||||
await ensureSession();
|
await ensureSession();
|
||||||
|
|
||||||
const rawPath = to?.path ?? "";
|
const rawPath = to?.path ?? "";
|
||||||
@@ -14,11 +13,21 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
fullPath.startsWith("/profiles") ||
|
fullPath.startsWith("/profiles") ||
|
||||||
routeName.startsWith("profiles");
|
routeName.startsWith("profiles");
|
||||||
|
|
||||||
if (process.client && !activeProfile.value) {
|
// Redirect to login if no active profile
|
||||||
await fetchCurrentProfile();
|
if (!activeProfile.value && !isProfilesRoute) {
|
||||||
}
|
|
||||||
|
|
||||||
if (process.client && !activeProfile.value && !isProfilesRoute) {
|
|
||||||
return navigateTo("/profiles");
|
return navigateTo("/profiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permission checks
|
||||||
|
if (activeProfile.value) {
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
if (normalizedPath.startsWith("/admin")) {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
return navigateTo("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
245
app/pages/admin/index.vue
Normal file
245
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto p-6 max-w-6xl">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Administration des profils
|
||||||
|
</h1>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="showCreateDialog = true">
|
||||||
|
Nouveau profil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="profiles.length" class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Mot de passe</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="profile in profiles" :key="profile.id">
|
||||||
|
<td class="font-medium">
|
||||||
|
{{ profile.firstName }} {{ profile.lastName }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/70">
|
||||||
|
{{ profile.email || '-' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-xs"
|
||||||
|
:value="primaryRole(profile)"
|
||||||
|
@change="handleRoleChange(profile.id, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="ROLE_ADMIN">
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_GESTIONNAIRE">
|
||||||
|
Gestionnaire
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_VIEWER">
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||||
|
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs ml-1"
|
||||||
|
@click="openPasswordDialog(profile.id)"
|
||||||
|
>
|
||||||
|
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="profile.isActive ? 'badge-success' : 'badge-error'"
|
||||||
|
>
|
||||||
|
{{ profile.isActive ? 'Actif' : 'Inactif' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
v-if="profile.isActive"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="handleDeactivate(profile.id)"
|
||||||
|
>
|
||||||
|
Desactiver
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-center py-12 text-base-content/60">
|
||||||
|
Aucun profil.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Profile Dialog -->
|
||||||
|
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Nouveau profil
|
||||||
|
</h3>
|
||||||
|
<form @submit.prevent="handleCreate">
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Prenom</span></label>
|
||||||
|
<input v-model="createForm.firstName" type="text" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
|
<input v-model="createForm.lastName" type="text" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Email</span></label>
|
||||||
|
<input v-model="createForm.email" type="email" class="input input-bordered">
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Mot de passe</span></label>
|
||||||
|
<input v-model="createForm.password" type="password" class="input input-bordered">
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Role</span></label>
|
||||||
|
<select v-model="createForm.role" class="select select-bordered">
|
||||||
|
<option value="ROLE_ADMIN">
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_GESTIONNAIRE">
|
||||||
|
Gestionnaire
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_VIEWER">
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showCreateDialog = false">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="creating">
|
||||||
|
<span v-if="creating" class="loading loading-spinner loading-xs" />
|
||||||
|
Creer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button type="button" @click="showCreateDialog = false">
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Set Password Dialog -->
|
||||||
|
<dialog ref="passwordDialog" class="modal" :open="showPasswordDialog || undefined">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Definir le mot de passe
|
||||||
|
</h3>
|
||||||
|
<form @submit.prevent="handleSetPassword">
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Nouveau mot de passe</span></label>
|
||||||
|
<input v-model="newPassword" type="password" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showPasswordDialog = false">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="settingPassword">
|
||||||
|
<span v-if="settingPassword" class="loading loading-spinner loading-xs" />
|
||||||
|
Valider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button type="button" @click="showPasswordDialog = false">
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAdminProfiles } from '#imports'
|
||||||
|
|
||||||
|
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showPasswordDialog = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const settingPassword = ref(false)
|
||||||
|
const passwordProfileId = ref(null)
|
||||||
|
const newPassword = ref('')
|
||||||
|
|
||||||
|
const createForm = ref({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'ROLE_VIEWER',
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryRole = (profile) => {
|
||||||
|
const roles = profile.roles || []
|
||||||
|
if (roles.includes('ROLE_ADMIN')) { return 'ROLE_ADMIN' }
|
||||||
|
if (roles.includes('ROLE_GESTIONNAIRE')) { return 'ROLE_GESTIONNAIRE' }
|
||||||
|
return 'ROLE_VIEWER'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...createForm.value }
|
||||||
|
if (!data.email) { delete data.email }
|
||||||
|
if (!data.password) { delete data.password }
|
||||||
|
await createProfile(data)
|
||||||
|
showCreateDialog.value = false
|
||||||
|
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoleChange = async (profileId, role) => {
|
||||||
|
await updateRole(profileId, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPasswordDialog = (profileId) => {
|
||||||
|
passwordProfileId.value = profileId
|
||||||
|
newPassword.value = ''
|
||||||
|
showPasswordDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetPassword = async () => {
|
||||||
|
if (!passwordProfileId.value) { return }
|
||||||
|
settingPassword.value = true
|
||||||
|
try {
|
||||||
|
await setPassword(passwordProfileId.value, newPassword.value)
|
||||||
|
showPasswordDialog.value = false
|
||||||
|
} finally {
|
||||||
|
settingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivate = async (profileId) => {
|
||||||
|
await deactivateProfile(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -149,6 +149,7 @@
|
|||||||
Modifier
|
Modifier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="loadingComposants"
|
:disabled="loadingComposants"
|
||||||
@@ -185,6 +186,7 @@ import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
|||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -47,6 +48,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -128,6 +130,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="COMPONENT"
|
initial-category="COMPONENT"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de composant',
|
title: 'Nouvelle catégorie de composant',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="component?.constructeurs || []"
|
:initial-options="component?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -286,14 +286,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -419,6 +419,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments"
|
:disabled="uploadingDocuments"
|
||||||
@@ -566,6 +567,7 @@ interface ComponentCatalogType extends ModelType {
|
|||||||
customFields?: Array<Record<string, any>>
|
customFields?: Array<Record<string, any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
@@ -746,6 +748,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
component.value &&
|
component.value &&
|
||||||
editionForm.name &&
|
editionForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -253,14 +253,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -278,7 +278,7 @@
|
|||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -401,6 +401,7 @@ const {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -755,6 +756,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name &&
|
creationForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Gérez les fournisseurs et leurs coordonnées.
|
Gérez les fournisseurs et leurs coordonnées.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="openCreateModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
Nouveau fournisseur
|
Nouveau fournisseur
|
||||||
</button>
|
</button>
|
||||||
@@ -73,9 +73,9 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
||||||
Modifier
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,22 +90,22 @@
|
|||||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
|
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Nom</span></label>
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
<input v-model="form.name" type="text" class="input input-bordered" required>
|
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FieldEmail v-model="form.email" label="Email" />
|
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
||||||
<FieldPhone v-model="form.phone" label="Téléphone" />
|
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="closeModal">
|
<button type="button" class="btn" @click="closeModal">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
|
||||||
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
||||||
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
@@ -126,6 +126,7 @@ import { usePersistedValue } from '~/composables/usePersistedValue'
|
|||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
|
||||||
@@ -221,7 +222,7 @@ const confirmDelete = async (constructeur) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadConstructeurs()
|
onMounted(() => loadConstructeurs())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -104,10 +104,11 @@
|
|||||||
Commencez par ajouter des sites et des machines.
|
Commencez par ajouter des sites et des machines.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
<button class="btn btn-primary" @click="showAddSiteModal = true">
|
<button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@click="showAddMachineModal = true"
|
@click="showAddMachineModal = true"
|
||||||
>
|
>
|
||||||
@@ -239,12 +240,14 @@
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-3">
|
<div class="card-actions justify-end mt-3">
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-xs btn-outline"
|
class="btn btn-xs btn-outline"
|
||||||
@click.stop="editMachine(machine)"
|
@click.stop="editMachine(machine)"
|
||||||
>
|
>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-xs btn-error"
|
class="btn btn-xs btn-error"
|
||||||
@click.stop="confirmDeleteMachine(machine)"
|
@click.stop="confirmDeleteMachine(machine)"
|
||||||
>
|
>
|
||||||
@@ -277,6 +280,7 @@
|
|||||||
Aucune machine dans ce site
|
Aucune machine dans ce site
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
@click="addMachineToSite(site)"
|
@click="addMachineToSite(site)"
|
||||||
>
|
>
|
||||||
@@ -304,11 +308,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Usine de production"
|
placeholder="Ex: Usine de production"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="newSite" />
|
<SiteContactFormFields :form="newSite" :disabled="!canEdit" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button
|
<button
|
||||||
@@ -318,7 +323,7 @@
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||||
Créer le site
|
Créer le site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,6 +348,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Presse hydraulique #1"
|
placeholder="Ex: Presse hydraulique #1"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +360,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="newMachine.siteId"
|
v-model="newMachine.siteId"
|
||||||
class="select select-bordered"
|
class="select select-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -374,6 +381,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="newMachine.typeMachineId"
|
v-model="newMachine.typeMachineId"
|
||||||
class="select select-bordered"
|
class="select select-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: PRESS-001"
|
placeholder="Ex: PRESS-001"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,7 +455,7 @@
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||||
Créer la machine
|
Créer la machine
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,6 +483,7 @@ import IconLucideTag from '~icons/lucide/tag'
|
|||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { sites, loading, loadSites, createSite } = useSites()
|
const { sites, loading, loadSites, createSite } = useSites()
|
||||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||||
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-sm btn-error"
|
class="btn btn-sm btn-error"
|
||||||
@click.stop="confirmDeleteType(type)"
|
@click.stop="confirmDeleteType(type)"
|
||||||
>
|
>
|
||||||
@@ -108,6 +109,7 @@ import IconLucidePackage from "~icons/lucide/package";
|
|||||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||||
import IconLucideBox from "~icons/lucide/box";
|
import IconLucideBox from "~icons/lucide/box";
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
const { machineTypes, loadMachineTypes, deleteMachineType } =
|
const { machineTypes, loadMachineTypes, deleteMachineType } =
|
||||||
useMachineTypesApi();
|
useMachineTypesApi();
|
||||||
|
|
||||||
|
|||||||
@@ -19,15 +19,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TypeEditForm
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
|
||||||
:key="formKey"
|
<TypeEditForm
|
||||||
v-model="draftType"
|
:key="formKey"
|
||||||
:saving="creating"
|
v-model="draftType"
|
||||||
:resettable="false"
|
:saving="!canEdit || creating"
|
||||||
submit-label="Créer le type"
|
:resettable="false"
|
||||||
submit-loading-label="Création..."
|
submit-label="Créer le type"
|
||||||
@submit="handleSubmit"
|
submit-loading-label="Création..."
|
||||||
/>
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,6 +96,7 @@ import IconLucideBox from '~icons/lucide/box'
|
|||||||
|
|
||||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const formKey = ref(0)
|
const formKey = ref(0)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const machineId = route.params.id
|
const machineId = route.params.id
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
if (!machineId) {
|
if (!machineId) {
|
||||||
console.error('ID de machine manquant')
|
console.error('ID de machine manquant')
|
||||||
@@ -212,7 +213,7 @@ onMounted(() => {
|
|||||||
d.loadMachineData()
|
d.loadMachineData()
|
||||||
d.loadInitialData()
|
d.loadInitialData()
|
||||||
|
|
||||||
if (route.query.edit === 'true') {
|
if (route.query.edit === 'true' && canEdit.value) {
|
||||||
d.isEditMode.value = true
|
d.isEditMode.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
|
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
<button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
|
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
|
||||||
@@ -144,6 +144,7 @@ import IconLucideMapPin from '~icons/lucide/map-pin'
|
|||||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
import IconLucideTag from '~icons/lucide/tag'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Presse hydraulique #1"
|
placeholder="Ex: Presse hydraulique #1"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
<label class="label" for="machine-field-site">
|
<label class="label" for="machine-field-site">
|
||||||
<span class="label-text">Site</span>
|
<span class="label-text">Site</span>
|
||||||
</label>
|
</label>
|
||||||
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" required>
|
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" :disabled="!canEdit" required>
|
||||||
<option value="">
|
<option value="">
|
||||||
Sélectionner un site
|
Sélectionner un site
|
||||||
</option>
|
</option>
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
v-model="c.newMachine.typeMachineId"
|
v-model="c.newMachine.typeMachineId"
|
||||||
:options="c.machineTypes"
|
:options="c.machineTypes"
|
||||||
:loading="c.machineTypesLoading"
|
:loading="c.machineTypesLoading"
|
||||||
|
:disabled="!canEdit"
|
||||||
placeholder="Rechercher un type…"
|
placeholder="Rechercher un type…"
|
||||||
empty-text="Aucun type trouvé"
|
empty-text="Aucun type trouvé"
|
||||||
:option-label="c.machineTypeLabel"
|
:option-label="c.machineTypeLabel"
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: PRESS-001"
|
placeholder="Ex: PRESS-001"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +174,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="!c.canCreateMachine || c.submitting"
|
:disabled="!canEdit || !c.canCreateMachine || c.submitting"
|
||||||
:class="{ loading: c.submitting }"
|
:class="{ loading: c.submitting }"
|
||||||
>
|
>
|
||||||
Créer la machine
|
Créer la machine
|
||||||
@@ -194,4 +197,5 @@ import RequirementProductSelector from '~/components/machine/create/RequirementP
|
|||||||
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
|
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
|
||||||
|
|
||||||
const c = proxyRefs(useMachineCreatePage())
|
const c = proxyRefs(useMachineCreatePage())
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -47,6 +48,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -126,6 +128,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="PIECE"
|
initial-category="PIECE"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de pièce',
|
title: 'Nouvelle catégorie de pièce',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@
|
|||||||
Modifier
|
Modifier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="loadingPieces"
|
:disabled="loadingPieces"
|
||||||
@@ -207,6 +208,7 @@ import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
|||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="piece?.constructeurs || []"
|
:initial-options="piece?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<ProductSelect
|
<ProductSelect
|
||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
helper-text="Un produit valide est requis pour cette pièce."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -233,14 +233,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -366,6 +366,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments"
|
:disabled="uploadingDocuments"
|
||||||
@@ -511,6 +512,7 @@ interface PieceCatalogType extends ModelType {
|
|||||||
customFields?: Array<Record<string, any>>
|
customFields?: Array<Record<string, any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
@@ -731,6 +733,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
|
canEdit.value &&
|
||||||
piece.value &&
|
piece.value &&
|
||||||
editionForm.name &&
|
editionForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<ProductSelect
|
<ProductSelect
|
||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit est requis pour cette pièce."
|
helper-text="Un produit est requis pour cette pièce."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -205,14 +205,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -329,6 +329,7 @@ const { createPiece } = usePieces()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -478,6 +479,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name &&
|
creationForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
Modifier
|
Modifier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
@click="confirmDelete(row.product)"
|
@click="confirmDelete(row.product)"
|
||||||
@@ -179,6 +180,8 @@ import { useUrlState } from '~/composables/useUrlState'
|
|||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Catalogue des produits',
|
title: 'Catalogue des produits',
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -47,6 +48,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -126,6 +128,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="PRODUCT"
|
initial-category="PRODUCT"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de produit',
|
title: 'Nouvelle catégorie de produit',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="product?.constructeurs || []"
|
:initial-options="product?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -157,14 +157,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -286,6 +286,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments || saving"
|
:disabled="uploadingDocuments || saving"
|
||||||
@@ -424,6 +425,7 @@ import {
|
|||||||
historyDiffEntries as _historyDiffEntries,
|
historyDiffEntries as _historyDiffEntries,
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
} from '~/shared/utils/historyDisplayUtils'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -489,7 +491,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -144,14 +144,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -267,6 +267,7 @@ const { createProduct } = useProducts()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -346,6 +347,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name.trim().length >= 2 &&
|
creationForm.name.trim().length >= 2 &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -1,55 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
<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="w-full max-w-md">
|
||||||
<div class="card bg-base-100 shadow-2xl">
|
<div class="card bg-base-100 shadow-2xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h1 class="text-2xl font-bold mb-2">
|
<h1 class="text-2xl font-bold mb-6 text-center">
|
||||||
Choisir un profil
|
Connexion
|
||||||
</h1>
|
</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">
|
<div v-if="loadingProfiles" class="flex justify-center py-8">
|
||||||
<header class="flex items-center justify-between">
|
<span class="loading loading-spinner loading-lg" />
|
||||||
<h2 class="font-semibold">
|
</div>
|
||||||
Profils disponibles
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
:disabled="loadingProfiles"
|
|
||||||
@click="refreshProfiles"
|
|
||||||
>
|
|
||||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Rafraîchir</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="profiles.length" class="space-y-2 max-h-64 overflow-y-auto">
|
<form v-else @submit.prevent="handleLogin">
|
||||||
<button
|
<div class="form-control mb-4">
|
||||||
v-for="profile in profiles"
|
<label class="label">
|
||||||
:key="profile.id"
|
<span class="label-text">Profil</span>
|
||||||
type="button"
|
</label>
|
||||||
class="btn btn-outline btn-sm w-full justify-between"
|
<select
|
||||||
@click="selectProfile(profile.id)"
|
v-model="selectedProfileId"
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<span>{{ profile.firstName }} {{ profile.lastName }}</span>
|
<option value="" disabled>
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
Choisir un profil...
|
||||||
</button>
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="profile in profiles"
|
||||||
|
:key="profile.id"
|
||||||
|
:value="profile.id"
|
||||||
|
>
|
||||||
|
{{ profile.firstName }} {{ profile.lastName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-sm text-base-content/60">
|
|
||||||
Aucun profil enregistré.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer v-if="activeProfile" class="mt-6 flex justify-between items-center">
|
<div class="form-control mb-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Mot de passe</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="passwordInput"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
:class="{ 'input-error': loginError }"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loginError" class="text-error text-sm mb-4">
|
||||||
|
{{ loginError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full mt-4"
|
||||||
|
:disabled="!selectedProfileId || submitting"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
Se connecter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer v-if="activeProfile" class="mt-6 pt-4 border-t border-base-300 flex justify-between items-center">
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
Profil actuel :
|
Connecte :
|
||||||
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
|
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
|
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
|
||||||
Déconnexion
|
Deconnexion
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,32 +78,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useProfiles, useProfileSession } from '#imports'
|
import { useProfiles, useProfileSession } from '#imports'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
|
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
|
||||||
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
||||||
|
|
||||||
const refreshProfiles = async () => {
|
const selectedProfileId = ref('')
|
||||||
await fetchProfiles()
|
const password = ref('')
|
||||||
}
|
const loginError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const passwordInput = ref(null)
|
||||||
|
|
||||||
const selectProfile = async (profileId) => {
|
const handleLogin = async () => {
|
||||||
|
if (!selectedProfileId.value) { return }
|
||||||
|
submitting.value = true
|
||||||
|
loginError.value = ''
|
||||||
try {
|
try {
|
||||||
await activateProfile(profileId)
|
await activateProfile(selectedProfileId.value, password.value || undefined)
|
||||||
await fetchProfiles()
|
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la sélection du profil', error)
|
const err = error
|
||||||
|
if (err?.status === 401 || err?.statusCode === 401) {
|
||||||
|
loginError.value = 'Mot de passe incorrect.'
|
||||||
|
} else {
|
||||||
|
loginError.value = 'Erreur lors de la connexion.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
await router.push('/profiles')
|
selectedProfileId.value = ''
|
||||||
|
password.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
<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" :disabled="loadingProfiles" @click="refresh">
|
|
||||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Rafraîchir</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="profiles.length" class="space-y-2 max-h-80 overflow-y-auto">
|
|
||||||
<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 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 v-else>Créer et activer</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeProfile" class="mt-8 flex items-center justify-between bg-base-100 shadow-lg rounded-lg p-4">
|
|
||||||
<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 { confirm } = useConfirm()
|
|
||||||
|
|
||||||
const remove = async (profileId) => {
|
|
||||||
if (!await confirm({ message: '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>
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<h2 class="text-2xl font-bold">
|
<h2 class="text-2xl font-bold">
|
||||||
Sites
|
Sites
|
||||||
</h2>
|
</h2>
|
||||||
<button class="btn btn-primary" @click="openCreateSiteModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
|
||||||
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<p class="text-gray-500 mb-4">
|
<p class="text-gray-500 mb-4">
|
||||||
Commencez par ajouter votre premier site.
|
Commencez par ajouter votre premier site.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn btn-primary" @click="openCreateSiteModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
<SiteCreateModal
|
<SiteCreateModal
|
||||||
:visible="showAddSiteModal"
|
:visible="showAddSiteModal"
|
||||||
:site="newSite"
|
:site="newSite"
|
||||||
|
:disabled="!canEdit"
|
||||||
@close="closeCreateModal"
|
@close="closeCreateModal"
|
||||||
@submit="handleCreateSite"
|
@submit="handleCreateSite"
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
:can-preview-document="canPreviewDocument"
|
:can-preview-document="canPreviewDocument"
|
||||||
:document-icon="documentIcon"
|
:document-icon="documentIcon"
|
||||||
:format-size="formatSize"
|
:format-size="formatSize"
|
||||||
|
:disabled="!canEdit"
|
||||||
@close="closeEditModal"
|
@close="closeEditModal"
|
||||||
@submit="handleUpdateSite"
|
@submit="handleUpdateSite"
|
||||||
@remove-document="handleRemoveSiteDocument"
|
@remove-document="handleRemoveSiteDocument"
|
||||||
@@ -83,6 +85,8 @@ import SiteCreateModal from '~/components/sites/SiteCreateModal.vue'
|
|||||||
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
|
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
|
||||||
import { useSiteManagement } from '~/composables/useSiteManagement'
|
import { useSiteManagement } from '~/composables/useSiteManagement'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sites,
|
sites,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { getMachineTypeById } = useMachineTypesApi()
|
const { getMachineTypeById } = useMachineTypesApi()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
|
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
|
||||||
@@ -204,6 +205,10 @@ const saveChanges = async () => {
|
|||||||
|
|
||||||
// Charger le type au montage
|
// Charger le type au montage
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!canEdit.value) {
|
||||||
|
router.replace(`/type/${route.params.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const typeId = route.params.id
|
const typeId = route.params.id
|
||||||
const result = await getMachineTypeById(typeId, true)
|
const result = await getMachineTypeById(typeId, true)
|
||||||
|
|||||||
229
migration.md
Normal file
229
migration.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Plan de migration — Réduction de code frontend
|
||||||
|
|
||||||
|
> Objectif : réduire ~5 700 LOC sans modifier le fonctionnel.
|
||||||
|
> Branche : à partir de `refacto/F1-decoupage-mega-composants`
|
||||||
|
> Statut global : **EN ATTENTE**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Pages catalogue (3 pages, ~1 200 LOC → ~350 LOC)
|
||||||
|
|
||||||
|
### M1.1 · Composant générique `CatalogPage.vue`
|
||||||
|
|
||||||
|
- **Motif** : `component-catalog.vue` (348 LOC), `pieces-catalog.vue` (463 LOC) et `product-catalog.vue` (408 LOC) partagent 95 % de structure (recherche, tri, pagination, tableau, suppression, états vides/loading).
|
||||||
|
- **Différences isolées** : colonnes du tableau, garde de suppression, extraction fournisseur.
|
||||||
|
- **Plan** :
|
||||||
|
1. Créer `app/components/common/CatalogPage.vue` acceptant :
|
||||||
|
- `columns: ColumnDef[]` (nom, clé, slot optionnel)
|
||||||
|
- `fetchFn: (params) => Promise<PaginatedResult>`
|
||||||
|
- `deleteFn: (id) => Promise<Result>`
|
||||||
|
- `deleteGuard?: (item) => string | null` (message bloquant ou null)
|
||||||
|
- `entityLabel: string`, `createRoute: string`
|
||||||
|
- Slots nommés pour colonnes custom (`#col-supplier`, etc.)
|
||||||
|
2. Extraire `supplierDisplayUtils.ts` (pattern `MAX_VISIBLE_SUPPLIERS` dupliqué dans pieces-catalog et product-catalog).
|
||||||
|
3. Réduire chaque page catalogue à ~80 LOC (config + slots custom).
|
||||||
|
- **Gain estimé** : ~850 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Composables CRUD génériques (~1 170 LOC → ~400 LOC)
|
||||||
|
|
||||||
|
### M2.1 · Factory `useEntityCRUD<T>(config)`
|
||||||
|
|
||||||
|
- **Motif** : `usePieces.ts` (240), `useProducts.ts` (305), `useComposants.ts` (231), `useSites.ts` (124) suivent le même pattern CRUD : refs `loading/loaded/error`, `loadItems()` paginé, `create/update/delete` avec mise à jour cache + toast.
|
||||||
|
- **Différences isolées** : endpoint, normaliseur, enrichissement constructeurs, champs de tri.
|
||||||
|
- **Plan** :
|
||||||
|
1. Créer `app/composables/useEntityCRUD.ts` :
|
||||||
|
```ts
|
||||||
|
interface EntityCRUDConfig {
|
||||||
|
endpoint: string
|
||||||
|
label: string
|
||||||
|
normalizer?: (item: any) => any
|
||||||
|
enricher?: (item: any) => Promise<any>
|
||||||
|
defaultSort?: { field: string; dir: 'asc' | 'desc' }
|
||||||
|
}
|
||||||
|
export function useEntityCRUD(config: EntityCRUDConfig)
|
||||||
|
```
|
||||||
|
2. Extraire `extractTotal()` dans `apiHelpers.ts` (dupliqué 3×, ~10 LOC chacun).
|
||||||
|
3. Extraire `buildPaginatedQuery(options)` dans `apiHelpers.ts` (dupliqué 3×, ~15 LOC chacun).
|
||||||
|
4. Extraire pattern `withResolvedConstructeurs()` dans `useEntityEnricher.ts` (dupliqué 3× dans pieces/products/composants, ~50 LOC chacun).
|
||||||
|
5. Réduire chaque composable à un appel de factory + méthodes spécifiques.
|
||||||
|
6. Garder `useMachines.ts` séparé (méthodes spéciales : `reconfigureSkeleton`, `createMachineFromType`).
|
||||||
|
- **Gain estimé** : ~770 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M2.2 · Helper `withLoadingState()`
|
||||||
|
|
||||||
|
- **Motif** : pattern `loading.value = true; try { ... } finally { loading.value = false }` répété 10+ fois dans les composables CRUD.
|
||||||
|
- **Plan** : créer `app/composables/useLoadingHelper.ts` exportant :
|
||||||
|
```ts
|
||||||
|
async function withLoadingState<T>(loading: Ref<boolean>, fn: () => Promise<T>): Promise<T>
|
||||||
|
```
|
||||||
|
- **Gain estimé** : ~100 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M2.3 · Fusion `usePersistedValue` + `usePersistedSort`
|
||||||
|
|
||||||
|
- **Motif** : même pattern `useCookie()` + `watch()` + JSON parse/stringify.
|
||||||
|
- **Plan** : fusionner en `usePersistedState<T>(key, fallback, prefix?)`.
|
||||||
|
- **Gain estimé** : ~30 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Pages edit entités (~2 750 LOC → ~1 200 LOC)
|
||||||
|
|
||||||
|
### M3.1 · Composant `HistorySection.vue`
|
||||||
|
|
||||||
|
- **Motif** : bloc historique identique (loading/error/empty + itération entries) dans `component/[id]/edit.vue` (L437-503), `pieces/[id]/edit.vue` (L384-450), `product/[id]/edit.vue` (L304-370) — ~67 LOC × 3.
|
||||||
|
- **Plan** : créer `app/components/common/HistorySection.vue` avec props `entries`, `loading`, `error`.
|
||||||
|
- **Gain estimé** : ~130 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M3.2 · Composant `DocumentsSection.vue`
|
||||||
|
|
||||||
|
- **Motif** : bloc document (upload, liste, preview, download, delete) dupliqué dans les 3 pages edit + `MachineDocumentsCard.vue` + `SiteEditModal.vue` — ~70-180 LOC × 5.
|
||||||
|
- **Plan** : créer `app/components/common/DocumentsSection.vue` avec props `documents`, `entityId`, `entityType` et events `upload`, `delete`, `preview`.
|
||||||
|
- **Gain estimé** : ~400 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M3.3 · Composable `useEntityEditForm(config)`
|
||||||
|
|
||||||
|
- **Motif** : les 3 pages edit partagent : chargement entité + types + constructeurs, gestion champs custom, normalisation payload, sauvegarde, gestion erreur.
|
||||||
|
- **Différences** : component a structure display, piece a product selection, product est plus simple.
|
||||||
|
- **Plan** :
|
||||||
|
1. Créer `app/composables/useEntityEditForm.ts` gérant le cycle de vie commun (load, save, custom fields sync).
|
||||||
|
2. Chaque page edit ne garde que ses spécificités.
|
||||||
|
- **Gain estimé** : ~500 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M3.4 · Réutilisation `customFieldFormUtils.ts` dans `component/create.vue`
|
||||||
|
|
||||||
|
- **Motif** : `component/create.vue` (1 266 LOC) réimplémente `resolveFieldName`, `resolveFieldType`, `resolveDefaultValue` déjà dans `customFieldFormUtils.ts`. Aussi 3 fonctions `resolveXxxLabel` quasi-identiques (~18 LOC × 3).
|
||||||
|
- **Plan** :
|
||||||
|
1. Remplacer les fonctions locales par les imports de `customFieldFormUtils.ts`.
|
||||||
|
2. Créer `resolveTypeLabel(entity, typeField, labelField, fallback)` générique.
|
||||||
|
- **Gain estimé** : ~120 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Décomposition `useMachineDetailData.ts` (1 410 LOC → ~500 LOC)
|
||||||
|
|
||||||
|
### M4.1 · Extraire `useMachineDocuments.ts`
|
||||||
|
|
||||||
|
- **Motif** : gestion documents (upload, delete, preview, refresh) = ~200 LOC dans le composable monolithique.
|
||||||
|
- **Gain estimé** : ~150 LOC (après factorisation avec DocumentsSection)
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M4.2 · Extraire `useMachineConstructeurs.ts`
|
||||||
|
|
||||||
|
- **Motif** : résolution constructeurs avec chaînes de fallback 4 niveaux, `uniqueConstructeurIds`, `resolveConstructeurs` = ~80 LOC.
|
||||||
|
- **Gain estimé** : ~60 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M4.3 · Fusionner `transformCustomFields` et `transformComponentCustomFields`
|
||||||
|
|
||||||
|
- **Motif** : L303-405 et L407-514 — logique quasi-identique de transformation des champs custom, seule la source (machine vs composant) diffère.
|
||||||
|
- **Plan** : créer `transformEntityCustomFields(entity, fieldSource, config)` paramétrable.
|
||||||
|
- **Gain estimé** : ~100 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M4.4 · Extraire groupement de requirements
|
||||||
|
|
||||||
|
- **Motif** : `componentRequirementGroups`, `pieceRequirementGroups` = computed complexes avec construction de maps et filtres répétitifs.
|
||||||
|
- **Gain estimé** : ~80 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — `StructureNodeEditor.vue` (1 167 LOC → ~600 LOC)
|
||||||
|
|
||||||
|
### M5.1 · Composable `useDragDrop.ts`
|
||||||
|
|
||||||
|
- **Motif** : 4 handlers drag-drop quasi-identiques (custom fields, pièces, produits, sous-composants) avec chacun `draggingIndex`, `dropTargetIndex`, `reorderClass()`, `handleDragStart/Over/End`.
|
||||||
|
- **Plan** : créer `useDragDrop<T>(items: Ref<T[]>)` retournant `{ dragging, target, reorderClass, onDragStart, onDragOver, onDragEnd, onDrop }`.
|
||||||
|
- **Gain estimé** : ~350 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M5.2 · Extraire validation noeud
|
||||||
|
|
||||||
|
- **Motif** : `isAssignmentNodeComplete` + logique de validation dispersée.
|
||||||
|
- **Plan** : déplacer vers `app/shared/utils/structureValidation.ts`.
|
||||||
|
- **Gain estimé** : ~40 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Micro-duplications restantes (du `micro-dup-report.md`)
|
||||||
|
|
||||||
|
### M6.1 · `useControlledModel.ts` (MDUP-004)
|
||||||
|
|
||||||
|
- **Motif** : `computed({ get, set })` pour transiter `v-model` entre props et emits — dupliqué dans 6 composants.
|
||||||
|
- **Gain estimé** : ~60 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M6.2 · `ModalShell.vue` (MDUP-008) + `ModalActions.vue` (MDUP-007)
|
||||||
|
|
||||||
|
- **Motif** : squelette de modale DaisyUI (`.modal` + `.modal-box` + titre + footer) dupliqué dans 4+ composants. Pieds de modale « Annuler + Primaire + spinner » dupliqués 5×.
|
||||||
|
- **Gain estimé** : ~120 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M6.3 · `LoadingButton.vue` (MDUP-010) + `FieldText.vue` (MDUP-009)
|
||||||
|
|
||||||
|
- **Motif** : bouton primaire avec spinner (3 occurrences), champ texte simple label+input (5 occurrences).
|
||||||
|
- **Gain estimé** : ~80 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M6.4 · `createRequirementDefaults` + `useEnsureOptionsLoaded` (MDUP-005, MDUP-006)
|
||||||
|
|
||||||
|
- **Motif** : factory de requirement par défaut + `onMounted` identiques dans les sections composant/pièce.
|
||||||
|
- **Gain estimé** : ~30 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Consolidation custom fields (~1 150 LOC → ~800 LOC)
|
||||||
|
|
||||||
|
### M7.1 · Fusionner logique de résolution dans `customFieldUtils.ts`
|
||||||
|
|
||||||
|
- **Motif** : `customFieldUtils.ts` (440), `entityCustomFieldLogic.ts` (349), `customFieldFormUtils.ts` (367) contiennent des fonctions de résolution de champs qui se chevauchent (`resolveFieldId`, `resolveFieldName`, génération de clé, déduplication).
|
||||||
|
- **Plan** : consolider les fonctions dupliquées en gardant la séparation thématique (utils / form / entity) mais en partageant les primitives.
|
||||||
|
- **Gain estimé** : ~150 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Récapitulatif
|
||||||
|
|
||||||
|
| Phase | Cible | LOC avant | Gain estimé | Priorité |
|
||||||
|
|-------|-------|-----------|-------------|----------|
|
||||||
|
| **P1** | Pages catalogue | ~1 220 | ~850 | Haute |
|
||||||
|
| **P2** | Composables CRUD | ~1 170 | ~900 | Haute |
|
||||||
|
| **P3** | Pages edit entités | ~2 750 | ~1 150 | Haute |
|
||||||
|
| **P4** | useMachineDetailData | ~1 410 | ~390 | Moyenne |
|
||||||
|
| **P5** | StructureNodeEditor | ~1 167 | ~390 | Moyenne |
|
||||||
|
| **P6** | Micro-duplications | ~400 | ~290 | Basse |
|
||||||
|
| **P7** | Custom fields utils | ~1 150 | ~150 | Basse |
|
||||||
|
| | **Total** | | **~4 120 LOC** | |
|
||||||
|
|
||||||
|
### Ordre recommandé
|
||||||
|
|
||||||
|
1. **P2** (CRUD generics) — fondation pour P1 et P3
|
||||||
|
2. **P1** (catalogues) — dépend de P2 pour les fetch functions
|
||||||
|
3. **P3** (pages edit) — plus gros gain absolu, dépend partiellement de P2
|
||||||
|
4. **P5** (drag-drop) — indépendant, quick win
|
||||||
|
5. **P4** (machine detail) — complexe mais fort impact
|
||||||
|
6. **P6** (micro-dup) — petits gains, faible risque
|
||||||
|
7. **P7** (custom fields) — délicat, à faire en dernier
|
||||||
|
|
||||||
|
### Vérification après chaque phase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Inventory_frontend
|
||||||
|
npx nuxi typecheck # 0 erreurs
|
||||||
|
npm run lint:fix # 0 erreurs
|
||||||
|
npm run build # succès
|
||||||
|
npx vitest run # 54+ tests pass
|
||||||
|
```
|
||||||
@@ -41,7 +41,7 @@ export default defineNuxtConfig({
|
|||||||
|| process.env.NUXT_PUBLIC_API_BASE_URL
|
|| process.env.NUXT_PUBLIC_API_BASE_URL
|
||||||
|| 'http://localhost/api',
|
|| 'http://localhost/api',
|
||||||
public: {
|
public: {
|
||||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api',
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '/api',
|
||||||
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||||
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
@@ -54,7 +54,15 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()]
|
plugins: [tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
css: ['~/assets/app.css'],
|
css: ['~/assets/app.css'],
|
||||||
router: {
|
router: {
|
||||||
|
|||||||
Reference in New Issue
Block a user