refactor : merge Inventory_frontend submodule into frontend/ directory
Merges the full git history of Inventory_frontend into the monorepo under frontend/. Removes the submodule in favor of a unified repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
frontend/app/composables/useActivityLog.ts
Normal file
70
frontend/app/composables/useActivityLog.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type ActivityLogActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ActivityLogEntry = {
|
||||
id: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName: string | null
|
||||
entityRef: string | null
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ActivityLogActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface LoadActivityLogOptions {
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
entityType?: string
|
||||
action?: string
|
||||
}
|
||||
|
||||
export function useActivityLog() {
|
||||
const { get } = useApi()
|
||||
|
||||
const entries = ref<ActivityLogEntry[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadActivityLog = async (options: LoadActivityLogOptions = {}) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(options.page ?? 1))
|
||||
params.set('itemsPerPage', String(options.itemsPerPage ?? 30))
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
if (options.action) params.set('action', options.action)
|
||||
|
||||
const result = await get(`/activity-logs?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger le journal d\'activité.'
|
||||
entries.value = []
|
||||
return result
|
||||
}
|
||||
|
||||
const data = result.data as any
|
||||
entries.value = Array.isArray(data?.items) ? data.items : []
|
||||
total.value = typeof data?.total === 'number' ? data.total : entries.value.length
|
||||
|
||||
return { success: true, data: entries.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
entries.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, total, loading, error, loadActivityLog }
|
||||
}
|
||||
80
frontend/app/composables/useAdminProfiles.ts
Normal file
80
frontend/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,
|
||||
}
|
||||
}
|
||||
141
frontend/app/composables/useApi.ts
Normal file
141
frontend/app/composables/useApi.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useToast } from './useToast'
|
||||
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
interface ApiCallOptions extends RequestInit {
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
const { showError } = useToast()
|
||||
const { public: publicConfig } = useRuntimeConfig()
|
||||
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000'
|
||||
const parsedApiTimeout = Number(publicConfig.apiTimeout ?? 30000)
|
||||
const API_TIMEOUT = Number.isNaN(parsedApiTimeout) ? 30000 : parsedApiTimeout
|
||||
|
||||
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
||||
const url = `${API_BASE_URL}${endpoint}`
|
||||
const isFormData = options.body instanceof FormData
|
||||
const defaultOptions: ApiCallOptions = {
|
||||
credentials: 'include',
|
||||
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
|
||||
}
|
||||
|
||||
// Ajouter un timeout à la requête
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...options.headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (response.ok) {
|
||||
let data: T | null = null
|
||||
if (response.status !== 204) {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json') || contentType.includes('application/ld+json') || contentType.includes('+json')) {
|
||||
const text = await response.text()
|
||||
data = text ? JSON.parse(text) : null
|
||||
} else {
|
||||
const text = await response.text()
|
||||
data = (text || null) as T | null
|
||||
}
|
||||
}
|
||||
return { success: true, data: data as T }
|
||||
} else {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let errorData: Record<string, unknown> = {}
|
||||
if (contentType.includes('json')) {
|
||||
errorData = await response.json().catch(() => ({}))
|
||||
} else {
|
||||
const text = await response.text().catch(() => '')
|
||||
errorData = text ? { message: text } : {}
|
||||
}
|
||||
const rawMessage = response.status === 403
|
||||
? 'Permissions insuffisantes pour cette action.'
|
||||
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
|
||||
const errorMessage = humanizeError(rawMessage)
|
||||
showError(errorMessage)
|
||||
return { success: false, error: errorMessage, status: response.status }
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
const errorMessage = err.name === 'AbortError'
|
||||
? 'La requête a pris trop de temps. Veuillez réessayer.'
|
||||
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
|
||||
showError(errorMessage)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
const get = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, { method: 'GET' })
|
||||
}
|
||||
|
||||
const post = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const patch = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const put = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/ld+json',
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return {
|
||||
apiCall,
|
||||
get,
|
||||
post,
|
||||
postFormData,
|
||||
patch,
|
||||
put,
|
||||
delete: del,
|
||||
}
|
||||
}
|
||||
207
frontend/app/composables/useComments.ts
Normal file
207
frontend/app/composables/useComments.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface CommentDocument {
|
||||
id: string
|
||||
name: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
type: string
|
||||
fileUrl: string
|
||||
downloadUrl: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
content: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName?: string | null
|
||||
authorId: string
|
||||
authorName: string
|
||||
status: 'open' | 'resolved'
|
||||
resolvedById?: string | null
|
||||
resolvedByName?: string | null
|
||||
resolvedAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
documents?: CommentDocument[]
|
||||
}
|
||||
|
||||
interface CommentResult {
|
||||
success: boolean
|
||||
data?: Comment | Comment[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface CommentListResult {
|
||||
success: boolean
|
||||
data?: Comment[]
|
||||
total?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useComments() {
|
||||
const { get, post, patch, postFormData, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchComments = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
status: string = 'open',
|
||||
): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get<Comment[]>(`/comments/by-entity/${entityType}/${entityId}?status=${status}`)
|
||||
if (result.success) {
|
||||
const items = (result.data ?? []) as Comment[]
|
||||
return { success: true, data: items }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllComments = async (options: {
|
||||
status?: string
|
||||
entityType?: string
|
||||
entityName?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: string
|
||||
} = {}): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (options.status) params.set('status', options.status)
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
if (options.entityName) params.set('entityName', options.entityName)
|
||||
params.set('sort', options.orderBy || 'createdAt')
|
||||
params.set('direction', options.orderDir || 'desc')
|
||||
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||
params.set('page', String(options.page || 1))
|
||||
|
||||
const result = await get<{ items: Comment[]; total: number }>(`/comments/search/list?${params.toString()}`)
|
||||
if (result.success && result.data) {
|
||||
const data = result.data as { items: Comment[]; total: number }
|
||||
return { success: true, data: data.items, total: data.total }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComment = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
content: string,
|
||||
entityName?: string,
|
||||
files?: File[],
|
||||
): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
let result
|
||||
if (files && files.length > 0) {
|
||||
const formData = new FormData()
|
||||
formData.append('content', content)
|
||||
formData.append('entityType', entityType)
|
||||
formData.append('entityId', entityId)
|
||||
if (entityName) formData.append('entityName', entityName)
|
||||
for (const file of files) {
|
||||
formData.append('files[]', file)
|
||||
}
|
||||
result = await postFormData('/comments', formData)
|
||||
} else {
|
||||
const payload: Record<string, string> = { entityType, entityId, content }
|
||||
if (entityName) payload.entityName = entityName
|
||||
result = await post('/comments', payload)
|
||||
}
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire ajouté')
|
||||
return { success: true, data: result.data as Comment }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible d\'ajouter le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveComment = async (commentId: string): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/comments/${commentId}/resolve`)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire résolu')
|
||||
return { success: true, data: result.data as Comment }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de résoudre le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComment = async (commentId: string): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/comments/${commentId}`)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire supprimé')
|
||||
return { success: true }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de supprimer le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUnresolvedCount = async (): Promise<number> => {
|
||||
try {
|
||||
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
|
||||
if (result.success && result.data) {
|
||||
return result.data.count
|
||||
}
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
fetchComments,
|
||||
fetchAllComments,
|
||||
createComment,
|
||||
resolveComment,
|
||||
deleteComment,
|
||||
fetchUnresolvedCount,
|
||||
}
|
||||
}
|
||||
426
frontend/app/composables/useComponentCreate.ts
Normal file
426
frontend/app/composables/useComponentCreate.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Component creation page – orchestration composable.
|
||||
*
|
||||
* Pure structure-assignment helpers live in
|
||||
* `~/shared/utils/structureAssignmentHelpers.ts`.
|
||||
*/
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from '#imports'
|
||||
import type { StructureAssignmentNode } from '~/components/ComponentStructureAssignmentNode.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import {
|
||||
getStructurePieces,
|
||||
resolvePieceLabel as _resolvePieceLabel,
|
||||
resolveProductLabel as _resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
fetchModelTypeNames,
|
||||
buildTypeLabelMap,
|
||||
} from '~/shared/utils/structureDisplayUtils'
|
||||
import {
|
||||
hasAssignments,
|
||||
initializeStructureAssignments,
|
||||
isAssignmentNodeComplete,
|
||||
serializeStructureAssignments,
|
||||
} from '~/shared/utils/structureAssignmentHelpers'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
structure: ComponentModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main composable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useComponentCreate() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const {
|
||||
createComposant,
|
||||
composants: componentCatalogRef,
|
||||
loading: componentsLoading,
|
||||
} = useComposants()
|
||||
const {
|
||||
pieces: pieceCatalogRef,
|
||||
loading: piecesLoading,
|
||||
} = usePieces()
|
||||
const {
|
||||
products: productCatalogRef,
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Local state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const selectedTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const submitting = ref(false)
|
||||
const creationForm = reactive({
|
||||
name: '' as string,
|
||||
description: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
const lastSuggestedName = ref('')
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||||
const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Computed
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
||||
const availableProducts = computed(() => productCatalogRef.value ?? [])
|
||||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||||
const structureDataLoading = computed(
|
||||
() => !submitting.value && (piecesLoading.value || componentsLoading.value || productsLoading.value),
|
||||
)
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
)
|
||||
const productTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(productTypes.value),
|
||||
)
|
||||
const componentTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(componentTypes.value),
|
||||
)
|
||||
|
||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||
(componentTypes.value || [])
|
||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||
)
|
||||
|
||||
const typeOptionLabel = (type?: ComponentCatalogType) =>
|
||||
type?.name || 'Catégorie'
|
||||
|
||||
const typeOptionDescription = (type?: ComponentCatalogType) =>
|
||||
type?.description ? String(type.description) : ''
|
||||
|
||||
const selectedType = computed(() => {
|
||||
if (!selectedTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
|
||||
const structure = selectedType.value?.structure ?? null
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const structureHasRequirements = computed(() =>
|
||||
hasAssignments(structureAssignments.value),
|
||||
)
|
||||
|
||||
const structureSelectionsComplete = computed(() => {
|
||||
if (!structureHasRequirements.value) {
|
||||
return true
|
||||
}
|
||||
if (structureDataLoading.value) {
|
||||
return false
|
||||
}
|
||||
if (!structureAssignments.value) {
|
||||
return false
|
||||
}
|
||||
return isAssignmentNodeComplete(structureAssignments.value, true)
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
&& selectedType.value
|
||||
&& creationForm.name
|
||||
&& requiredCustomFieldsFilled.value
|
||||
&& structureSelectionsComplete.value
|
||||
&& !submitting.value,
|
||||
))
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) =>
|
||||
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Watchers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
watch(
|
||||
() => route.query.typeId,
|
||||
(value) => {
|
||||
if (typeof value === 'string') {
|
||||
selectedTypeId.value = value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedTypeId, (id) => {
|
||||
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
|
||||
if ((id || '') === current) {
|
||||
return
|
||||
}
|
||||
const nextQuery = { ...route.query }
|
||||
if (id) {
|
||||
nextQuery.typeId = id
|
||||
}
|
||||
else {
|
||||
delete nextQuery.typeId
|
||||
}
|
||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||
})
|
||||
|
||||
const clearCreationForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.description = ''
|
||||
creationForm.reference = ''
|
||||
creationForm.constructeurIds = []
|
||||
creationForm.prix = ''
|
||||
lastSuggestedName.value = ''
|
||||
structureAssignments.value = null
|
||||
}
|
||||
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
customFieldInputs.value = []
|
||||
structureAssignments.value = null
|
||||
return
|
||||
}
|
||||
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
||||
creationForm.name = type.name
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
|
||||
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const ids = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
fetchModelTypeNames(Array.from(new Set(ids)), pieceTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Submission
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const submitCreation = async () => {
|
||||
if (!selectedType.value) {
|
||||
toast.showError('Sélectionnez une catégorie de composant.')
|
||||
return
|
||||
}
|
||||
const payload: Record<string, any> = {
|
||||
name: creationForm.name.trim(),
|
||||
typeComposantId: selectedType.value.id,
|
||||
}
|
||||
|
||||
const description = creationForm.description.trim()
|
||||
if (description) {
|
||||
payload.description = description
|
||||
}
|
||||
|
||||
const reference = creationForm.reference.trim()
|
||||
if (reference) {
|
||||
payload.reference = reference
|
||||
}
|
||||
|
||||
// constructeurIds are handled via link entities, not in the main payload
|
||||
|
||||
const rawPrice = typeof creationForm.prix === 'string'
|
||||
? creationForm.prix.trim()
|
||||
: creationForm.prix === null || creationForm.prix === undefined
|
||||
? ''
|
||||
: String(creationForm.prix).trim()
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
const rootProductSelection
|
||||
= structureAssignments.value?.products?.find(
|
||||
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
|
||||
) ?? null
|
||||
|
||||
if (rootProductSelection?.selectedProductId) {
|
||||
payload.productId = rootProductSelection.selectedProductId.trim()
|
||||
}
|
||||
|
||||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
||||
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
||||
return
|
||||
}
|
||||
|
||||
const serializedStructure = structureHasRequirements.value
|
||||
? serializeStructureAssignments(structureAssignments.value)
|
||||
: null
|
||||
|
||||
if (serializedStructure) {
|
||||
payload.structure = serializedStructure
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await createComposant(payload)
|
||||
if (result.success) {
|
||||
const createdComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
createdComponent.id,
|
||||
[createdComponent?.typeComposant?.structure?.customFields],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
if (selectedDocuments.value.length && result.data?.id) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
{
|
||||
files: selectedDocuments.value,
|
||||
context: { composantId: result.data.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (!uploadResult.success) {
|
||||
const message = uploadResult.error
|
||||
? `Documents non ajoutés : ${uploadResult.error}`
|
||||
: 'Documents non ajoutés : une erreur est survenue.'
|
||||
toast.showError(message)
|
||||
}
|
||||
selectedDocuments.value = []
|
||||
}
|
||||
// Sync constructeur links after creation
|
||||
if (constructeurLinks.value.length) {
|
||||
await syncLinks('composant', createdComponent.id, [], constructeurLinks.value)
|
||||
}
|
||||
toast.showSuccess('Composant créé avec succès')
|
||||
await router.replace(`/component/${createdComponent.id}?edit=true`)
|
||||
}
|
||||
else if (result.error) {
|
||||
toast.showError(result.error)
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTypeId,
|
||||
submitting,
|
||||
creationForm,
|
||||
constructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
structureAssignments,
|
||||
selectedDocuments,
|
||||
uploadingDocuments,
|
||||
|
||||
// Computed
|
||||
loadingTypes,
|
||||
componentTypeList,
|
||||
selectedType,
|
||||
selectedTypeStructure,
|
||||
availablePieces,
|
||||
availableProducts,
|
||||
availableComponents,
|
||||
piecesLoading,
|
||||
productsLoading,
|
||||
componentsLoading,
|
||||
structureDataLoading,
|
||||
pieceTypeLabelMap,
|
||||
productTypeLabelMap,
|
||||
componentTypeLabelMap,
|
||||
structureHasRequirements,
|
||||
structureSelectionsComplete,
|
||||
canEdit,
|
||||
canSubmit,
|
||||
|
||||
// Functions
|
||||
typeOptionLabel,
|
||||
typeOptionDescription,
|
||||
formatStructurePreview,
|
||||
resolvePieceLabel,
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
submitCreation,
|
||||
}
|
||||
}
|
||||
597
frontend/app/composables/useComponentEdit.ts
Normal file
597
frontend/app/composables/useComponentEdit.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from '#imports'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import {
|
||||
getStructurePieces,
|
||||
getStructureProducts,
|
||||
resolvePieceLabel as _resolvePieceLabel,
|
||||
resolveProductLabel as _resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
fetchModelTypeNames,
|
||||
buildTypeLabelMap,
|
||||
} from '~/shared/utils/structureDisplayUtils'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
structure: ComponentModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
structure: 'Structure',
|
||||
typeComposant: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
export function useComponentEdit(componentId: string) {
|
||||
const { canEdit } = usePermissions()
|
||||
const router = useRouter()
|
||||
const { get, patch } = useApi()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const { updateComposant, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces } = usePieces()
|
||||
const { products } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useComponentHistory()
|
||||
|
||||
const component = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const componentDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
description: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
)
|
||||
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
||||
const productTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(productTypes.value, fetchedProductTypeMap.value),
|
||||
)
|
||||
const pieceCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(pieces.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const productCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(products.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const componentCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(componentCatalogRef.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
return
|
||||
}
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||
if (!documentId) {
|
||||
return
|
||||
}
|
||||
const result = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success) {
|
||||
componentDocuments.value = componentDocuments.value.filter((doc) => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
if (!component.value?.id) {
|
||||
componentDocuments.value = []
|
||||
return
|
||||
}
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files: File[]) => {
|
||||
if (!files?.length || !component.value?.id) {
|
||||
return
|
||||
}
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const result = await uploadDocuments(
|
||||
{
|
||||
files,
|
||||
context: { composantId: component.value.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (result.success) {
|
||||
selectedFiles.value = []
|
||||
await refreshDocuments()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||
(componentTypes.value || [])
|
||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||
)
|
||||
|
||||
const selectedType = computed(() => {
|
||||
if (!selectedTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
|
||||
const structure = selectedType.value?.structure ?? null
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ComponentModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? selectedTypeStructure.value ?? null
|
||||
const values = valuesOverride ?? component.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
&& component.value
|
||||
&& editionForm.name
|
||||
&& requiredCustomFieldsFilled.value
|
||||
&& !saving.value,
|
||||
))
|
||||
|
||||
const fetchComponent = async () => {
|
||||
if (!componentId || typeof componentId !== 'string') {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
return
|
||||
}
|
||||
const result = await get(`/composants/${componentId}`)
|
||||
if (result.success) {
|
||||
component.value = result.data
|
||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
}
|
||||
else {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) =>
|
||||
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||
|
||||
const structureSelections = computed(() => {
|
||||
const selections = collectStructureSelections(
|
||||
component.value?.structure,
|
||||
{
|
||||
pieceCatalogMap: pieceCatalogMap.value,
|
||||
productCatalogMap: productCatalogMap.value,
|
||||
componentCatalogMap: componentCatalogMap.value,
|
||||
},
|
||||
{ resolvePieceLabel, resolveProductLabel, resolveSubcomponentLabel },
|
||||
)
|
||||
const total
|
||||
= selections.pieces.length + selections.products.length + selections.components.length
|
||||
return {
|
||||
...selections,
|
||||
total,
|
||||
hasAny: total > 0,
|
||||
}
|
||||
})
|
||||
|
||||
// --- Slot local edits (saved on submit, not auto-saved) ---
|
||||
|
||||
const slotEdits = reactive<{
|
||||
pieces: Record<string, { selectedPieceId?: string | null, quantity?: number }>
|
||||
products: Record<string, { selectedProductId?: string | null }>
|
||||
subcomponents: Record<string, { selectedComposantId?: string | null }>
|
||||
}>({ pieces: {}, products: {}, subcomponents: {} })
|
||||
|
||||
const pieceSlotEntries = computed(() => {
|
||||
const structure = component.value?.structure
|
||||
if (!structure?.pieces) return []
|
||||
return (structure.pieces as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.pieces[slot.slotId]
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typePieceId: slot.typePieceId,
|
||||
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
|
||||
selectedPieceName: slot.selectedPieceName ?? null,
|
||||
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
|
||||
position: slot.position ?? i,
|
||||
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const productSlotEntries = computed(() => {
|
||||
const structure = component.value?.structure
|
||||
if (!structure?.products) return []
|
||||
return (structure.products as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.products[slot.slotId]
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeProductId: slot.typeProductId,
|
||||
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
|
||||
selectedProductName: slot.selectedProductName ?? null,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const subcomponentSlotEntries = computed(() => {
|
||||
const structure = component.value?.structure
|
||||
if (!structure?.subcomponents) return []
|
||||
return (structure.subcomponents as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.subcomponents[slot.slotId]
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeComposantId: slot.typeComposantId,
|
||||
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
|
||||
selectedComponentName: slot.selectedComponentName ?? null,
|
||||
alias: slot.alias,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: slot.alias || `Sous-composant #${i + 1}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const setPieceSlotSelection = (slotId: string, selectedPieceId: string | null) => {
|
||||
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], selectedPieceId }
|
||||
}
|
||||
|
||||
const setProductSlotSelection = (slotId: string, selectedProductId: string | null) => {
|
||||
slotEdits.products[slotId] = { ...slotEdits.products[slotId], selectedProductId }
|
||||
}
|
||||
|
||||
const setSubcomponentSlotSelection = (slotId: string, selectedComposantId: string | null) => {
|
||||
slotEdits.subcomponents[slotId] = { ...slotEdits.subcomponents[slotId], selectedComposantId }
|
||||
}
|
||||
|
||||
const setSlotQuantity = (slotId: string, quantity: number) => {
|
||||
if (!slotId || quantity < 1) return
|
||||
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], quantity: Math.max(1, quantity) }
|
||||
}
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!component.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.prix === 'string'
|
||||
? editionForm.prix.trim()
|
||||
: editionForm.prix === null || editionForm.prix === undefined
|
||||
? ''
|
||||
: String(editionForm.prix).trim()
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
description: editionForm.description.trim() || null,
|
||||
}
|
||||
|
||||
const reference = editionForm.reference.trim()
|
||||
payload.reference = reference || null
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
else {
|
||||
payload.prix = null
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updateComposant(component.value.id, payload)
|
||||
if (result.success && result.data) {
|
||||
const updatedComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
updatedComponent.id,
|
||||
[
|
||||
updatedComponent?.typeComposant?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
|
||||
// Save slot edits
|
||||
const slotPromises: Promise<any>[] = []
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
||||
if (Object.keys(edits).length) {
|
||||
slotPromises.push(patch(`/composant-piece-slots/${slotId}`, {
|
||||
...'selectedPieceId' in edits ? { selectedPieceId: edits.selectedPieceId } : {},
|
||||
...'quantity' in edits ? { quantity: edits.quantity } : {},
|
||||
}))
|
||||
}
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
||||
if ('selectedProductId' in edits) {
|
||||
slotPromises.push(patch(`/composant-product-slots/${slotId}`, { selectedProductId: edits.selectedProductId }))
|
||||
}
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
||||
if ('selectedComposantId' in edits) {
|
||||
slotPromises.push(patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId: edits.selectedComposantId }))
|
||||
}
|
||||
}
|
||||
await Promise.all(slotPromises)
|
||||
|
||||
// Apply slot edits to local structure so UI reflects saved values
|
||||
const structure = component.value?.structure
|
||||
if (structure) {
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
||||
const slot = (structure.pieces as any[])?.find((s: any) => s.slotId === slotId)
|
||||
if (slot) {
|
||||
if ('selectedPieceId' in edits) slot.selectedPieceId = edits.selectedPieceId
|
||||
if ('quantity' in edits) slot.quantity = edits.quantity
|
||||
}
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
||||
const slot = (structure.products as any[])?.find((s: any) => s.slotId === slotId)
|
||||
if (slot && 'selectedProductId' in edits) slot.selectedProductId = edits.selectedProductId
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
||||
const slot = (structure.subcomponents as any[])?.find((s: any) => s.slotId === slotId)
|
||||
if (slot && 'selectedComposantId' in edits) slot.selectedComponentId = edits.selectedComposantId
|
||||
}
|
||||
}
|
||||
|
||||
// Reset local slot edits
|
||||
slotEdits.pieces = {}
|
||||
slotEdits.products = {}
|
||||
slotEdits.subcomponents = {}
|
||||
|
||||
await syncLinks('composant', component.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
|
||||
toast.showSuccess('Composant mis à jour avec succès.')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la mise à jour du composant')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
watch(
|
||||
[component, selectedTypeStructure],
|
||||
([currentComponent, currentStructure]) => {
|
||||
if (!currentComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
const resolvedTypeId = currentComponent.typeComposantId
|
||||
|| extractRelationId(currentComponent.typeComposant)
|
||||
|| ''
|
||||
if (resolvedTypeId && !currentComponent.typeComposantId) {
|
||||
currentComponent.typeComposantId = resolvedTypeId
|
||||
}
|
||||
selectedTypeId.value = resolvedTypeId
|
||||
|
||||
editionForm.name = currentComponent.name || ''
|
||||
editionForm.description = currentComponent.description || ''
|
||||
editionForm.reference = currentComponent.reference || ''
|
||||
// Load constructeur links
|
||||
fetchLinks('composant', componentId).then((links) => {
|
||||
constructeurLinks.value = links
|
||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
})
|
||||
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
||||
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const pieceIds = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (pieceIds.length) {
|
||||
fetchModelTypeNames(Array.from(new Set(pieceIds)), pieceTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const productIds = getStructureProducts(structure)
|
||||
.map((product: any) => product?.typeProductId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (productIds.length) {
|
||||
fetchModelTypeNames(Array.from(new Set(productIds)), productTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedProductTypeMap.value = { ...fetchedProductTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
fetchComponent(),
|
||||
])
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
component,
|
||||
loading,
|
||||
saving,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
componentDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
historyFieldLabels,
|
||||
|
||||
// Computed
|
||||
canEdit,
|
||||
canSubmit,
|
||||
componentTypeList,
|
||||
selectedType,
|
||||
selectedTypeStructure,
|
||||
structureSelections,
|
||||
pieceSlotEntries,
|
||||
productSlotEntries,
|
||||
subcomponentSlotEntries,
|
||||
|
||||
// History
|
||||
history,
|
||||
historyLoading,
|
||||
historyError,
|
||||
|
||||
// Methods
|
||||
openPreview,
|
||||
closePreview,
|
||||
removeDocument,
|
||||
handleFilesAdded,
|
||||
refreshDocuments,
|
||||
submitEdition,
|
||||
fetchComponent,
|
||||
setSlotQuantity,
|
||||
setPieceSlotSelection,
|
||||
setProductSlotSelection,
|
||||
setSubcomponentSlotSelection,
|
||||
resolvePieceLabel,
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
formatStructurePreview,
|
||||
}
|
||||
}
|
||||
12
frontend/app/composables/useComponentHistory.ts
Normal file
12
frontend/app/composables/useComponentHistory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type ComponentHistoryActor = EntityHistoryActor
|
||||
export type ComponentHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function useComponentHistory() {
|
||||
return useEntityHistory('composant')
|
||||
}
|
||||
29
frontend/app/composables/useComponentTypes.ts
Normal file
29
frontend/app/composables/useComponentTypes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityTypes.
|
||||
* Preserves the original API surface (renamed fields) so consumers need no changes.
|
||||
*/
|
||||
import { useEntityTypes, type EntityType } from './useEntityTypes'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface ComponentType extends EntityType {
|
||||
structure: ComponentModelStructure | null
|
||||
}
|
||||
|
||||
export function useComponentTypes() {
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'COMPONENT',
|
||||
label: 'composant',
|
||||
})
|
||||
|
||||
return {
|
||||
componentTypes: types as Ref<ComponentType[]>,
|
||||
loadingComponentTypes: loading,
|
||||
loadComponentTypes: loadTypes,
|
||||
createComponentType: createType,
|
||||
updateComponentType: updateType,
|
||||
deleteComponentType: deleteType,
|
||||
getComponentTypes: () => types.value as ComponentType[],
|
||||
isComponentTypeLoading: () => loading.value,
|
||||
}
|
||||
}
|
||||
276
frontend/app/composables/useComposants.ts
Normal file
276
frontend/app/composables/useComposants.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Composant {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
description?: string | null
|
||||
typeComposantId?: string | null
|
||||
typeComposant?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
product?: { id: string; name?: string } | null
|
||||
constructeurs?: Constructeur[]
|
||||
constructeurIds?: string[]
|
||||
documents?: unknown[]
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ComposantListResult {
|
||||
success: boolean
|
||||
data?: { items: Composant[]; total: number; page: number; itemsPerPage: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ComposantSingleResult {
|
||||
success: boolean
|
||||
data?: Composant
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LoadComposantsOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typeComposantId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const composants = ref<Composant[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useComposants() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (composant: Composant): Promise<Composant> => {
|
||||
if (!composant || typeof composant !== 'object') {
|
||||
return composant
|
||||
}
|
||||
if (!composant.typeComposantId) {
|
||||
const typeComposantId = extractRelationId(composant.typeComposant)
|
||||
if (typeComposantId) {
|
||||
composant.typeComposantId = typeComposantId
|
||||
}
|
||||
}
|
||||
if (!composant.productId) {
|
||||
const productId = extractRelationId(composant.product)
|
||||
if (productId) {
|
||||
composant.productId = productId
|
||||
}
|
||||
}
|
||||
const ids = uniqueConstructeurIds(
|
||||
composant.constructeurIds,
|
||||
composant.constructeurs,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
Array.isArray(composant.constructeurs) &&
|
||||
composant.constructeurs.length > 0 &&
|
||||
composant.constructeurs.every((item) => item && typeof item === 'object')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
if (resolved.length) {
|
||||
composant.constructeurs = resolved
|
||||
composant.constructeurIds = ids
|
||||
}
|
||||
}
|
||||
return composant
|
||||
}
|
||||
|
||||
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typeComposantId,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (!typeComposantId && loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('search', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeComposant.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typeComposantId) {
|
||||
params.set('typeComposant', typeComposantId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
if (!typeComposantId) {
|
||||
composants.value = enrichedItems
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result as ComposantListResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await post('/composants', normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Composant)
|
||||
composants.value.unshift(enriched)
|
||||
total.value += 1
|
||||
const definition = (composantData as Record<string, unknown>)?.definition as Record<string, unknown> | undefined
|
||||
const displayName =
|
||||
(result.data as Composant)?.name ||
|
||||
(definition?.name as string | undefined) ||
|
||||
composantData?.name ||
|
||||
'Composant'
|
||||
showSuccess(`Composant "${displayName}" créé avec succès`)
|
||||
return { success: true, data: enriched }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du composant:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await patch(`/composants/${id}`, normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const updated = await withResolvedConstructeurs(result.data as Composant)
|
||||
const index = composants.value.findIndex((comp) => comp.id === id)
|
||||
if (index !== -1) {
|
||||
composants.value[index] = updated
|
||||
}
|
||||
showSuccess(`Composant "${updated?.name || composantData.name || ''}" mis à jour avec succès`)
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du composant:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComposant = async (id: string): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/composants/${id}`)
|
||||
if (result.success) {
|
||||
const deletedComposant = composants.value.find((comp) => comp.id === id)
|
||||
composants.value = composants.value.filter((comp) => comp.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du composant:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getComposants = () => composants.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
const clearComposantsCache = () => {
|
||||
composants.value = []
|
||||
total.value = 0
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
composants,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadComposants,
|
||||
createComposant,
|
||||
updateComposant: updateComposantData,
|
||||
deleteComposant,
|
||||
getComposants,
|
||||
isLoading,
|
||||
clearComposantsCache,
|
||||
}
|
||||
}
|
||||
73
frontend/app/composables/useConfirm.ts
Normal file
73
frontend/app/composables/useConfirm.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Promise-based confirmation dialog composable.
|
||||
*
|
||||
* Usage:
|
||||
* const { confirm, confirmState } = useConfirm()
|
||||
* const ok = await confirm({ message: 'Supprimer ?' })
|
||||
* if (ok) { ... }
|
||||
*
|
||||
* The ConfirmModal component reads `confirmState` to render the dialog.
|
||||
*/
|
||||
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
dangerous?: boolean
|
||||
}
|
||||
|
||||
export interface ConfirmState {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText: string
|
||||
cancelText: string
|
||||
dangerous: boolean
|
||||
resolve: ((value: boolean) => void) | null
|
||||
}
|
||||
|
||||
const state = reactive<ConfirmState>({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: 'Supprimer',
|
||||
cancelText: 'Annuler',
|
||||
dangerous: true,
|
||||
resolve: null,
|
||||
})
|
||||
|
||||
function confirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
state.title = options.title ?? 'Confirmation'
|
||||
state.message = options.message
|
||||
state.confirmText = options.confirmText ?? 'Supprimer'
|
||||
state.cancelText = options.cancelText ?? 'Annuler'
|
||||
state.dangerous = options.dangerous ?? true
|
||||
state.resolve = resolve
|
||||
state.open = true
|
||||
})
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
state.resolve?.(true)
|
||||
state.open = false
|
||||
state.resolve = null
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
state.resolve?.(false)
|
||||
state.open = false
|
||||
state.resolve = null
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
return {
|
||||
confirm,
|
||||
confirmState: state,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
}
|
||||
}
|
||||
103
frontend/app/composables/useConstructeurLinks.ts
Normal file
103
frontend/app/composables/useConstructeurLinks.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
type EntityType = 'machine' | 'piece' | 'composant' | 'product'
|
||||
|
||||
const ENDPOINTS: Record<EntityType, string> = {
|
||||
machine: '/machine_constructeur_links',
|
||||
piece: '/piece_constructeur_links',
|
||||
composant: '/composant_constructeur_links',
|
||||
product: '/product_constructeur_links',
|
||||
}
|
||||
|
||||
const ENTITY_KEYS: Record<EntityType, string> = {
|
||||
machine: 'machine',
|
||||
piece: 'piece',
|
||||
composant: 'composant',
|
||||
product: 'product',
|
||||
}
|
||||
|
||||
const ENTITY_PLURALS: Record<EntityType, string> = {
|
||||
machine: 'machines',
|
||||
piece: 'pieces',
|
||||
composant: 'composants',
|
||||
product: 'products',
|
||||
}
|
||||
|
||||
export function useConstructeurLinks() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const fetchLinks = async (
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
): Promise<ConstructeurLinkEntry[]> => {
|
||||
const endpoint = ENDPOINTS[entityType]
|
||||
const key = ENTITY_KEYS[entityType]
|
||||
const plural = ENTITY_PLURALS[entityType]
|
||||
const url = `${endpoint}?${key}=/api/${plural}/${entityId}`
|
||||
const result = await get(url)
|
||||
if (!result.success || !result.data) return []
|
||||
|
||||
const members = extractCollection(result.data)
|
||||
if (!Array.isArray(members)) return []
|
||||
|
||||
return members.map((link: any) => ({
|
||||
linkId: link.id ?? (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : undefined),
|
||||
constructeurId: typeof link.constructeur === 'string'
|
||||
? link.constructeur.split('/').pop()!
|
||||
: link.constructeur?.id ?? '',
|
||||
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
|
||||
supplierReference: link.supplierReference ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
const syncLinks = async (
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
originalLinks: ConstructeurLinkEntry[],
|
||||
formLinks: ConstructeurLinkEntry[],
|
||||
): Promise<void> => {
|
||||
const endpoint = ENDPOINTS[entityType]
|
||||
const key = ENTITY_KEYS[entityType]
|
||||
const plural = ENTITY_PLURALS[entityType]
|
||||
const entityIri = `/api/${plural}/${entityId}`
|
||||
|
||||
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
|
||||
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
|
||||
|
||||
const promises: Promise<any>[] = []
|
||||
|
||||
// Delete removed links
|
||||
for (const [cId, orig] of originalMap) {
|
||||
if (!formMap.has(cId) && orig.linkId) {
|
||||
promises.push(del(`${endpoint}/${orig.linkId}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Create new links
|
||||
for (const [cId, form] of formMap) {
|
||||
if (!originalMap.has(cId)) {
|
||||
promises.push(post(endpoint, {
|
||||
[key]: entityIri,
|
||||
constructeur: `/api/constructeurs/${cId}`,
|
||||
supplierReference: form.supplierReference || null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Patch modified supplierReference
|
||||
for (const [cId, form] of formMap) {
|
||||
const orig = originalMap.get(cId)
|
||||
if (orig?.linkId && (orig.supplierReference ?? null) !== (form.supplierReference ?? null)) {
|
||||
promises.push(patch(`${endpoint}/${orig.linkId}`, {
|
||||
supplierReference: form.supplierReference || null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
return { fetchLinks, syncLinks }
|
||||
}
|
||||
219
frontend/app/composables/useConstructeurs.ts
Normal file
219
frontend/app/composables/useConstructeurs.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Constructeur {
|
||||
id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
|
||||
interface ConstructeurResult {
|
||||
success: boolean
|
||||
data?: Constructeur | Constructeur[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
|
||||
const map = new Map<string, Constructeur>()
|
||||
items.forEach((item) => {
|
||||
if (item && typeof item === 'object' && typeof item.id === 'string') {
|
||||
map.set(item.id, item)
|
||||
}
|
||||
})
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
const normalizeIds = (ids: unknown[] = []): string[] => {
|
||||
if (!Array.isArray(ids)) {
|
||||
return []
|
||||
}
|
||||
return Array.from(
|
||||
new Set(
|
||||
ids
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const upsertConstructeurs = (items: Constructeur[] = []) => {
|
||||
if (!Array.isArray(items) || !items.length) {
|
||||
return
|
||||
}
|
||||
const merged = uniqueConstructeurs([...constructeurs.value, ...items])
|
||||
constructeurs.value = merged
|
||||
}
|
||||
|
||||
const getIndexedConstructeur = (id: string): Constructeur | null =>
|
||||
constructeurs.value.find((item) => item && item.id === id) || null
|
||||
|
||||
const pendingFetches = new Map<string, Promise<Constructeur | null>>()
|
||||
|
||||
export function useConstructeurs() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
|
||||
if (!search && !options.force && loaded.value) {
|
||||
return { success: true, data: constructeurs.value }
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const result = await get(`/constructeurs${query}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
if (!search) loaded.value = true
|
||||
}
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement des fournisseurs:', error)
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
|
||||
return loadConstructeurs(search)
|
||||
}
|
||||
|
||||
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/constructeurs', data)
|
||||
if (result.success) {
|
||||
upsertConstructeurs([result.data as Constructeur])
|
||||
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" créé`)
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la création du fournisseur:', error)
|
||||
showError('Impossible de créer le fournisseur')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureConstructeurs = async (ids: unknown[] = []): Promise<Constructeur[]> => {
|
||||
const normalizedIds = normalizeIds(ids)
|
||||
if (!normalizedIds.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const collected: Constructeur[] = []
|
||||
const missing: string[] = []
|
||||
normalizedIds.forEach((id) => {
|
||||
const existing = getIndexedConstructeur(id)
|
||||
if (existing) {
|
||||
collected.push(existing)
|
||||
} else {
|
||||
missing.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (missing.length) {
|
||||
const fetchTasks = missing.map((id) => {
|
||||
const cached = pendingFetches.get(id)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const task = get(`/constructeurs/${id}`)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
return result.data as Constructeur
|
||||
}
|
||||
return null
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Erreur lors du chargement du fournisseur:', error)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
pendingFetches.delete(id)
|
||||
})
|
||||
pendingFetches.set(id, task)
|
||||
return task
|
||||
})
|
||||
|
||||
const fetched = await Promise.all(fetchTasks)
|
||||
const validFetched = fetched.filter((item): item is Constructeur => item !== null && item.id !== undefined)
|
||||
if (validFetched.length) {
|
||||
upsertConstructeurs(validFetched)
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedIds
|
||||
.map((id) => getIndexedConstructeur(id))
|
||||
.filter((item): item is Constructeur => item !== null)
|
||||
}
|
||||
|
||||
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/constructeurs/${id}`, data)
|
||||
if (result.success) {
|
||||
upsertConstructeurs([result.data as Constructeur])
|
||||
showSuccess(`Fournisseur "${(result.data as Constructeur).name}" mis à jour`)
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la mise à jour du fournisseur:', error)
|
||||
showError('Impossible de mettre à jour le fournisseur')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConstructeur = async (id: string): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/constructeurs/${id}`)
|
||||
if (result.success) {
|
||||
constructeurs.value = constructeurs.value.filter((item) => item.id !== id)
|
||||
showSuccess('Fournisseur supprimé')
|
||||
} else if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as ConstructeurResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la suppression du fournisseur:', error)
|
||||
showError('Impossible de supprimer le fournisseur')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getConstructeurById = (id: string) => getIndexedConstructeur(id)
|
||||
|
||||
return {
|
||||
constructeurs,
|
||||
loading,
|
||||
loadConstructeurs,
|
||||
searchConstructeurs,
|
||||
createConstructeur,
|
||||
updateConstructeur,
|
||||
deleteConstructeur,
|
||||
getConstructeurById,
|
||||
ensureConstructeurs,
|
||||
}
|
||||
}
|
||||
113
frontend/app/composables/useCustomFields.ts
Normal file
113
frontend/app/composables/useCustomFields.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
|
||||
export interface CustomFieldValue {
|
||||
id: string
|
||||
customFieldId: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
value: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useCustomFields() {
|
||||
const { apiCall } = useApi()
|
||||
const customFieldValues = ref<CustomFieldValue[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Créer une valeur de champ personnalisé
|
||||
const createCustomFieldValue = async (customFieldValueData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(customFieldValueData),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les valeurs de champs personnalisés pour une entité
|
||||
const getCustomFieldValuesByEntity = async (entityType: string, entityId: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await apiCall(`/custom-fields/values/${entityType}/${entityId}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
if (result.success) {
|
||||
customFieldValues.value = result.data as CustomFieldValue[]
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des valeurs de champs personnalisés:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour une valeur de champ personnalisé
|
||||
const updateCustomFieldValue = async (id: string, updateData: Record<string, unknown>): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour une valeur de champ personnalisé
|
||||
const upsertCustomFieldValue = async (
|
||||
customFieldId: string | null,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: unknown,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall('/custom-fields/values/upsert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
customFieldId,
|
||||
entityType,
|
||||
entityId,
|
||||
value,
|
||||
...metadata,
|
||||
}),
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création/mise à jour de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une valeur de champ personnalisé
|
||||
const deleteCustomFieldValue = async (id: string): Promise<ApiResponse> => {
|
||||
try {
|
||||
const result = await apiCall(`/custom-fields/values/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la valeur de champ personnalisé:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customFieldValues,
|
||||
loading,
|
||||
createCustomFieldValue,
|
||||
getCustomFieldValuesByEntity,
|
||||
updateCustomFieldValue,
|
||||
upsertCustomFieldValue,
|
||||
deleteCustomFieldValue,
|
||||
}
|
||||
}
|
||||
26
frontend/app/composables/useDarkMode.ts
Normal file
26
frontend/app/composables/useDarkMode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const isDark = ref(false)
|
||||
|
||||
export function useDarkMode() {
|
||||
const toggle = () => {
|
||||
isDark.value = !isDark.value
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
const applyTheme = () => {
|
||||
const theme = isDark.value ? 'mytheme-dark' : 'mytheme'
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'mytheme-dark') {
|
||||
isDark.value = true
|
||||
} else if (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
isDark.value = true
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
return { isDark, toggle, init }
|
||||
}
|
||||
221
frontend/app/composables/useDataTable.ts
Normal file
221
frontend/app/composables/useDataTable.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
|
||||
import { useUrlState } from './useUrlState'
|
||||
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
|
||||
|
||||
export interface UseDataTableDeps {
|
||||
/** Called whenever sort/page/search/perPage/filter changes. The composable does NOT fetch data itself. */
|
||||
fetchData: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface UseDataTableOptions {
|
||||
/** Default sort field */
|
||||
defaultSort?: string
|
||||
/** Default sort direction */
|
||||
defaultDirection?: SortDirection
|
||||
/** Default items per page */
|
||||
defaultPerPage?: number
|
||||
/** Available per-page options */
|
||||
perPageOptions?: number[]
|
||||
/** Search debounce in ms. Default: 300 */
|
||||
searchDebounceMs?: number
|
||||
/** Whether to persist state to URL. Default: true */
|
||||
persistToUrl?: boolean
|
||||
/** Extra URL state params for page-specific filters */
|
||||
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
|
||||
/** Column filter keys to persist in URL (prefixed with `f.` in query string) */
|
||||
columnFilterKeys?: string[]
|
||||
}
|
||||
|
||||
export interface UseDataTableReturn {
|
||||
searchTerm: Ref<string>
|
||||
sortField: Ref<string>
|
||||
sortDirection: Ref<SortDirection>
|
||||
currentPage: Ref<number>
|
||||
itemsPerPage: Ref<number>
|
||||
columnFilters: Ref<DataTableColumnFilters>
|
||||
filters: Record<string, Ref<string | number>>
|
||||
sort: ComputedRef<DataTableSort>
|
||||
pagination: (total: Ref<number>, pageItems: Ref<number>) => ComputedRef<DataTablePagination>
|
||||
handleSort: (newSort: DataTableSort) => void
|
||||
handlePageChange: (page: number) => void
|
||||
handlePerPageChange: (perPage: number) => void
|
||||
handleFilterChange: () => void
|
||||
handleColumnFiltersChange: (filters: DataTableColumnFilters) => void
|
||||
debouncedSearch: () => void
|
||||
refresh: () => void
|
||||
perPageOptions: number[]
|
||||
}
|
||||
|
||||
export function useDataTable(
|
||||
deps: UseDataTableDeps,
|
||||
options: UseDataTableOptions = {},
|
||||
): UseDataTableReturn {
|
||||
const {
|
||||
defaultSort = 'name',
|
||||
defaultDirection = 'asc',
|
||||
defaultPerPage = 20,
|
||||
perPageOptions = [20, 50, 100],
|
||||
searchDebounceMs = 300,
|
||||
persistToUrl = true,
|
||||
extraParams = {},
|
||||
columnFilterKeys = [],
|
||||
} = options
|
||||
|
||||
let searchTerm: Ref<string>
|
||||
let sortField: Ref<string>
|
||||
let sortDirection: Ref<SortDirection>
|
||||
let currentPage: Ref<number>
|
||||
let itemsPerPage: Ref<number>
|
||||
const filters: Record<string, Ref<string | number>> = {}
|
||||
const columnFilterRefs: Record<string, Ref<string>> = {}
|
||||
|
||||
if (persistToUrl) {
|
||||
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: defaultPerPage, type: 'number' },
|
||||
q: { default: '', debounce: searchDebounceMs },
|
||||
sort: { default: defaultSort },
|
||||
dir: { default: defaultDirection },
|
||||
...extraParams,
|
||||
}
|
||||
|
||||
for (const key of columnFilterKeys) {
|
||||
paramDefs[`f.${key}`] = { default: '', debounce: 300 }
|
||||
}
|
||||
|
||||
const state = useUrlState(paramDefs, {
|
||||
onRestore: () => deps.fetchData(),
|
||||
})
|
||||
|
||||
searchTerm = state.q as Ref<string>
|
||||
sortField = state.sort as Ref<string>
|
||||
sortDirection = state.dir as unknown as Ref<SortDirection>
|
||||
currentPage = state.page as unknown as Ref<number>
|
||||
itemsPerPage = state.perPage as unknown as Ref<number>
|
||||
|
||||
for (const key of Object.keys(extraParams)) {
|
||||
filters[key] = (state as Record<string, Ref<string | number>>)[key]!
|
||||
}
|
||||
|
||||
for (const key of columnFilterKeys) {
|
||||
columnFilterRefs[key] = (state as Record<string, Ref<string>>)[`f.${key}`]!
|
||||
}
|
||||
}
|
||||
else {
|
||||
searchTerm = ref('')
|
||||
sortField = ref(defaultSort)
|
||||
sortDirection = ref(defaultDirection) as Ref<SortDirection>
|
||||
currentPage = ref(1)
|
||||
itemsPerPage = ref(defaultPerPage)
|
||||
|
||||
for (const [key, def] of Object.entries(extraParams)) {
|
||||
filters[key] = ref(def.default)
|
||||
}
|
||||
}
|
||||
|
||||
// Search debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}, searchDebounceMs)
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sort = computed<DataTableSort>(() => ({
|
||||
field: sortField.value,
|
||||
direction: sortDirection.value,
|
||||
}))
|
||||
|
||||
const handleSort = (newSort: DataTableSort) => {
|
||||
sortField.value = newSort.field
|
||||
sortDirection.value = newSort.direction
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
const handlePerPageChange = (perPage: number) => {
|
||||
itemsPerPage.value = perPage
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Column filters — seed from URL-persisted refs
|
||||
const initialColumnFilters: DataTableColumnFilters = {}
|
||||
for (const [key, r] of Object.entries(columnFilterRefs)) {
|
||||
if (r.value) initialColumnFilters[key] = r.value
|
||||
}
|
||||
const columnFilters = ref<DataTableColumnFilters>(initialColumnFilters)
|
||||
|
||||
// Sync columnFilters → URL refs
|
||||
if (persistToUrl && columnFilterKeys.length > 0) {
|
||||
watch(columnFilters, (val) => {
|
||||
for (const key of columnFilterKeys) {
|
||||
columnFilterRefs[key]!.value = val[key] || ''
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Sync URL refs → columnFilters (back/forward navigation)
|
||||
for (const key of columnFilterKeys) {
|
||||
watch(columnFilterRefs[key]!, (urlVal) => {
|
||||
const current = columnFilters.value[key] || ''
|
||||
if (current !== urlVal) {
|
||||
columnFilters.value = { ...columnFilters.value, [key]: urlVal }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
|
||||
columnFilters.value = newFilters
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Generic filter change handler (resets page and refetches)
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
const pagination = (total: Ref<number>, pageItems: Ref<number>): ComputedRef<DataTablePagination> =>
|
||||
computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: Math.ceil(total.value / itemsPerPage.value) || 1,
|
||||
totalItems: total.value,
|
||||
pageItems: pageItems.value,
|
||||
perPageOptions,
|
||||
perPage: itemsPerPage.value,
|
||||
}))
|
||||
|
||||
const refresh = () => deps.fetchData()
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
sortField,
|
||||
sortDirection,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
columnFilters,
|
||||
filters,
|
||||
sort,
|
||||
pagination,
|
||||
handleSort,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleFilterChange,
|
||||
handleColumnFiltersChange,
|
||||
debouncedSearch,
|
||||
refresh,
|
||||
perPageOptions,
|
||||
}
|
||||
}
|
||||
334
frontend/app/composables/useDocuments.ts
Normal file
334
frontend/app/composables/useDocuments.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
name: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
fileUrl: string
|
||||
downloadUrl: string
|
||||
type?: string
|
||||
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||
path?: string
|
||||
createdAt?: string
|
||||
siteId?: string
|
||||
machineId?: string
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
site?: { id: string; name?: string } | null
|
||||
machine?: { id: string; name?: string } | null
|
||||
composant?: { id: string; name?: string } | null
|
||||
piece?: { id: string; name?: string } | null
|
||||
product?: { id: string; name?: string } | null
|
||||
}
|
||||
|
||||
export interface UploadContext {
|
||||
siteId?: string
|
||||
machineId?: string
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface DocumentResult {
|
||||
success: boolean
|
||||
data?: Document | Document[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LoadDocumentsOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
attachmentFilter?: string
|
||||
type?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const documents = ref<Document[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') return p.totalItems
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useDocuments() {
|
||||
const { get, patch, postFormData, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
endpoint: string,
|
||||
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||
const result = await get(url)
|
||||
if (result.success) {
|
||||
const data = extractCollection(result.data)
|
||||
if (updateStore) {
|
||||
documents.value = data
|
||||
}
|
||||
return { success: true, data }
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error(`Erreur lors du chargement des documents (${endpoint}):`, error)
|
||||
showError('Impossible de charger les documents')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'createdAt',
|
||||
orderDir = 'desc',
|
||||
attachmentFilter = 'all',
|
||||
type = 'all',
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all' && type === 'all') {
|
||||
return { success: true, data: documents.value }
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
return { success: true, data: documents.value }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (attachmentFilter && attachmentFilter !== 'all') {
|
||||
params.set(`exists[${attachmentFilter}]`, 'true')
|
||||
}
|
||||
|
||||
if (type && type !== 'all') {
|
||||
params.set('type', type)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/documents?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
documents.value = items
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return { success: true, data: items }
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement des documents:', error)
|
||||
showError('Impossible de charger les documents')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocumentsBySite = async (
|
||||
siteId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!siteId) {
|
||||
return { success: false, error: 'Aucun site sélectionné' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/site/${siteId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByMachine = async (
|
||||
machineId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!machineId) {
|
||||
return { success: false, error: 'Aucune machine sélectionnée' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/machine/${machineId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByComponent = async (
|
||||
componentId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!componentId) {
|
||||
return { success: false, error: 'Aucun composant sélectionné' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByProduct = async (
|
||||
productId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!productId) {
|
||||
return { success: false, error: 'Aucun produit sélectionné' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const loadDocumentsByPiece = async (
|
||||
pieceId: string,
|
||||
options: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!pieceId) {
|
||||
return { success: false, error: 'Aucune pièce sélectionnée' }
|
||||
}
|
||||
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
|
||||
}
|
||||
|
||||
const uploadDocuments = async (
|
||||
{ files, context = {} }: { files: File[]; context?: UploadContext },
|
||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!files.length) {
|
||||
return { success: false, error: 'Aucun fichier sélectionné' }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const created: Document[] = []
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('name', file.name)
|
||||
if (context.type) formData.append('type', context.type)
|
||||
|
||||
if (context.siteId) formData.append('siteId', context.siteId)
|
||||
if (context.machineId) formData.append('machineId', context.machineId)
|
||||
if (context.composantId) formData.append('composantId', context.composantId)
|
||||
if (context.productId) formData.append('productId', context.productId)
|
||||
if (context.pieceId) formData.append('pieceId', context.pieceId)
|
||||
|
||||
const result = await postFormData('/documents', formData)
|
||||
if (result.success) {
|
||||
created.push(result.data as Document)
|
||||
showSuccess(`Document "${file.name}" ajouté`)
|
||||
} else if (result.error) {
|
||||
showError(`Erreur sur ${file.name} : ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (created.length) {
|
||||
if (updateStore) {
|
||||
documents.value = [...created, ...documents.value]
|
||||
}
|
||||
return { success: true, data: created }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Aucun document ajouté' }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error("Erreur lors de l'upload des documents:", error)
|
||||
showError("Échec de l'ajout des documents")
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDocument = async (
|
||||
id: string | number,
|
||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
if (!id) {
|
||||
return { success: false, error: 'Identifiant manquant' }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/documents/${id}`)
|
||||
if (result.success) {
|
||||
if (updateStore) {
|
||||
documents.value = documents.value.filter((doc) => doc.id !== id)
|
||||
}
|
||||
showSuccess('Document supprimé')
|
||||
}
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors de la suppression du document:', error)
|
||||
showError('Impossible de supprimer le document')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateDocument = async (
|
||||
id: string,
|
||||
data: { name?: string; type?: string },
|
||||
): Promise<DocumentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/documents/${id}`, data)
|
||||
if (result.success && result.data) {
|
||||
const updated = result.data as Document
|
||||
const index = documents.value.findIndex((doc) => doc.id === id)
|
||||
if (index !== -1) {
|
||||
documents.value[index] = { ...documents.value[index], ...updated }
|
||||
}
|
||||
showSuccess('Document mis à jour')
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de mettre à jour le document')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadDocuments,
|
||||
loadDocumentsBySite,
|
||||
loadDocumentsByMachine,
|
||||
loadDocumentsByComponent,
|
||||
loadDocumentsByPiece,
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
}
|
||||
}
|
||||
109
frontend/app/composables/useDragReorder.ts
Normal file
109
frontend/app/composables/useDragReorder.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface DragReorderHandlers {
|
||||
draggingIndex: Ref<number | null>
|
||||
dropTargetIndex: Ref<number | null>
|
||||
onDragStart: (index: number, event: DragEvent) => void
|
||||
onDragEnter: (index: number) => void
|
||||
onDragOver: (event: DragEvent) => void
|
||||
onDrop: (index: number) => void
|
||||
onDragEnd: () => void
|
||||
reorderClass: (index: number) => string
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
interface DragReorderOptions {
|
||||
draggingClass?: string
|
||||
dropTargetClass?: string
|
||||
onReorder?: () => void
|
||||
}
|
||||
|
||||
function moveItemInPlace<T>(list: T[], from: number, to: number): void {
|
||||
if (from === to) return
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return
|
||||
const updated = list.slice()
|
||||
const [item] = updated.splice(from, 1)
|
||||
if (item === undefined) return
|
||||
updated.splice(to, 0, item)
|
||||
list.splice(0, list.length, ...updated)
|
||||
}
|
||||
|
||||
export function useDragReorder(
|
||||
getList: () => unknown[] | undefined,
|
||||
options: DragReorderOptions = {},
|
||||
): DragReorderHandlers {
|
||||
const {
|
||||
draggingClass = 'border-dashed border-primary',
|
||||
dropTargetClass = 'border-primary border-dashed bg-primary/5',
|
||||
onReorder,
|
||||
} = options
|
||||
|
||||
const draggingIndex = ref<number | null>(null)
|
||||
const dropTargetIndex = ref<number | null>(null)
|
||||
|
||||
const reset = () => {
|
||||
draggingIndex.value = null
|
||||
dropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
draggingIndex.value = index
|
||||
dropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (draggingIndex.value === null) return
|
||||
dropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
const list = getList()
|
||||
if (!Array.isArray(list)) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
const from = draggingIndex.value
|
||||
if (from === null) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
moveItemInPlace(list, from, index)
|
||||
onReorder?.()
|
||||
reset()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
reset()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number): string => {
|
||||
if (draggingIndex.value === index) return draggingClass
|
||||
if (
|
||||
draggingIndex.value !== null
|
||||
&& dropTargetIndex.value === index
|
||||
&& draggingIndex.value !== index
|
||||
) {
|
||||
return dropTargetClass
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return {
|
||||
draggingIndex,
|
||||
dropTargetIndex,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
reorderClass,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
181
frontend/app/composables/useEntityCustomFields.ts
Normal file
181
frontend/app/composables/useEntityCustomFields.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Reactive custom field management for entity items (ComponentItem, PieceItem).
|
||||
*
|
||||
* Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
|
||||
* watchers, and API calls for updating/upserting custom field values.
|
||||
*/
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import {
|
||||
buildDefinitionSources,
|
||||
buildCandidateCustomFields,
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
ensureCustomFieldId,
|
||||
resolveFieldId,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldReadOnly,
|
||||
resolveCustomFieldId,
|
||||
buildCustomFieldMetadata,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
|
||||
export interface EntityCustomFieldsDeps {
|
||||
entity: () => any
|
||||
entityType: 'composant' | 'piece'
|
||||
}
|
||||
|
||||
export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
|
||||
const { entity, entityType } = deps
|
||||
const {
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
upsertCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const definitionSources = computed(() =>
|
||||
buildDefinitionSources(entity(), entityType),
|
||||
)
|
||||
|
||||
const displayedCustomFields = computed(() =>
|
||||
dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(
|
||||
definitionSources.value,
|
||||
entity().customFieldValues,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const candidateCustomFields = computed(() =>
|
||||
buildCandidateCustomFields(entity(), definitionSources.value),
|
||||
)
|
||||
|
||||
// Watchers to ensure field IDs are resolved
|
||||
watch(
|
||||
candidateCustomFields,
|
||||
() => {
|
||||
const candidates = candidateCustomFields.value
|
||||
;(displayedCustomFields.value || []).forEach((field: any) => {
|
||||
if (field) ensureCustomFieldId(field, candidates)
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
displayedCustomFields,
|
||||
(fields) => {
|
||||
const candidates = candidateCustomFields.value
|
||||
;(fields || []).forEach((field: any) => {
|
||||
if (field) ensureCustomFieldId(field, candidates)
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const updateCustomField = async (field: any) => {
|
||||
if (!field || resolveFieldReadOnly(field)) return
|
||||
|
||||
const e = entity()
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
|
||||
// Update existing field value
|
||||
if (fieldValueId) {
|
||||
const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||
if (result.success) {
|
||||
const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
|
||||
if (existingValue?.customField?.id) {
|
||||
field.customFieldId = existingValue.customField.id
|
||||
field.customField = existingValue.customField
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create new field value
|
||||
const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
|
||||
const fieldName = resolveFieldName(field)
|
||||
if (!e?.id) {
|
||||
showError(`Impossible de créer la valeur pour ce champ`)
|
||||
return
|
||||
}
|
||||
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
||||
showError(`Impossible de créer la valeur pour ce champ`)
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId,
|
||||
entityType,
|
||||
e.id,
|
||||
field.value ?? '',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newValue = result.data
|
||||
if (newValue?.id) {
|
||||
field.customFieldValueId = newValue.id
|
||||
field.value = newValue.value ?? field.value ?? ''
|
||||
if (newValue.customField?.id) {
|
||||
field.customFieldId = newValue.customField.id
|
||||
field.customField = newValue.customField
|
||||
}
|
||||
|
||||
if (Array.isArray(e.customFieldValues)) {
|
||||
const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
|
||||
if (index !== -1) {
|
||||
e.customFieldValues.splice(index, 1, newValue)
|
||||
} else {
|
||||
e.customFieldValues.push(newValue)
|
||||
}
|
||||
} else {
|
||||
e.customFieldValues = [newValue]
|
||||
}
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
||||
|
||||
// Update definitions list
|
||||
const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
|
||||
const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
|
||||
const existingIndex = definitions.findIndex((definition: any) => {
|
||||
const definitionId = resolveCustomFieldId(definition)
|
||||
if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
|
||||
return definition?.name === resolveFieldName(field)
|
||||
})
|
||||
|
||||
const updatedDefinition = {
|
||||
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
||||
customFieldValueId: field.customFieldValueId,
|
||||
customFieldId: fieldIdentifier,
|
||||
name: resolveFieldName(field),
|
||||
type: resolveFieldType(field),
|
||||
required: field.required ?? false,
|
||||
options: field.options ?? [],
|
||||
value: field.value ?? '',
|
||||
customField: field.customField ?? null,
|
||||
}
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
definitions.splice(existingIndex, 1, updatedDefinition)
|
||||
} else {
|
||||
definitions.push(updatedDefinition)
|
||||
}
|
||||
e.customFields = definitions
|
||||
} else {
|
||||
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayedCustomFields,
|
||||
candidateCustomFields,
|
||||
updateCustomField,
|
||||
}
|
||||
}
|
||||
136
frontend/app/composables/useEntityDocuments.ts
Normal file
136
frontend/app/composables/useEntityDocuments.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Reactive document management for entity items (ComponentItem, PieceItem).
|
||||
*
|
||||
* Handles document CRUD operations, preview modal state, and lazy loading.
|
||||
* Display helpers (formatSize, shouldInlinePdf, etc.) are imported from
|
||||
* shared/utils/documentDisplayUtils.ts.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
export interface EntityDocumentsDeps {
|
||||
entity: () => any
|
||||
entityType: 'composant' | 'piece'
|
||||
}
|
||||
|
||||
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
const { entity, entityType } = deps
|
||||
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
|
||||
|
||||
const loadDocumentsFn = entityType === 'composant'
|
||||
? useDocuments().loadDocumentsByComponent
|
||||
: useDocuments().loadDocumentsByPiece
|
||||
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const documentsLoaded = ref(!!(entity().documents && entity().documents.length))
|
||||
|
||||
const documents = computed(() => entity().documents || [])
|
||||
|
||||
// Preview modal state
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
// Document watchers
|
||||
watch(
|
||||
() => entity().documents,
|
||||
(docs: any) => {
|
||||
documentsLoaded.value = !!(docs && docs.length)
|
||||
},
|
||||
)
|
||||
|
||||
// CRUD operations
|
||||
const refreshDocuments = async () => {
|
||||
const e = entity()
|
||||
if (!e?.id || e._structurePiece) return
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
e.documents = result.data || []
|
||||
documentsLoaded.value = true
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureDocumentsLoaded = async () => {
|
||||
if (documentsLoaded.value || !entity()?.id) return
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files: File[]) => {
|
||||
const e = entity()
|
||||
if (!files.length || !e?.id) return
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const contextKey = entityType === 'composant' ? 'composantId' : 'pieceId'
|
||||
const result: any = await uploadDocuments(
|
||||
{ files, context: { [contextKey]: e.id } } as any,
|
||||
{ updateStore: false } as any,
|
||||
)
|
||||
if (result.success) {
|
||||
const newDocs = result.data || []
|
||||
e.documents = [...newDocs, ...(e.documents || [])]
|
||||
documentsLoaded.value = true
|
||||
selectedFiles.value = []
|
||||
}
|
||||
} finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId: string) => {
|
||||
if (!documentId) return
|
||||
const result: any = await deleteDocument(documentId, { updateStore: false } as any)
|
||||
if (result.success) {
|
||||
const e = entity()
|
||||
e.documents = (e.documents || []).filter((doc: any) => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
|
||||
const result: any = await updateDocument(id, data)
|
||||
if (result.success) {
|
||||
const e = entity()
|
||||
const docs = e.documents || []
|
||||
const index = docs.findIndex((doc: any) => doc.id === id)
|
||||
if (index !== -1) {
|
||||
docs[index] = { ...docs[index], ...data }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
documentsLoaded,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
openPreview,
|
||||
closePreview,
|
||||
refreshDocuments,
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
}
|
||||
}
|
||||
70
frontend/app/composables/useEntityHistory.ts
Normal file
70
frontend/app/composables/useEntityHistory.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Generic entity history composable.
|
||||
*
|
||||
* Replaces useComponentHistory, usePieceHistory, useProductHistory which were
|
||||
* 99% identical (only the API endpoint differed).
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type EntityHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type EntityHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: EntityHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||
machine: '/machines',
|
||||
composant: '/composants',
|
||||
piece: '/pieces',
|
||||
product: '/products',
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): EntityHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) return payload.items
|
||||
if (Array.isArray(payload?.member)) return payload.member
|
||||
if (Array.isArray(payload?.['hydra:member'])) return payload['hydra:member']
|
||||
return []
|
||||
}
|
||||
|
||||
export function useEntityHistory(entityType: 'machine' | 'composant' | 'piece' | 'product') {
|
||||
const { get } = useApi()
|
||||
const basePath = ENTITY_ENDPOINTS[entityType]
|
||||
|
||||
const history = ref<EntityHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (entityId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`${basePath}/${entityId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l\'historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data)
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { history, loading, error, loadHistory }
|
||||
}
|
||||
103
frontend/app/composables/useEntityProductDisplay.ts
Normal file
103
frontend/app/composables/useEntityProductDisplay.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Reactive product display for entity items (ComponentItem, PieceItem).
|
||||
*
|
||||
* Resolves product information from entity.product, entity.__productDisplay,
|
||||
* or a selectedProduct ref, and produces display-ready computed properties.
|
||||
*/
|
||||
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
})
|
||||
|
||||
function buildProductDisplay(product: any) {
|
||||
if (!product || typeof product !== 'object') return null
|
||||
|
||||
const suppliers = Array.isArray(product.constructeurs)
|
||||
? product.constructeurs
|
||||
.map((c: any) => c?.name)
|
||||
.filter((name: any) => typeof name === 'string' && name.trim().length > 0)
|
||||
.join(', ')
|
||||
: product.supplierLabel || null
|
||||
|
||||
const priceValue = product.supplierPrice ?? product.price ?? product.priceLabel ?? product.priceDisplay ?? null
|
||||
let price: string | null = null
|
||||
if (priceValue !== null && priceValue !== undefined) {
|
||||
const parsed = Number(priceValue)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
price = currencyFormatter.format(parsed)
|
||||
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
|
||||
price = priceValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: product.name || product.label || product.reference || product.productName || null,
|
||||
reference: product.reference || null,
|
||||
category: product.typeProduct?.name || product.category || null,
|
||||
suppliers,
|
||||
price,
|
||||
}
|
||||
}
|
||||
|
||||
export interface EntityProductDisplayDeps {
|
||||
entity: () => any
|
||||
selectedProduct?: Ref<any>
|
||||
}
|
||||
|
||||
export function useEntityProductDisplay(deps: EntityProductDisplayDeps) {
|
||||
const { entity, selectedProduct } = deps
|
||||
|
||||
const displayProduct = computed(() => {
|
||||
// Priority: selectedProduct (for PieceItem) → entity.product → entity.__productDisplay
|
||||
if (selectedProduct?.value) {
|
||||
const normalized = buildProductDisplay(selectedProduct.value)
|
||||
if (normalized) return normalized
|
||||
}
|
||||
const explicit = entity().product || null
|
||||
const normalized = buildProductDisplay(explicit)
|
||||
if (normalized) return normalized
|
||||
const fallback = entity().__productDisplay
|
||||
if (fallback) {
|
||||
return {
|
||||
name: fallback.name || null,
|
||||
reference: fallback.reference || null,
|
||||
category: fallback.category || null,
|
||||
suppliers: fallback.suppliers || null,
|
||||
price: fallback.price || null,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const displayProductName = computed(() => {
|
||||
if (displayProduct.value?.name) return displayProduct.value.name
|
||||
const e = entity()
|
||||
return e.product?.name || e.productName || e.productLabel || null
|
||||
})
|
||||
|
||||
const productInfoRows = computed(() => {
|
||||
if (!displayProduct.value) return []
|
||||
const rows: { label: string; value: string }[] = []
|
||||
if (displayProduct.value.reference) rows.push({ label: 'Référence', value: displayProduct.value.reference })
|
||||
if (displayProduct.value.price) rows.push({ label: 'Prix indicatif', value: displayProduct.value.price })
|
||||
if (displayProduct.value.suppliers) rows.push({ label: 'Fournisseur(s)', value: displayProduct.value.suppliers })
|
||||
if (displayProduct.value.category) rows.push({ label: 'Catégorie', value: displayProduct.value.category })
|
||||
return rows
|
||||
})
|
||||
|
||||
const productDocuments = computed(() => {
|
||||
const product = selectedProduct?.value || entity().product || null
|
||||
return Array.isArray(product?.documents) ? product.documents : []
|
||||
})
|
||||
|
||||
return {
|
||||
displayProduct,
|
||||
displayProductName,
|
||||
productInfoRows,
|
||||
productDocuments,
|
||||
}
|
||||
}
|
||||
192
frontend/app/composables/useEntityTypes.ts
Normal file
192
frontend/app/composables/useEntityTypes.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Generic entity types composable.
|
||||
*
|
||||
* Replaces useComponentTypes, usePieceTypes, useProductTypes which were
|
||||
* 95%+ identical (only the category string and labels differed).
|
||||
*/
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import {
|
||||
listModelTypes,
|
||||
createModelType,
|
||||
updateModelType,
|
||||
deleteModelType,
|
||||
type ModelType,
|
||||
type ModelCategory,
|
||||
} from '~/services/modelTypes'
|
||||
|
||||
export interface EntityType extends ModelType {
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface EntityTypePayload {
|
||||
name: string
|
||||
code?: string
|
||||
description?: string | null
|
||||
notes?: string | null
|
||||
structure?: any
|
||||
}
|
||||
|
||||
interface EntityTypeResult {
|
||||
success: boolean
|
||||
data?: EntityType | EntityType[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface EntityTypeConfig {
|
||||
category: ModelCategory
|
||||
label: string // e.g. 'composant', 'pièce', 'produit'
|
||||
}
|
||||
|
||||
const generateCodeFromName = (name: string): string => {
|
||||
return (name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || 'type'
|
||||
}
|
||||
|
||||
// Shared state per category (module-level singletons)
|
||||
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean>; loaded: Ref<boolean> }> = {}
|
||||
|
||||
function getOrCreateState(category: ModelCategory) {
|
||||
if (!stateByCategory[category]) {
|
||||
stateByCategory[category] = {
|
||||
types: ref<EntityType[]>([]),
|
||||
loading: ref(false),
|
||||
loaded: ref(false),
|
||||
}
|
||||
}
|
||||
return stateByCategory[category]
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the cache for a given category as stale.
|
||||
* Next call to `loadTypes()` (without `force`) will refetch from the API.
|
||||
* Safe to call from event handlers (no setup context required).
|
||||
*/
|
||||
export function invalidateEntityTypeCache(category: ModelCategory) {
|
||||
const state = stateByCategory[category]
|
||||
if (state) {
|
||||
state.loaded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function useEntityTypes(config: EntityTypeConfig) {
|
||||
const { category, label } = config
|
||||
const { showSuccess, showError } = useToast()
|
||||
const state = getOrCreateState(category)
|
||||
|
||||
const normalizeItem = (item: ModelType): EntityType => ({
|
||||
...item,
|
||||
description: item.description ?? item.notes ?? null,
|
||||
})
|
||||
|
||||
const loadTypes = async (options: { force?: boolean } = {}): Promise<EntityTypeResult> => {
|
||||
if (!options.force && state.loaded.value) {
|
||||
return { success: true, data: state.types.value }
|
||||
}
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await listModelTypes({
|
||||
category,
|
||||
sort: 'name',
|
||||
dir: 'asc',
|
||||
limit: 200,
|
||||
})
|
||||
state.types.value = data.items.map(normalizeItem)
|
||||
state.loaded.value = true
|
||||
return { success: true, data: state.types.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
const message = humanizeError(err?.message)
|
||||
showError(`Impossible de charger les types de ${label}.`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createType = async (payload: EntityTypePayload): Promise<EntityTypeResult> => {
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await createModelType({
|
||||
name: payload.name,
|
||||
code: payload.code || generateCodeFromName(payload.name),
|
||||
category,
|
||||
notes: payload.description ?? payload.notes ?? undefined,
|
||||
description: payload.description ?? undefined,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
const normalized = normalizeItem(data)
|
||||
state.types.value.push(normalized)
|
||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateType = async (id: string, payload: EntityTypePayload): Promise<EntityTypeResult> => {
|
||||
state.loading.value = true
|
||||
try {
|
||||
const data = await updateModelType(id, {
|
||||
name: payload.name,
|
||||
description: payload.description ?? undefined,
|
||||
notes: payload.notes ?? undefined,
|
||||
code: payload.code,
|
||||
structure: payload.structure ?? undefined,
|
||||
})
|
||||
const normalized = normalizeItem(data)
|
||||
const index = state.types.value.findIndex((t) => t.id === id)
|
||||
if (index !== -1) state.types.value[index] = normalized
|
||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteType = async (id: string): Promise<EntityTypeResult> => {
|
||||
state.loading.value = true
|
||||
try {
|
||||
await deleteModelType(id)
|
||||
state.types.value = state.types.value.filter((t) => t.id !== id)
|
||||
showSuccess(`Type de ${label} supprimé`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
types: state.types,
|
||||
loading: state.loading,
|
||||
loadTypes,
|
||||
createType,
|
||||
updateType,
|
||||
deleteType,
|
||||
}
|
||||
}
|
||||
98
frontend/app/composables/useEntityVersions.ts
Normal file
98
frontend/app/composables/useEntityVersions.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { ref, toValue } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
export interface VersionEntry {
|
||||
version: number
|
||||
action: 'create' | 'update' | 'restore' | string
|
||||
createdAt: string
|
||||
actor: { id: string; label: string } | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
}
|
||||
|
||||
export interface RestorePreview {
|
||||
version: number
|
||||
restoreMode: 'full' | 'partial'
|
||||
diff: Record<string, { current: unknown; restored: unknown }>
|
||||
warnings: Array<{
|
||||
field: string
|
||||
message: string
|
||||
missingEntityId: string | null
|
||||
missingEntityName: string | null
|
||||
}>
|
||||
snapshot: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
success: boolean
|
||||
newVersion: number
|
||||
restoredFromVersion: number
|
||||
restoreMode: 'full' | 'partial'
|
||||
warnings: RestorePreview['warnings']
|
||||
}
|
||||
|
||||
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||
machine: '/machines',
|
||||
composant: '/composants',
|
||||
piece: '/pieces',
|
||||
product: '/products',
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
entityType: MaybeRef<string>
|
||||
entityId: MaybeRef<string>
|
||||
}
|
||||
|
||||
export function useEntityVersions(deps: Deps) {
|
||||
const { get, post } = useApi()
|
||||
|
||||
const versions = ref<VersionEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const getPath = () => {
|
||||
const type = toValue(deps.entityType)
|
||||
const id = toValue(deps.entityId)
|
||||
const base = ENTITY_ENDPOINTS[type]
|
||||
return `${base}/${id}`
|
||||
}
|
||||
|
||||
const fetchVersions = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`${getPath()}/versions`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger les versions.'
|
||||
versions.value = []
|
||||
return
|
||||
}
|
||||
versions.value = result.data?.items ?? []
|
||||
}
|
||||
catch (err: any) {
|
||||
error.value = err?.message ?? 'Erreur inconnue'
|
||||
versions.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
|
||||
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
|
||||
if (!result.success || !result.data) {
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
const restore = async (version: number): Promise<RestoreResult | null> => {
|
||||
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
|
||||
if (!result.success || !result.data) {
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
return { versions, loading, error, fetchVersions, fetchPreview, restore }
|
||||
}
|
||||
123
frontend/app/composables/useMachineCreatePage.ts
Normal file
123
frontend/app/composables/useMachineCreatePage.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Machine creation page – orchestration composable.
|
||||
*
|
||||
* Simplified: no more TypeMachine / skeleton system.
|
||||
* Supports direct creation or cloning from an existing machine.
|
||||
*/
|
||||
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
|
||||
export function useMachineCreatePage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composable calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
const newMachine = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
reference: '',
|
||||
cloneFromMachineId: '',
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const finalizeMachineCreation = async () => {
|
||||
if (submitting.value) return
|
||||
|
||||
if (!newMachine.name?.trim()) {
|
||||
toast.showError('Merci de renseigner un nom pour la machine')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
let result: any
|
||||
|
||||
if (newMachine.cloneFromMachineId) {
|
||||
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
||||
name: newMachine.name,
|
||||
siteId: newMachine.siteId,
|
||||
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
||||
})
|
||||
} else {
|
||||
result = await createMachine({
|
||||
name: newMachine.name,
|
||||
siteId: newMachine.siteId || undefined,
|
||||
reference: newMachine.reference || undefined,
|
||||
} as any)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const machineId = result.data?.id
|
||||
|| (result.data?.machine as any)?.id
|
||||
|| null
|
||||
|
||||
newMachine.name = ''
|
||||
newMachine.siteId = ''
|
||||
newMachine.reference = ''
|
||||
newMachine.cloneFromMachineId = ''
|
||||
|
||||
if (machineId) {
|
||||
await navigateTo(`/machine/${machineId}`)
|
||||
} else {
|
||||
await navigateTo('/machines')
|
||||
}
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadSites(),
|
||||
loadMachines(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// State
|
||||
newMachine,
|
||||
sites,
|
||||
machines,
|
||||
submitting,
|
||||
loading,
|
||||
|
||||
// Actions
|
||||
finalizeMachineCreation,
|
||||
}
|
||||
}
|
||||
327
frontend/app/composables/useMachineCustomFieldDefs.ts
Normal file
327
frontend/app/composables/useMachineCustomFieldDefs.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type MachineFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date'
|
||||
|
||||
export interface MachineCustomFieldEditorField {
|
||||
uid: string
|
||||
serverId?: string
|
||||
name: string
|
||||
type: MachineFieldType
|
||||
required: boolean
|
||||
optionsText: string
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
interface InitialDef {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options?: string[]
|
||||
orderIndex: number
|
||||
defaultValue?: unknown
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
machineId: string
|
||||
initialDefs: InitialDef[]
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
let uidCounter = 0
|
||||
const createUid = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
uidCounter += 1
|
||||
return `mcf-${Date.now().toString(36)}-${uidCounter}`
|
||||
}
|
||||
|
||||
const normalizeLineEndings = (value: string): string =>
|
||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const toEditorField = (def: InitialDef, index: number): MachineCustomFieldEditorField => ({
|
||||
uid: createUid(),
|
||||
serverId: def.id,
|
||||
name: def.name || '',
|
||||
type: (def.type || 'text') as MachineFieldType,
|
||||
required: Boolean(def.required),
|
||||
optionsText: normalizeLineEndings(
|
||||
Array.isArray(def.options) ? def.options.join('\n') : '',
|
||||
),
|
||||
orderIndex: typeof def.orderIndex === 'number' ? def.orderIndex : index,
|
||||
})
|
||||
|
||||
const hydrateFields = (defs: InitialDef[]): MachineCustomFieldEditorField[] =>
|
||||
defs
|
||||
.map((def, index) => toEditorField(def, index))
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||
|
||||
const buildSnapshot = (defs: InitialDef[]): Map<string, InitialDef> => {
|
||||
const map = new Map<string, InitialDef>()
|
||||
for (const def of defs) {
|
||||
map.set(def.id, def)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const applyOrderIndex = (
|
||||
list: MachineCustomFieldEditorField[],
|
||||
): MachineCustomFieldEditorField[] =>
|
||||
list.map((field, index) => ({ ...field, orderIndex: index }))
|
||||
|
||||
const parseOptions = (optionsText: string): string[] =>
|
||||
normalizeLineEndings(optionsText)
|
||||
.split('\n')
|
||||
.map(o => o.trim())
|
||||
.filter(o => o.length > 0)
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function useMachineCustomFieldDefs(deps: Deps) {
|
||||
const { apiCall } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
// --- State ---
|
||||
|
||||
const fields = ref<MachineCustomFieldEditorField[]>(hydrateFields(deps.initialDefs))
|
||||
const initialSnapshot = ref<Map<string, InitialDef>>(buildSnapshot(deps.initialDefs))
|
||||
const saving = ref(false)
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
const addField = () => {
|
||||
const next = fields.value.slice()
|
||||
next.push({
|
||||
uid: createUid(),
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
orderIndex: next.length,
|
||||
})
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
const removeField = (index: number) => {
|
||||
const next = fields.value.filter((_, i) => i !== index)
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
// --- Drag & drop ---
|
||||
|
||||
const dragState = reactive({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
})
|
||||
|
||||
const resetDragState = () => {
|
||||
dragState.draggingIndex = null
|
||||
dragState.dropTargetIndex = null
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
dragState.draggingIndex = index
|
||||
dragState.dropTargetIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (dragState.draggingIndex === null) return
|
||||
dragState.dropTargetIndex = index
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
const from = dragState.draggingIndex
|
||||
if (from === null) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
if (from === index) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const list = fields.value.slice()
|
||||
if (from < 0 || index < 0 || from >= list.length || index >= list.length) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const [moved] = list.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
list.splice(index, 0, moved)
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number): string => {
|
||||
if (dragState.draggingIndex === index) {
|
||||
return 'border-dashed border-primary bg-primary/5'
|
||||
}
|
||||
if (
|
||||
dragState.draggingIndex !== null
|
||||
&& dragState.dropTargetIndex === index
|
||||
&& dragState.draggingIndex !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/10'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// --- Save ---
|
||||
|
||||
const saveDefinitions = async () => {
|
||||
if (saving.value) return
|
||||
|
||||
// Validate: remove empty-name fields before saving
|
||||
const emptyNameFields = fields.value.filter(f => !f.name.trim() && !f.serverId)
|
||||
if (emptyNameFields.length > 0) {
|
||||
fields.value = applyOrderIndex(fields.value.filter(f => f.name.trim() || f.serverId))
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const snapshot = initialSnapshot.value
|
||||
const currentServerIds = new Set(
|
||||
fields.value.filter(f => f.serverId).map(f => f.serverId!),
|
||||
)
|
||||
|
||||
// DELETE removed fields
|
||||
const deletedIds = [...snapshot.keys()].filter(id => !currentServerIds.has(id))
|
||||
for (const id of deletedIds) {
|
||||
const result = await apiCall(`/custom_fields/${id}`, { method: 'DELETE' })
|
||||
if (!result.success) {
|
||||
showError('Erreur lors de la suppression d\'un champ personnalisé')
|
||||
await deps.onSaved()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let hasNewFields = false
|
||||
|
||||
for (const field of fields.value) {
|
||||
const name = field.name.trim()
|
||||
if (!name) continue
|
||||
|
||||
const options = field.type === 'select' ? parseOptions(field.optionsText) : []
|
||||
|
||||
if (!field.serverId) {
|
||||
// POST new field
|
||||
hasNewFields = true
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options,
|
||||
orderIndex: field.orderIndex,
|
||||
machine: `/api/machines/${deps.machineId}`,
|
||||
}
|
||||
|
||||
const result = await apiCall('/custom_fields', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!result.success) {
|
||||
showError('Erreur lors de la création d\'un champ personnalisé')
|
||||
await deps.onSaved()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// PATCH modified field
|
||||
const original = snapshot.get(field.serverId)
|
||||
const originalOptions = Array.isArray(original?.options)
|
||||
? original.options.join('\n')
|
||||
: ''
|
||||
const currentOptions = field.type === 'select' ? field.optionsText : ''
|
||||
|
||||
const changed
|
||||
= original?.name !== name
|
||||
|| original?.type !== field.type
|
||||
|| original?.required !== field.required
|
||||
|| normalizeLineEndings(originalOptions) !== normalizeLineEndings(currentOptions)
|
||||
|| original?.orderIndex !== field.orderIndex
|
||||
|
||||
if (changed) {
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options,
|
||||
orderIndex: field.orderIndex,
|
||||
}
|
||||
|
||||
const result = await apiCall(`/custom_fields/${field.serverId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!result.success) {
|
||||
showError('Erreur lors de la mise à jour d\'un champ personnalisé')
|
||||
await deps.onSaved()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize missing custom field values if new fields were created
|
||||
if (hasNewFields) {
|
||||
await apiCall(`/machines/${deps.machineId}/add-custom-fields`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
showSuccess('Champs personnalisés sauvegardés avec succès')
|
||||
await deps.onSaved()
|
||||
} catch {
|
||||
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
|
||||
await deps.onSaved()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reinit ---
|
||||
|
||||
const reinit = (newDefs: InitialDef[]) => {
|
||||
fields.value = hydrateFields(newDefs)
|
||||
initialSnapshot.value = buildSnapshot(newDefs)
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
saving,
|
||||
dragState,
|
||||
addField,
|
||||
removeField,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
reorderClass,
|
||||
saveDefinitions,
|
||||
reinit,
|
||||
}
|
||||
}
|
||||
449
frontend/app/composables/useMachineDetailCustomFields.ts
Normal file
449
frontend/app/composables/useMachineDetailCustomFields.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Machine detail — custom field management sub-composable.
|
||||
*
|
||||
* Handles custom field resolution, display filtering, sync and updates
|
||||
* for machines, components and pieces.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
shouldDisplayCustomField,
|
||||
normalizeExistingCustomFieldDefinitions,
|
||||
normalizeCustomFieldValueEntry,
|
||||
mergeCustomFieldValuesWithDefinitions,
|
||||
dedupeCustomFieldEntries,
|
||||
} from '~/shared/utils/customFieldUtils'
|
||||
import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailCustomFieldsDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
isEditMode: Ref<boolean>
|
||||
constructeurs: Ref<unknown[]>
|
||||
resolveProductReference: (source: AnyRecord) => { product: unknown; productId: string | null }
|
||||
getProductDisplay: (source: AnyRecord) => unknown
|
||||
}
|
||||
|
||||
export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps) {
|
||||
const { machine, isEditMode, constructeurs, resolveProductReference, getProductDisplay } = deps
|
||||
const {
|
||||
upsertCustomFieldValue,
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
} = useCustomFields()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineCustomFields = ref<AnyRecord[]>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const visibleMachineCustomFields = computed(() => {
|
||||
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||
if (isEditMode.value) return fields
|
||||
return fields.filter((field) => shouldDisplayCustomField(field))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transform helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getStructureCustomFields = (structure: unknown): AnyRecord[] => {
|
||||
if (!structure || typeof structure !== 'object') return []
|
||||
const normalized = normalizeStructureForEditor(structure as any) as any
|
||||
return Array.isArray(normalized?.customFields)
|
||||
? (normalized.customFields as AnyRecord[])
|
||||
: []
|
||||
}
|
||||
|
||||
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
|
||||
return (piecesData || []).map((piece) => {
|
||||
const typePiece = (piece.typePiece as AnyRecord) || {}
|
||||
|
||||
const normalizeStructureDefs = (structure: unknown) =>
|
||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||
|
||||
const normalizedStructureDefs = [
|
||||
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
|
||||
normalizeStructureDefs(typePiece.structure),
|
||||
]
|
||||
|
||||
const valueEntries = [
|
||||
...(Array.isArray(piece.customFieldValues) ? piece.customFieldValues : []),
|
||||
...(Array.isArray(piece.customFields)
|
||||
? (piece.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
...(Array.isArray(typePiece.customFieldValues)
|
||||
? (typePiece.customFieldValues as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
]
|
||||
|
||||
const customFields = dedupeCustomFieldEntries(
|
||||
mergeCustomFieldValuesWithDefinitions(
|
||||
valueEntries,
|
||||
normalizeExistingCustomFieldDefinitions(piece.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
|
||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
||||
),
|
||||
)
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(
|
||||
piece.constructeurs,
|
||||
piece.constructeurIds,
|
||||
piece.constructeurId,
|
||||
piece.constructeur,
|
||||
(piece.originalPiece as AnyRecord)?.constructeurs,
|
||||
(piece.originalPiece as AnyRecord)?.constructeurIds,
|
||||
(piece.originalPiece as AnyRecord)?.constructeurId,
|
||||
(piece.originalPiece as AnyRecord)?.constructeur,
|
||||
)
|
||||
|
||||
const { product: resolvedProduct, productId: resolvedProductId } =
|
||||
resolveProductReference(piece)
|
||||
|
||||
const constructeursList = resolveConstructeurs(
|
||||
constructeurIds,
|
||||
Array.isArray(piece.constructeurs) ? (piece.constructeurs as any[]) : [],
|
||||
piece.constructeur ? [piece.constructeur as any] : [],
|
||||
Array.isArray((piece.originalPiece as AnyRecord)?.constructeurs)
|
||||
? ((piece.originalPiece as AnyRecord).constructeurs as any[])
|
||||
: [],
|
||||
(piece.originalPiece as AnyRecord)?.constructeur
|
||||
? [(piece.originalPiece as AnyRecord).constructeur as any]
|
||||
: [],
|
||||
constructeurs.value as any,
|
||||
) as any[]
|
||||
|
||||
const normalizedPiece = {
|
||||
...piece,
|
||||
product: resolvedProduct || piece.product || null,
|
||||
productId: resolvedProductId || piece.productId || (piece.product as AnyRecord)?.id || null,
|
||||
}
|
||||
const productDisplay = getProductDisplay(normalizedPiece)
|
||||
|
||||
return {
|
||||
...normalizedPiece,
|
||||
customFields,
|
||||
documents: piece.documents || [],
|
||||
constructeurs: constructeursList,
|
||||
constructeur: constructeursList[0] || piece.constructeur || null,
|
||||
constructeurIds,
|
||||
constructeurId: constructeurIds[0] || null,
|
||||
typePieceId:
|
||||
piece.typePieceId ||
|
||||
(piece.typePiece as AnyRecord)?.id ||
|
||||
null,
|
||||
__productDisplay: productDisplay,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
|
||||
const normalizeStructureDefs = (structure: unknown) =>
|
||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||
|
||||
return (componentsData || []).map((component) => {
|
||||
const type = (component.typeComposant as AnyRecord) || {}
|
||||
|
||||
const normalizedStructureDefs = [
|
||||
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
|
||||
normalizeStructureDefs(type.structure),
|
||||
]
|
||||
|
||||
const actualComponent = (component.originalComposant as AnyRecord) || component
|
||||
|
||||
const valueEntries = [
|
||||
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []),
|
||||
...(Array.isArray(component.customFields)
|
||||
? (component.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
...(Array.isArray(actualComponent?.customFields)
|
||||
? (actualComponent.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
]
|
||||
|
||||
const customFields = dedupeCustomFieldEntries(
|
||||
mergeCustomFieldValuesWithDefinitions(
|
||||
valueEntries,
|
||||
normalizeExistingCustomFieldDefinitions(component.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((component.definition as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
|
||||
normalizeExistingCustomFieldDefinitions(type.customFields),
|
||||
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
|
||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
||||
),
|
||||
)
|
||||
|
||||
const piecesTransformed = component.pieces
|
||||
? transformCustomFields(component.pieces as AnyRecord[]).map((p) => ({
|
||||
...p,
|
||||
parentComponentName: component.name,
|
||||
}))
|
||||
: []
|
||||
|
||||
const subComponents = component.sousComposants
|
||||
? transformComponentCustomFields(component.sousComposants as AnyRecord[])
|
||||
: []
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(
|
||||
component.constructeurs,
|
||||
component.constructeurIds,
|
||||
component.constructeurId,
|
||||
component.constructeur,
|
||||
actualComponent?.constructeurs,
|
||||
actualComponent?.constructeurIds,
|
||||
actualComponent?.constructeurId,
|
||||
actualComponent?.constructeur,
|
||||
)
|
||||
|
||||
const constructeursList = resolveConstructeurs(
|
||||
constructeurIds,
|
||||
Array.isArray(component.constructeurs) ? (component.constructeurs as any[]) : [],
|
||||
component.constructeur ? [component.constructeur as any] : [],
|
||||
Array.isArray(actualComponent?.constructeurs)
|
||||
? (actualComponent.constructeurs as any[])
|
||||
: [],
|
||||
actualComponent?.constructeur ? [actualComponent.constructeur as any] : [],
|
||||
constructeurs.value as any,
|
||||
) as any[]
|
||||
|
||||
const { product: resolvedProduct, productId: resolvedProductId } =
|
||||
resolveProductReference(component)
|
||||
const normalizedComponent = {
|
||||
...component,
|
||||
product: resolvedProduct || component.product || null,
|
||||
productId:
|
||||
resolvedProductId || component.productId || (component.product as AnyRecord)?.id || null,
|
||||
}
|
||||
const productDisplay = getProductDisplay(normalizedComponent)
|
||||
|
||||
return {
|
||||
...normalizedComponent,
|
||||
customFields,
|
||||
pieces: piecesTransformed,
|
||||
subComponents,
|
||||
documents: component.documents || [],
|
||||
constructeurs: constructeursList,
|
||||
constructeur: constructeursList[0] || component.constructeur || null,
|
||||
constructeurIds,
|
||||
constructeurId: constructeurIds[0] || null,
|
||||
typeComposantId:
|
||||
component.typeComposantId ||
|
||||
(component.typeComposant as AnyRecord)?.id ||
|
||||
null,
|
||||
__productDisplay: productDisplay,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine custom field methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const syncMachineCustomFields = () => {
|
||||
if (!machine.value) {
|
||||
machineCustomFields.value = []
|
||||
return
|
||||
}
|
||||
const valueEntries = [
|
||||
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []),
|
||||
...(Array.isArray(machine.value.customFields)
|
||||
? (machine.value.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
]
|
||||
const merged = dedupeCustomFieldEntries(
|
||||
mergeCustomFieldValuesWithDefinitions(
|
||||
valueEntries,
|
||||
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
|
||||
),
|
||||
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
|
||||
machineCustomFields.value = merged
|
||||
}
|
||||
|
||||
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
|
||||
if (!field) return
|
||||
field.value = value
|
||||
if (field.customFieldValueId && (machine.value as AnyRecord)?.customFieldValues) {
|
||||
const stored = ((machine.value as AnyRecord).customFieldValues as AnyRecord[]).find(
|
||||
(fv) => fv.id === field.customFieldValueId,
|
||||
)
|
||||
if (stored) stored.value = value
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineCustomField = async (field: AnyRecord) => {
|
||||
if (!machine.value || !field) return
|
||||
|
||||
const { id: customFieldId, customFieldValueId } = field
|
||||
const fieldLabel = (field.name as string) || 'Champ personnalisé'
|
||||
|
||||
try {
|
||||
if (customFieldValueId) {
|
||||
const result: any = await updateCustomFieldValueApi(customFieldValueId as string, {
|
||||
value: field.value ?? '',
|
||||
} as any)
|
||||
if (result.success) {
|
||||
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
|
||||
syncMachineCustomFields()
|
||||
} else {
|
||||
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!customFieldId) {
|
||||
toast.showError(
|
||||
'Impossible de mettre à jour ce champ personnalisé (identifiant manquant).',
|
||||
)
|
||||
return
|
||||
}
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId as string,
|
||||
'machine',
|
||||
machine.value.id as string,
|
||||
field.value ?? '',
|
||||
)
|
||||
if (result.success) {
|
||||
const createdValue = result.data as AnyRecord
|
||||
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
|
||||
if (createdValue?.id) {
|
||||
if (!createdValue.customField) {
|
||||
createdValue.customField = {
|
||||
id: customFieldId,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
}
|
||||
}
|
||||
field.customFieldValueId = createdValue.id
|
||||
field.readOnly = false
|
||||
const existingValues = Array.isArray(machine.value.customFieldValues)
|
||||
? (machine.value.customFieldValues as AnyRecord[]).filter(
|
||||
(item) => item.id !== createdValue.id,
|
||||
)
|
||||
: []
|
||||
machine.value.customFieldValues = [...existingValues, createdValue]
|
||||
}
|
||||
syncMachineCustomFields()
|
||||
} else {
|
||||
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du champ personnalisé de la machine:', error)
|
||||
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceCustomField = async (fieldUpdate: AnyRecord) => {
|
||||
try {
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
fieldUpdate.fieldId as string,
|
||||
'piece',
|
||||
fieldUpdate.pieceId as string,
|
||||
fieldUpdate.value,
|
||||
)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Champ personnalisé mis à jour avec succès')
|
||||
} else {
|
||||
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
|
||||
console.error('Erreur lors de la mise à jour du champ personnalisé:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveAllMachineCustomFields = async () => {
|
||||
if (!machine.value) return
|
||||
|
||||
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||
const fieldsToSave = fields.filter(
|
||||
(field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
|
||||
)
|
||||
|
||||
for (const field of fieldsToSave) {
|
||||
const { id: customFieldId, customFieldValueId } = field
|
||||
|
||||
try {
|
||||
if (customFieldValueId) {
|
||||
await updateCustomFieldValueApi(customFieldValueId as string, {
|
||||
value: field.value ?? '',
|
||||
} as any)
|
||||
} else if (customFieldId) {
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId as string,
|
||||
'machine',
|
||||
machine.value.id as string,
|
||||
field.value ?? '',
|
||||
)
|
||||
if (result.success) {
|
||||
const createdValue = result.data as AnyRecord
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
if (!createdValue.customField) {
|
||||
createdValue.customField = {
|
||||
id: customFieldId,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
}
|
||||
}
|
||||
const existingValues = Array.isArray(machine.value.customFieldValues)
|
||||
? (machine.value.customFieldValues as AnyRecord[]).filter(
|
||||
(item) => item.id !== createdValue.id,
|
||||
)
|
||||
: []
|
||||
machine.value.customFieldValues = [...existingValues, createdValue]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
machineCustomFields,
|
||||
|
||||
// Computed
|
||||
visibleMachineCustomFields,
|
||||
|
||||
// Transform functions
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
|
||||
// Methods
|
||||
syncMachineCustomFields,
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
saveAllMachineCustomFields,
|
||||
}
|
||||
}
|
||||
519
frontend/app/composables/useMachineDetailData.ts
Normal file
519
frontend/app/composables/useMachineDetailData.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Machine detail page — core state & business logic (orchestrator).
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue (F1.1).
|
||||
* Composes sub-composables for documents, custom fields, hierarchy and products.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useMachinePrint } from '~/composables/useMachinePrint'
|
||||
import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
parseConstructeurLinksFromApi,
|
||||
constructeurIdsFromLinks,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useMachineDetailDocuments } from '~/composables/useMachineDetailDocuments'
|
||||
import { useMachineDetailCustomFields } from '~/composables/useMachineDetailCustomFields'
|
||||
import { useMachineDetailHierarchy } from '~/composables/useMachineDetailHierarchy'
|
||||
import { useMachineDetailProducts } from '~/composables/useMachineDetailProducts'
|
||||
import { useMachineDetailUpdates } from '~/composables/useMachineDetailUpdates'
|
||||
import { downloadDocument as downloadDocumentHelper } from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export function useMachineDetailData(machineId: string) {
|
||||
// External composables
|
||||
const {
|
||||
updateMachine: updateMachineApi,
|
||||
updateStructure: updateMachineStructure,
|
||||
} = useMachines()
|
||||
const { updateComposant: updateComposantApi } = useComposants()
|
||||
const { updatePiece: updatePieceApi } = usePieces()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { get, patch: apiPatch } = useApi()
|
||||
const toast = useToast()
|
||||
const { constructeurs, loadConstructeurs } = useConstructeurs()
|
||||
const { sites, loadSites } = useSites()
|
||||
|
||||
const {
|
||||
printModalOpen,
|
||||
printSelection,
|
||||
ensurePrintSelectionEntries: _ensurePrintEntries,
|
||||
setAllPrintSelection: _setAllPrint,
|
||||
openPrintModal: _openPrintModal,
|
||||
closePrintModal,
|
||||
handlePrintConfirm: _handlePrintConfirm,
|
||||
} = useMachinePrint()
|
||||
|
||||
// Core state
|
||||
const loading = ref(true)
|
||||
const machine = ref<AnyRecord | null>(null)
|
||||
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
|
||||
const printAreaRef = ref<HTMLElement | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
// Constructeur links
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
|
||||
// Machine fields
|
||||
const machineName = ref('')
|
||||
const machineReference = ref('')
|
||||
const machineSiteId = ref('')
|
||||
const machineConstructeurIds = ref<string[]>([])
|
||||
|
||||
const machineConstructeurId = computed({
|
||||
get: () => machineConstructeurIds.value[0] || null,
|
||||
set: (value: string | null) => {
|
||||
machineConstructeurIds.value = value ? [value] : []
|
||||
},
|
||||
})
|
||||
|
||||
const machineConstructeursDisplay = computed(() => {
|
||||
const ids = machineConstructeurIds.value
|
||||
if (!ids.length) return [] as any[]
|
||||
// Extract nested constructeur objects from link entries as candidate pool
|
||||
const linkConstructeurs = constructeurLinks.value
|
||||
.filter(l => l.constructeur && l.constructeur.id)
|
||||
.map(l => l.constructeur!) as any[]
|
||||
return resolveConstructeurs(
|
||||
ids,
|
||||
linkConstructeurs,
|
||||
constructeurs.value as any,
|
||||
) as any[]
|
||||
})
|
||||
|
||||
const machineConstructeurContact = computed(() =>
|
||||
machineConstructeursDisplay.value
|
||||
.map((c: any) => formatConstructeurContactSummary(c))
|
||||
.filter(Boolean)
|
||||
.join(' • '),
|
||||
)
|
||||
|
||||
const hasMachineConstructeur = computed(
|
||||
() => machineConstructeursDisplay.value.length > 0,
|
||||
)
|
||||
|
||||
// UI state
|
||||
const isEditMode = ref(false)
|
||||
const canSubmit = computed(() => {
|
||||
if (!machine.value) return false
|
||||
if (saving.value) return false
|
||||
if (!machineName.value.trim()) return false
|
||||
return true
|
||||
})
|
||||
const debug = ref(false)
|
||||
|
||||
const componentsCollapsed = ref(true)
|
||||
const collapseToggleToken = ref(0)
|
||||
|
||||
const piecesCollapsed = ref(true)
|
||||
const pieceCollapseToggleToken = ref(0)
|
||||
|
||||
// Sub-composables: Products (init first — hierarchy needs findProductById)
|
||||
// Products needs machineProductLinks from hierarchy, but hierarchy needs
|
||||
// findProductById from products. We break the cycle by passing a lazy
|
||||
// computed that reads from the hierarchy ref once it exists.
|
||||
const _machineProductLinksProxy = ref<AnyRecord[]>([])
|
||||
|
||||
const {
|
||||
productInventory,
|
||||
productById,
|
||||
machineDirectProducts,
|
||||
findProductById,
|
||||
resolveProductReference,
|
||||
getProductDisplay,
|
||||
loadProducts,
|
||||
} = useMachineDetailProducts({
|
||||
machineProductLinks: _machineProductLinksProxy,
|
||||
productDocumentsMap,
|
||||
constructeurs,
|
||||
})
|
||||
|
||||
// Sub-composables: Custom fields
|
||||
const {
|
||||
machineCustomFields,
|
||||
visibleMachineCustomFields,
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
syncMachineCustomFields,
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
saveAllMachineCustomFields,
|
||||
} = useMachineDetailCustomFields({
|
||||
machine,
|
||||
isEditMode,
|
||||
constructeurs,
|
||||
resolveProductReference,
|
||||
getProductDisplay,
|
||||
})
|
||||
|
||||
// Sub-composables: Hierarchy (includes structure link CRUD)
|
||||
const hierarchy = useMachineDetailHierarchy({
|
||||
machineId,
|
||||
machine,
|
||||
constructeurs,
|
||||
findProductById,
|
||||
transformComponentCustomFields,
|
||||
transformCustomFields,
|
||||
syncMachineCustomFields,
|
||||
})
|
||||
|
||||
const {
|
||||
components,
|
||||
pieces,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
machineProductLinks,
|
||||
flattenedComponents,
|
||||
machinePieces,
|
||||
applyMachineLinks,
|
||||
reloadMachineStructure,
|
||||
addComponentLink,
|
||||
removeComponentLink,
|
||||
addPieceLink,
|
||||
removePieceLink,
|
||||
addProductLink,
|
||||
removeProductLink,
|
||||
} = hierarchy
|
||||
|
||||
// Keep the product links proxy in sync with the hierarchy's machineProductLinks
|
||||
watch(machineProductLinks, (val) => { _machineProductLinksProxy.value = val }, { immediate: true })
|
||||
|
||||
// Sub-composables: Documents
|
||||
const {
|
||||
machineDocumentFiles,
|
||||
machineDocumentsUploading,
|
||||
machineDocumentsLoaded,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
machineDocumentsList,
|
||||
refreshMachineDocuments,
|
||||
handleMachineFilesAdded,
|
||||
removeMachineDocument,
|
||||
openPreview,
|
||||
closePreview,
|
||||
loadProductDocuments: _loadProductDocuments,
|
||||
} = useMachineDetailDocuments({ machine })
|
||||
|
||||
// Type helpers
|
||||
const componentTypeOptions = computed(() => componentTypes.value || [])
|
||||
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
||||
|
||||
const componentTypeLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
componentTypeOptions.value.forEach((type) => {
|
||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceTypeLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
pieceTypeOptions.value.forEach((type) => {
|
||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// Machine field methods
|
||||
const initMachineFields = () => {
|
||||
if (machine.value) {
|
||||
machineName.value = (machine.value.name as string) || ''
|
||||
machineReference.value = (machine.value.reference as string) || ''
|
||||
// Parse constructeur links from structure response
|
||||
const rawLinks = Array.isArray(machine.value.constructeurs) ? machine.value.constructeurs as any[] : []
|
||||
const parsed = parseConstructeurLinksFromApi(rawLinks)
|
||||
constructeurLinks.value = parsed
|
||||
originalConstructeurLinks.value = parsed.map(l => ({ ...l }))
|
||||
machineConstructeurIds.value = constructeurIdsFromLinks(parsed)
|
||||
machineSiteId.value = (machine.value.siteId as string) || (machine.value.site as AnyRecord)?.id as string || ''
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineFieldId = (fieldName: string): string => {
|
||||
return machine.value ? `machine-${fieldName}-${machine.value.id}` : `machine-${fieldName}`
|
||||
}
|
||||
|
||||
// Product documents wrapper
|
||||
const loadProductDocuments = async () => {
|
||||
const map = await _loadProductDocuments(machineProductLinks.value)
|
||||
productDocumentsMap.value = map
|
||||
}
|
||||
|
||||
// Update methods (delegated to useMachineDetailUpdates)
|
||||
const {
|
||||
updateMachineInfo,
|
||||
updateComponent,
|
||||
updatePieceFromComponent,
|
||||
updatePieceInfo,
|
||||
handleMachineConstructeurChange,
|
||||
editComponent,
|
||||
editPiece,
|
||||
} = useMachineDetailUpdates({
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machineSiteId,
|
||||
machineConstructeurIds,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
machineDocumentsLoaded,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
machineProductLinks,
|
||||
applyMachineLinks,
|
||||
refreshMachineDocuments,
|
||||
transformComponentCustomFields,
|
||||
transformCustomFields,
|
||||
loadProductDocuments,
|
||||
upsertCustomFieldValue,
|
||||
updateMachineApi,
|
||||
updateComposantApi: updateComposantApi,
|
||||
updatePieceApi,
|
||||
apiPatch,
|
||||
toast,
|
||||
syncLinks,
|
||||
})
|
||||
|
||||
// UI methods
|
||||
const toggleEditMode = () => {
|
||||
isEditMode.value = !isEditMode.value
|
||||
debug.value = !debug.value
|
||||
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
||||
refreshMachineDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAllComponents = () => {
|
||||
componentsCollapsed.value = !componentsCollapsed.value
|
||||
collapseToggleToken.value += 1
|
||||
}
|
||||
|
||||
const collapseAllComponents = () => {
|
||||
componentsCollapsed.value = true
|
||||
collapseToggleToken.value += 1
|
||||
}
|
||||
|
||||
const toggleAllPieces = () => {
|
||||
piecesCollapsed.value = !piecesCollapsed.value
|
||||
pieceCollapseToggleToken.value += 1
|
||||
}
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!machine.value || saving.value) return
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// 1. Save machine info (name, reference, site, constructeurs)
|
||||
await updateMachineInfo()
|
||||
|
||||
// 2. Save all custom field values
|
||||
await saveAllMachineCustomFields()
|
||||
|
||||
// 3. Reload machine data to get fresh state
|
||||
await loadMachineData()
|
||||
|
||||
// 4. Exit edit mode
|
||||
isEditMode.value = false
|
||||
toast.showSuccess('Machine mise à jour avec succès')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde:', error)
|
||||
toast.showError('Erreur lors de la sauvegarde de la machine')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEdition = () => {
|
||||
initMachineFields()
|
||||
syncMachineCustomFields()
|
||||
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
|
||||
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
|
||||
isEditMode.value = false
|
||||
}
|
||||
|
||||
// Print wrappers
|
||||
const ensurePrintSelectionEntries = () =>
|
||||
_ensurePrintEntries(components.value, machinePieces.value)
|
||||
const setAllPrintSelection = (value: boolean) =>
|
||||
_setAllPrint(value, components.value, machinePieces.value)
|
||||
const openPrintModal = () =>
|
||||
_openPrintModal(components.value, machinePieces.value)
|
||||
const handlePrintConfirm = () =>
|
||||
_handlePrintConfirm(
|
||||
machine.value as any,
|
||||
machineName.value,
|
||||
machineReference.value,
|
||||
machinePieces.value as any,
|
||||
components.value as any,
|
||||
)
|
||||
|
||||
// Data loading
|
||||
const loadMachineData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const machineResult: any = await get(`/machines/${machineId}/structure`)
|
||||
|
||||
if (!machineResult.success) {
|
||||
console.error('Machine non trouvée:', machineId, machineResult.error)
|
||||
machine.value = null
|
||||
components.value = []
|
||||
pieces.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const machinePayload =
|
||||
machineResult.data?.machine && typeof machineResult.data.machine === 'object'
|
||||
? machineResult.data.machine
|
||||
: machineResult.data
|
||||
|
||||
if (!machinePayload || typeof machinePayload !== 'object') {
|
||||
console.error('Réponse machine invalide pour', machineId)
|
||||
machine.value = null
|
||||
components.value = []
|
||||
pieces.value = []
|
||||
return
|
||||
}
|
||||
|
||||
machine.value = {
|
||||
...machinePayload,
|
||||
documents: machinePayload.documents || [],
|
||||
customFieldValues: machinePayload.customFieldValues || [],
|
||||
}
|
||||
|
||||
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
|
||||
syncMachineCustomFields()
|
||||
initMachineFields()
|
||||
|
||||
// Start document loading early (independent of products/links)
|
||||
const documentPromise = !machineDocumentsLoaded.value
|
||||
? refreshMachineDocuments()
|
||||
: Promise.resolve()
|
||||
|
||||
// Load products in parallel — don't block hierarchy rendering
|
||||
const productsPromise = !(productInventory.value as AnyRecord[]).length
|
||||
? loadProducts().catch((error: unknown) => {
|
||||
console.error('Erreur lors du chargement des produits:', error)
|
||||
})
|
||||
: Promise.resolve()
|
||||
|
||||
await productsPromise
|
||||
const linksApplied = applyMachineLinks(machineResult.data)
|
||||
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
|
||||
if (!linksApplied) {
|
||||
components.value = transformComponentCustomFields(machinePayload.components || [])
|
||||
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
||||
machineProductLinks.value = Array.isArray(machinePayload.productLinks)
|
||||
? machinePayload.productLinks
|
||||
: []
|
||||
}
|
||||
|
||||
if (machine.value) {
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
|
||||
collapseAllComponents()
|
||||
|
||||
// Load product documents in background
|
||||
loadProductDocuments().catch(() => {})
|
||||
|
||||
// Wait for documents if still loading
|
||||
await documentPromise
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des données:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadInitialData = (): Promise<unknown[]> => {
|
||||
return Promise.all([
|
||||
loadConstructeurs(),
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadSites(),
|
||||
])
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => (machine.value as AnyRecord)?.customFieldValues, () => syncMachineCustomFields(), { deep: true })
|
||||
watch(() => (machine.value as AnyRecord)?.customFields, () => syncMachineCustomFields(), { deep: true })
|
||||
watch(
|
||||
() => [components.value.length, machinePieces.value.length],
|
||||
() => ensurePrintSelectionEntries(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
// State
|
||||
loading, machine, components, pieces, printAreaRef,
|
||||
machineComponentLinks, machinePieceLinks, machineProductLinks,
|
||||
|
||||
// Machine fields
|
||||
machineName, machineReference, machineSiteId, machineConstructeurIds, machineConstructeurId,
|
||||
machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur,
|
||||
constructeurLinks, originalConstructeurLinks,
|
||||
sites,
|
||||
|
||||
// UI state
|
||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||
machineCustomFields, previewDocument, previewVisible,
|
||||
isEditMode, debug,
|
||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||
|
||||
// Computed
|
||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
||||
productInventory, productById, flattenedComponents, machinePieces,
|
||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||
|
||||
// Methods
|
||||
findProductById, resolveProductReference, getProductDisplay,
|
||||
initMachineFields, getMachineFieldId,
|
||||
syncMachineCustomFields, setMachineCustomFieldValue,
|
||||
updateMachineCustomField, updatePieceCustomField,
|
||||
refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument,
|
||||
openPreview, closePreview,
|
||||
updateMachineInfo, updateComponent, updatePieceFromComponent,
|
||||
updatePieceInfo, handleMachineConstructeurChange, editComponent, editPiece,
|
||||
toggleEditMode, toggleAllComponents, collapseAllComponents, toggleAllPieces,
|
||||
saving, canSubmit, submitEdition, cancelEdition,
|
||||
|
||||
// Print
|
||||
printModalOpen, printSelection, ensurePrintSelectionEntries,
|
||||
setAllPrintSelection, openPrintModal, closePrintModal, handlePrintConfirm,
|
||||
|
||||
// Loading & structure
|
||||
loadMachineData, loadInitialData,
|
||||
addComponentLink, removeComponentLink, addPieceLink, removePieceLink,
|
||||
addProductLink, removeProductLink, reloadMachineStructure,
|
||||
|
||||
// External
|
||||
constructeurs, loadProducts, updateMachineStructure, toast,
|
||||
downloadDocument: downloadDocumentHelper,
|
||||
}
|
||||
}
|
||||
146
frontend/app/composables/useMachineDetailDocuments.ts
Normal file
146
frontend/app/composables/useMachineDetailDocuments.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Machine detail — document management sub-composable.
|
||||
*
|
||||
* Handles document loading, upload, delete and preview state.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailDocumentsDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
}
|
||||
|
||||
export function useMachineDetailDocuments(deps: MachineDetailDocumentsDeps) {
|
||||
const { machine } = deps
|
||||
const {
|
||||
uploadDocuments,
|
||||
deleteDocument,
|
||||
loadDocumentsByMachine,
|
||||
loadDocumentsByProduct,
|
||||
} = useDocuments()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineDocumentFiles = ref<File[]>([])
|
||||
const machineDocumentsUploading = ref(false)
|
||||
const machineDocumentsLoaded = ref(false)
|
||||
const previewDocument = ref<AnyRecord | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineDocumentsList = computed(
|
||||
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const refreshMachineDocuments = async () => {
|
||||
if (!machine.value?.id) return
|
||||
const result: any = await loadDocumentsByMachine(machine.value.id as string, { updateStore: false })
|
||||
if (result.success && machine.value) {
|
||||
machine.value.documents = result.data || []
|
||||
machineDocumentsLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleMachineFilesAdded = async (files: File[]) => {
|
||||
if (!files.length || !machine.value?.id) return
|
||||
machineDocumentsUploading.value = true
|
||||
try {
|
||||
const result: any = await uploadDocuments(
|
||||
{ files, context: { machineId: machine.value.id } } as any,
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (result.success && machine.value) {
|
||||
const newDocs = (result.data as AnyRecord[]) || []
|
||||
machine.value.documents = [
|
||||
...newDocs,
|
||||
...((machine.value.documents as AnyRecord[]) || []),
|
||||
]
|
||||
machineDocumentFiles.value = []
|
||||
}
|
||||
} finally {
|
||||
machineDocumentsUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeMachineDocument = async (documentId: string) => {
|
||||
if (!documentId) return
|
||||
const result: any = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success && machine.value) {
|
||||
machine.value.documents = ((machine.value.documents as AnyRecord[]) || []).filter(
|
||||
(doc) => doc.id !== documentId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const openPreview = (doc: AnyRecord) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const loadProductDocuments = async (machineProductLinks: AnyRecord[]) => {
|
||||
const productIds = machineProductLinks
|
||||
.map((link) => {
|
||||
const p = link.product as AnyRecord | string | null
|
||||
if (typeof p === 'string') return p.split('/').pop() || null
|
||||
return (p as AnyRecord)?.id as string | null
|
||||
})
|
||||
.filter((id): id is string => !!id)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
productIds.map(async (id) => {
|
||||
const result: any = await loadDocumentsByProduct(id, { updateStore: false })
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
return { id, docs: result.data as AnyRecord[] }
|
||||
}
|
||||
return { id, docs: [] }
|
||||
}),
|
||||
)
|
||||
|
||||
const map = new Map<string, AnyRecord[]>()
|
||||
results.forEach((r) => {
|
||||
if (r.status === 'fulfilled' && r.value.docs.length) {
|
||||
map.set(r.value.id, r.value.docs)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
machineDocumentFiles,
|
||||
machineDocumentsUploading,
|
||||
machineDocumentsLoaded,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
|
||||
// Computed
|
||||
machineDocumentsList,
|
||||
|
||||
// Methods
|
||||
refreshMachineDocuments,
|
||||
handleMachineFilesAdded,
|
||||
removeMachineDocument,
|
||||
openPreview,
|
||||
closePreview,
|
||||
loadProductDocuments,
|
||||
}
|
||||
}
|
||||
306
frontend/app/composables/useMachineDetailHierarchy.ts
Normal file
306
frontend/app/composables/useMachineDetailHierarchy.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Machine detail — hierarchy & link management sub-composable.
|
||||
*
|
||||
* Handles machine hierarchy building, component/piece tree resolution,
|
||||
* flatten helpers, find-by-id utilities, and structure link CRUD.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import {
|
||||
resolveIdentifier,
|
||||
} from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
buildMachineHierarchyFromLinks,
|
||||
resolveLinkArray,
|
||||
} from '~/composables/useMachineHierarchy'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailHierarchyDeps {
|
||||
machineId: string
|
||||
machine: Ref<AnyRecord | null>
|
||||
constructeurs: Ref<unknown[]>
|
||||
findProductById: (id: string | null | undefined) => AnyRecord | null
|
||||
transformComponentCustomFields: (data: AnyRecord[]) => AnyRecord[]
|
||||
transformCustomFields: (data: AnyRecord[]) => AnyRecord[]
|
||||
syncMachineCustomFields: () => void
|
||||
}
|
||||
|
||||
export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
const {
|
||||
machineId,
|
||||
machine,
|
||||
constructeurs,
|
||||
findProductById,
|
||||
transformComponentCustomFields,
|
||||
transformCustomFields,
|
||||
syncMachineCustomFields,
|
||||
} = deps
|
||||
|
||||
const { get, post: apiPost, delete: apiDel } = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const components = ref<AnyRecord[]>([])
|
||||
const pieces = ref<AnyRecord[]>([])
|
||||
const machineComponentLinks = ref<AnyRecord[]>([])
|
||||
const machinePieceLinks = ref<AnyRecord[]>([])
|
||||
const machineProductLinks = ref<AnyRecord[]>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const flattenComponents = (list: AnyRecord[] = []): AnyRecord[] => {
|
||||
const result: AnyRecord[] = []
|
||||
const traverse = (items: AnyRecord[]) => {
|
||||
items.forEach((item) => {
|
||||
result.push(item)
|
||||
if (Array.isArray(item.subComponents) && item.subComponents.length) {
|
||||
traverse(item.subComponents as AnyRecord[])
|
||||
}
|
||||
})
|
||||
}
|
||||
traverse(list)
|
||||
return result
|
||||
}
|
||||
|
||||
const findComponentById = (items: AnyRecord[] | undefined, id: string): AnyRecord | null => {
|
||||
for (const item of items || []) {
|
||||
if (item.id === id) return item
|
||||
const found = findComponentById(item.subComponents as AnyRecord[] | undefined, id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findPieceById = (pieceId: string): AnyRecord | null => {
|
||||
const direct = pieces.value.find((p) => p.id === pieceId)
|
||||
if (direct) return direct
|
||||
|
||||
const searchInComponents = (items: AnyRecord[]): AnyRecord | null => {
|
||||
for (const item of items || []) {
|
||||
const match = ((item.pieces as AnyRecord[]) || []).find((p) => p.id === pieceId)
|
||||
if (match) return match
|
||||
const nested = searchInComponents((item.subComponents as AnyRecord[]) || [])
|
||||
if (nested) return nested
|
||||
}
|
||||
return null
|
||||
}
|
||||
return searchInComponents(components.value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hierarchy & links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const applyMachineLinks = (source: AnyRecord): boolean => {
|
||||
const container = (source?.machine as AnyRecord) ?? null
|
||||
const componentLinksData =
|
||||
resolveLinkArray(source, ['componentLinks', 'machineComponentLinks']) ??
|
||||
resolveLinkArray(container, ['componentLinks', 'machineComponentLinks'])
|
||||
const pieceLinksData =
|
||||
resolveLinkArray(source, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
resolveLinkArray(container, ['pieceLinks', 'machinePieceLinks'])
|
||||
const productLinksData =
|
||||
resolveLinkArray(source, ['productLinks', 'machineProductLinks']) ??
|
||||
resolveLinkArray(container, ['productLinks', 'machineProductLinks'])
|
||||
|
||||
if (componentLinksData === null && pieceLinksData === null && productLinksData === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedComponentLinks = (componentLinksData ?? []) as AnyRecord[]
|
||||
const normalizedPieceLinks = (pieceLinksData ?? []) as AnyRecord[]
|
||||
const normalizedProductLinks = (productLinksData ?? []) as AnyRecord[]
|
||||
|
||||
machineComponentLinks.value = normalizedComponentLinks
|
||||
machinePieceLinks.value = normalizedPieceLinks
|
||||
machineProductLinks.value = normalizedProductLinks
|
||||
|
||||
const { components: hierarchy, machinePieces: machineLevelPieces } =
|
||||
buildMachineHierarchyFromLinks(
|
||||
normalizedComponentLinks,
|
||||
normalizedPieceLinks,
|
||||
findProductById as any,
|
||||
constructeurs.value as any,
|
||||
)
|
||||
|
||||
components.value = transformComponentCustomFields(hierarchy as AnyRecord[])
|
||||
pieces.value = transformCustomFields(machineLevelPieces as AnyRecord[])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const flattenedComponents = computed(() => flattenComponents(components.value))
|
||||
|
||||
const machinePieces = computed(() => {
|
||||
return pieces.value.filter((piece) => {
|
||||
const parentLinkId = resolveIdentifier(
|
||||
piece.parentComponentLinkId,
|
||||
(piece.machinePieceLink as AnyRecord)?.parentComponentLinkId,
|
||||
piece.parentLinkId,
|
||||
)
|
||||
if (parentLinkId) return false
|
||||
return !piece.composantId
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const reloadMachineStructure = async () => {
|
||||
const result: any = await get(`/machines/${machineId}/structure`)
|
||||
if (result.success) {
|
||||
const machinePayload =
|
||||
result.data?.machine && typeof result.data.machine === 'object'
|
||||
? result.data.machine
|
||||
: result.data
|
||||
if (machinePayload && typeof machinePayload === 'object') {
|
||||
machine.value = {
|
||||
...machine.value,
|
||||
...machinePayload,
|
||||
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
|
||||
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
|
||||
}
|
||||
const linksApplied = applyMachineLinks(result.data)
|
||||
if (linksApplied && machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
syncMachineCustomFields()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure link CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const addComponentLink = async (composantId: string) => {
|
||||
const result: any = await apiPost('/machine_component_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
composant: `/api/composants/${composantId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Composant ajouté à la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout du composant')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const removeComponentLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_component_links/${linkId}`)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Composant retiré de la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de la suppression du composant')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
|
||||
const payload: any = {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
piece: `/api/pieces/${pieceId}`,
|
||||
}
|
||||
if (parentComponentLinkId) {
|
||||
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
||||
}
|
||||
const result: any = await apiPost('/machine_piece_links', payload)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Pièce ajoutée à la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout de la pièce')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const removePieceLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Pièce retirée de la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de la suppression de la pièce')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
|
||||
const payload: any = {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
product: `/api/products/${productId}`,
|
||||
}
|
||||
if (parentComponentLinkId) {
|
||||
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
|
||||
}
|
||||
if (parentPieceLinkId) {
|
||||
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
|
||||
}
|
||||
const result: any = await apiPost('/machine_product_links', payload)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Produit ajouté à la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout du produit')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const removeProductLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_product_links/${linkId}`)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Produit retiré de la machine')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de la suppression du produit')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
components,
|
||||
pieces,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
machineProductLinks,
|
||||
|
||||
// Computed
|
||||
flattenedComponents,
|
||||
machinePieces,
|
||||
|
||||
// Helpers
|
||||
flattenComponents,
|
||||
findComponentById,
|
||||
findPieceById,
|
||||
|
||||
// Hierarchy
|
||||
applyMachineLinks,
|
||||
|
||||
// Structure link management
|
||||
reloadMachineStructure,
|
||||
addComponentLink,
|
||||
removeComponentLink,
|
||||
addPieceLink,
|
||||
removePieceLink,
|
||||
addProductLink,
|
||||
removeProductLink,
|
||||
}
|
||||
}
|
||||
132
frontend/app/composables/useMachineDetailProducts.ts
Normal file
132
frontend/app/composables/useMachineDetailProducts.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Machine detail — product display sub-composable.
|
||||
*
|
||||
* Handles product resolution, display helpers, supplier info,
|
||||
* and machine-level direct product links.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import {
|
||||
resolveProductReference as _resolveProductReference,
|
||||
getProductDisplay as _getProductDisplay,
|
||||
getProductSuppliersLabel,
|
||||
getProductPriceLabel,
|
||||
} from '~/shared/utils/productDisplayUtils'
|
||||
import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
interface MachineDetailProductsDeps {
|
||||
machineProductLinks: Ref<AnyRecord[]>
|
||||
productDocumentsMap: Ref<Map<string, AnyRecord[]>>
|
||||
constructeurs: Ref<unknown[]>
|
||||
}
|
||||
|
||||
export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
|
||||
const { machineProductLinks, productDocumentsMap, constructeurs } = deps
|
||||
const { products, loadProducts } = useProducts()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const productInventory = computed(() => products.value || [])
|
||||
|
||||
const productById = computed(() => {
|
||||
const map = new Map<string, AnyRecord>()
|
||||
;(productInventory.value as AnyRecord[]).forEach((product: AnyRecord) => {
|
||||
if (product?.id) map.set(product.id as string, product)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const findProductById = (productId: string | null | undefined): AnyRecord | null => {
|
||||
if (!productId) return null
|
||||
return productById.value.get(productId) || null
|
||||
}
|
||||
|
||||
const resolveProductReference = (source: AnyRecord) =>
|
||||
_resolveProductReference(source, findProductById as any)
|
||||
const getProductDisplay = (source: AnyRecord) =>
|
||||
_getProductDisplay(source, findProductById as any)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machine direct products
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineDirectProducts = computed(() => {
|
||||
return machineProductLinks.value.map((link) => {
|
||||
const productObj = link.product as AnyRecord | string | null
|
||||
let resolved: AnyRecord | null = null
|
||||
let productId: string | null = null
|
||||
|
||||
if (typeof productObj === 'string') {
|
||||
productId = productObj.split('/').pop() || null
|
||||
resolved = productId ? findProductById(productId) : null
|
||||
} else if (productObj && typeof productObj === 'object') {
|
||||
productId = (productObj as AnyRecord)?.id as string | null
|
||||
// Prefer the embedded product from the structure endpoint — it has richer
|
||||
// data (typeProduct as object, supplierPrice, constructeurs) than the
|
||||
// global products cache which may store typeProduct as an IRI string.
|
||||
const cached = productId ? findProductById(productId) : null
|
||||
resolved = productObj as AnyRecord
|
||||
if (cached) {
|
||||
// Merge: use embedded as base, overlay any non-null cached fields
|
||||
resolved = { ...resolved, ...Object.fromEntries(
|
||||
Object.entries(cached as AnyRecord).filter(([, v]) => v != null && v !== ''),
|
||||
) }
|
||||
// But always prefer the embedded typeProduct when it's an object
|
||||
if (productObj.typeProduct && typeof productObj.typeProduct === 'object') {
|
||||
resolved.typeProduct = productObj.typeProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cIds = uniqueConstructeurIds(
|
||||
resolved?.constructeurs,
|
||||
resolved?.constructeurIds,
|
||||
)
|
||||
const resolvedConstructeurs = resolveConstructeurs(
|
||||
cIds,
|
||||
resolved?.constructeurs as any[] || [],
|
||||
constructeurs.value as any,
|
||||
)
|
||||
|
||||
return {
|
||||
id: (resolved?.id as string) || productId || null,
|
||||
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
|
||||
name: (resolved?.name as string) || 'Produit inconnu',
|
||||
reference: (resolved?.reference as string) || null,
|
||||
supplierLabel: resolvedConstructeurs.length
|
||||
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
|
||||
: getProductSuppliersLabel(resolved),
|
||||
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
|
||||
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
|
||||
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
// Computed
|
||||
productInventory,
|
||||
productById,
|
||||
machineDirectProducts,
|
||||
|
||||
// Helpers
|
||||
findProductById,
|
||||
resolveProductReference,
|
||||
getProductDisplay,
|
||||
|
||||
// Loading
|
||||
loadProducts,
|
||||
}
|
||||
}
|
||||
244
frontend/app/composables/useMachineDetailUpdates.ts
Normal file
244
frontend/app/composables/useMachineDetailUpdates.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Machine detail page — update/mutation methods.
|
||||
*
|
||||
* Extracted from useMachineDetailData.ts to keep the orchestrator under 500 lines.
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface UseMachineDetailUpdatesDeps {
|
||||
machine: Ref<AnyRecord | null>
|
||||
machineName: Ref<string>
|
||||
machineReference: Ref<string>
|
||||
machineSiteId: Ref<string>
|
||||
machineConstructeurIds: Ref<string[]>
|
||||
constructeurLinks: Ref<ConstructeurLinkEntry[]>
|
||||
originalConstructeurLinks: Ref<ConstructeurLinkEntry[]>
|
||||
machineDocumentsLoaded: Ref<boolean>
|
||||
machineComponentLinks: Ref<AnyRecord[]>
|
||||
machinePieceLinks: Ref<AnyRecord[]>
|
||||
machineProductLinks: Ref<AnyRecord[]>
|
||||
applyMachineLinks: (data: AnyRecord) => boolean
|
||||
refreshMachineDocuments: () => Promise<void>
|
||||
transformComponentCustomFields: (items: AnyRecord[]) => AnyRecord[]
|
||||
transformCustomFields: (items: AnyRecord[]) => AnyRecord[]
|
||||
loadProductDocuments: () => Promise<void>
|
||||
upsertCustomFieldValue: (
|
||||
fieldId: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
value: unknown,
|
||||
) => Promise<unknown>
|
||||
updateMachineApi: (id: string, data: any) => Promise<unknown>
|
||||
updateComposantApi: (id: string, data: any) => Promise<unknown>
|
||||
updatePieceApi: (id: string, data: any) => Promise<unknown>
|
||||
apiPatch: (endpoint: string, data?: unknown) => Promise<any>
|
||||
toast: { showInfo: (msg: string) => void }
|
||||
syncLinks: (
|
||||
entityType: 'machine' | 'piece' | 'composant' | 'product',
|
||||
entityId: string,
|
||||
originalLinks: ConstructeurLinkEntry[],
|
||||
formLinks: ConstructeurLinkEntry[],
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
const {
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machineSiteId,
|
||||
machineConstructeurIds,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
applyMachineLinks,
|
||||
loadProductDocuments,
|
||||
transformComponentCustomFields,
|
||||
transformCustomFields,
|
||||
upsertCustomFieldValue,
|
||||
updateMachineApi,
|
||||
updateComposantApi,
|
||||
updatePieceApi,
|
||||
apiPatch,
|
||||
toast,
|
||||
syncLinks,
|
||||
} = deps
|
||||
|
||||
const updateMachineInfo = async () => {
|
||||
if (!machine.value) return
|
||||
try {
|
||||
const result: any = await updateMachineApi(machine.value.id as string, {
|
||||
name: machineName.value,
|
||||
reference: machineReference.value,
|
||||
siteId: machineSiteId.value || undefined,
|
||||
} as any)
|
||||
if (result.success) {
|
||||
const machinePayload =
|
||||
result.data?.machine && typeof result.data.machine === 'object'
|
||||
? result.data.machine
|
||||
: result.data
|
||||
if (machinePayload && typeof machinePayload === 'object') {
|
||||
machine.value = {
|
||||
...machine.value,
|
||||
...machinePayload,
|
||||
documents: machinePayload.documents || machine.value.documents || [],
|
||||
customFieldValues: machinePayload.customFieldValues || machine.value.customFieldValues || [],
|
||||
}
|
||||
const linksApplied = applyMachineLinks(result.data)
|
||||
if (linksApplied && machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
}
|
||||
loadProductDocuments().catch(() => {})
|
||||
}
|
||||
}
|
||||
// Sync constructeur links after entity save
|
||||
await syncLinks('machine', machine.value!.id as string, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la machine:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const updateComponent = async (updatedComponent: AnyRecord) => {
|
||||
try {
|
||||
const cIds = uniqueConstructeurIds(
|
||||
updatedComponent.constructeurIds,
|
||||
updatedComponent.constructeurId,
|
||||
updatedComponent.constructeur,
|
||||
)
|
||||
const productId = updatedComponent.productId
|
||||
? String(updatedComponent.productId)
|
||||
: null
|
||||
const prixStr =
|
||||
updatedComponent.prix !== null &&
|
||||
updatedComponent.prix !== undefined &&
|
||||
String(updatedComponent.prix).trim() !== ''
|
||||
? String(updatedComponent.prix)
|
||||
: null
|
||||
|
||||
const result: any = await updateComposantApi(updatedComponent.id as string, {
|
||||
name: updatedComponent.name,
|
||||
reference: updatedComponent.reference,
|
||||
constructeurIds: cIds,
|
||||
prix: prixStr,
|
||||
productId,
|
||||
} as any)
|
||||
if (result.success) {
|
||||
const transformed = transformComponentCustomFields([result.data])[0]
|
||||
Object.assign(updatedComponent, transformed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du composant:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const _buildAndUpdatePiece = async (updatedPiece: AnyRecord) => {
|
||||
const cIds = uniqueConstructeurIds(
|
||||
updatedPiece.constructeurIds,
|
||||
updatedPiece.constructeurId,
|
||||
updatedPiece.constructeur,
|
||||
)
|
||||
const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
|
||||
const prixStr =
|
||||
updatedPiece.prix !== null &&
|
||||
updatedPiece.prix !== undefined &&
|
||||
String(updatedPiece.prix).trim() !== ''
|
||||
? String(updatedPiece.prix)
|
||||
: null
|
||||
|
||||
const result: any = await updatePieceApi(updatedPiece.id as string, {
|
||||
name: updatedPiece.name,
|
||||
reference: updatedPiece.reference || null,
|
||||
constructeurIds: cIds,
|
||||
prix: prixStr,
|
||||
productId,
|
||||
} as any)
|
||||
if (result.success) {
|
||||
const transformed = transformCustomFields([result.data])[0]
|
||||
Object.assign(updatedPiece, transformed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const updatePieceFromComponent = async (updatedPiece: AnyRecord) => {
|
||||
try {
|
||||
const result = await _buildAndUpdatePiece(updatedPiece)
|
||||
if (result?.success && updatedPiece.customFields) {
|
||||
const fieldsToSave = (updatedPiece.customFields as AnyRecord[]).filter(
|
||||
(field) => field.value !== undefined,
|
||||
)
|
||||
if (fieldsToSave.length) {
|
||||
await Promise.allSettled(
|
||||
fieldsToSave.map((field) =>
|
||||
upsertCustomFieldValue(
|
||||
field.id as string,
|
||||
'piece',
|
||||
updatedPiece.id as string,
|
||||
field.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update slot quantity if this is a composant structure piece
|
||||
const slotId = updatedPiece.slotId as string | null
|
||||
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
|
||||
if (slotId && quantity !== null) {
|
||||
await apiPatch(`/composant-piece-slots/${slotId}`, { quantity })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceInfo = async (updatedPiece: AnyRecord) => {
|
||||
try {
|
||||
await _buildAndUpdatePiece(updatedPiece)
|
||||
|
||||
// Update link quantity if this is a direct machine piece
|
||||
const linkId = updatedPiece.linkId || updatedPiece.machinePieceLinkId
|
||||
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
|
||||
if (linkId && quantity !== null) {
|
||||
await apiPatch(`/machine_piece_links/${linkId}`, { quantity })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMachineConstructeurChange = (value: unknown) => {
|
||||
const newIds = uniqueConstructeurIds(value)
|
||||
machineConstructeurIds.value = newIds
|
||||
// Sync constructeurLinks: keep existing entries, add new ones
|
||||
const existingMap = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
|
||||
constructeurLinks.value = newIds.map(id =>
|
||||
existingMap.get(id) ?? { constructeurId: id, supplierReference: null },
|
||||
)
|
||||
}
|
||||
|
||||
const editComponent = () => {
|
||||
toast.showInfo('La modification des composants sera bientôt disponible')
|
||||
}
|
||||
|
||||
const editPiece = () => {
|
||||
toast.showInfo('La modification des pièces sera bientôt disponible')
|
||||
}
|
||||
|
||||
return {
|
||||
updateMachineInfo,
|
||||
updateComponent,
|
||||
updatePieceFromComponent,
|
||||
updatePieceInfo,
|
||||
handleMachineConstructeurChange,
|
||||
editComponent,
|
||||
editPiece,
|
||||
}
|
||||
}
|
||||
312
frontend/app/composables/useMachineHierarchy.ts
Normal file
312
frontend/app/composables/useMachineHierarchy.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Builds a component/piece hierarchy tree from flat machine link arrays.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue to keep the page orchestrator lean.
|
||||
*/
|
||||
|
||||
import { resolveIdentifier, getProductDisplay } from '~/shared/utils/productDisplayUtils'
|
||||
import { resolveConstructeurs, uniqueConstructeurIds, type ConstructeurSummary } from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const collectConstructeurs = (
|
||||
allConstructeurs: AnyRecord[],
|
||||
...sources: unknown[]
|
||||
): AnyRecord[] => {
|
||||
const ids = uniqueConstructeurIds(...sources)
|
||||
if (!ids.length) return []
|
||||
|
||||
const pools = sources
|
||||
.flatMap((source) => {
|
||||
if (Array.isArray(source)) return [source]
|
||||
if (source && typeof source === 'object' && (source as AnyRecord).id) return [[source]]
|
||||
return []
|
||||
})
|
||||
.filter(Boolean) as AnyRecord[][]
|
||||
|
||||
// ConstructeurSummary and AnyRecord are structurally compatible at runtime
|
||||
const allPools = [...pools, allConstructeurs] as unknown as Array<ConstructeurSummary[]>
|
||||
return resolveConstructeurs(ids, ...allPools) as unknown as AnyRecord[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link array resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const resolveLinkArray = (source: unknown, keys: string[]): unknown[] | null => {
|
||||
if (!source || typeof source !== 'object') return null
|
||||
for (const key of keys) {
|
||||
const value = (source as AnyRecord)[key]
|
||||
if (Array.isArray(value)) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merge trees (for incremental updates)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function mergePieceLists(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
|
||||
if (!existing.length) {
|
||||
return updates.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
|
||||
}
|
||||
if (!updates.length) {
|
||||
return existing.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
|
||||
}
|
||||
|
||||
const updateMap = new Map<unknown, AnyRecord>(
|
||||
updates.map((piece) => [piece.id, { ...piece, constructeurs: piece.constructeurs || [] }]),
|
||||
)
|
||||
const merged = existing.map((piece) => {
|
||||
const update = updateMap.get(piece.id)
|
||||
if (!update) return piece
|
||||
return { ...piece, ...update, customFields: update.customFields ?? piece.customFields }
|
||||
})
|
||||
|
||||
updates.forEach((update) => {
|
||||
if (!existing.some((piece) => piece.id === update.id)) merged.push(update)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function mergeComponentTrees(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
|
||||
if (!existing.length) {
|
||||
return updates.map((component) => ({
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
|
||||
...piece,
|
||||
constructeurs: piece.constructeurs || [],
|
||||
})),
|
||||
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
|
||||
}))
|
||||
}
|
||||
if (!updates.length) return existing
|
||||
|
||||
const updateMap = new Map<unknown, AnyRecord>(
|
||||
updates.map((component) => [
|
||||
component.id,
|
||||
{
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
|
||||
...piece,
|
||||
constructeurs: piece.constructeurs || [],
|
||||
})),
|
||||
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
|
||||
},
|
||||
]),
|
||||
)
|
||||
const merged: AnyRecord[] = existing.map((component) => {
|
||||
const update = updateMap.get(component.id)
|
||||
if (!update) {
|
||||
return {
|
||||
...component,
|
||||
constructeurs: component.constructeurs || [],
|
||||
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], []),
|
||||
subComponents: mergeComponentTrees((component.subComponents || []) as AnyRecord[], []),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...component,
|
||||
...update,
|
||||
customFields: update.customFields ?? component.customFields,
|
||||
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], (update.pieces || []) as AnyRecord[]),
|
||||
subComponents: mergeComponentTrees(
|
||||
(component.subComponents || []) as AnyRecord[],
|
||||
(update.subComponents || []) as AnyRecord[],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
updates.forEach((update) => {
|
||||
if (!existing.some((component) => component.id === update.id)) merged.push(update)
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build hierarchy from links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildMachineHierarchyFromLinks = (
|
||||
componentLinks: AnyRecord[] = [],
|
||||
pieceLinks: AnyRecord[] = [],
|
||||
findProductById: (id: string) => AnyRecord | null,
|
||||
allConstructeurs: AnyRecord[] = [],
|
||||
): { components: AnyRecord[]; machinePieces: AnyRecord[] } => {
|
||||
const normalizeComponentLinkId = (link: AnyRecord) =>
|
||||
resolveIdentifier(link?.id, link?.linkId, link?.machineComponentLinkId)
|
||||
|
||||
const normalizePieceLinkId = (link: AnyRecord) =>
|
||||
resolveIdentifier(link?.id, link?.linkId, link?.machinePieceLinkId)
|
||||
|
||||
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
||||
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
||||
|
||||
const machinePieceLinkId = normalizePieceLinkId(link)
|
||||
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
|
||||
|
||||
const overrides = (link.overrides || null) as AnyRecord | null
|
||||
|
||||
const basePiece: AnyRecord = {
|
||||
...appliedPiece,
|
||||
id: appliedPiece.id || pieceId || machinePieceLinkId || `piece-${machinePieceLinkId}`,
|
||||
pieceId,
|
||||
name: overrides?.name || appliedPiece.name || (appliedPiece.definition as AnyRecord)?.name || (appliedPiece.definition as AnyRecord)?.role || originalPiece?.name || 'Pièce',
|
||||
reference: overrides?.reference || appliedPiece.reference || (appliedPiece.definition as AnyRecord)?.reference || originalPiece?.reference || null,
|
||||
prix: overrides?.prix ?? appliedPiece.prix ?? originalPiece?.prix ?? null,
|
||||
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
|
||||
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
|
||||
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
|
||||
typePiece: appliedPiece.typePiece || null,
|
||||
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || null,
|
||||
overrides,
|
||||
originalPiece,
|
||||
machinePieceLink: link,
|
||||
machinePieceLinkId,
|
||||
linkId: machinePieceLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedPiece.parentComponentLinkId),
|
||||
parentComponentId: resolveIdentifier(appliedPiece.parentComponentId, link.parentComponentId),
|
||||
parentComponentName,
|
||||
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
|
||||
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
|
||||
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
|
||||
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
|
||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||
customFields: appliedPiece.customFields || [],
|
||||
}
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
||||
const resolvedProduct = (appliedPiece.product || link.product || originalPiece?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
|
||||
|
||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedPiece.constructeurs, appliedPiece.constructeur, appliedPiece.constructeurIds, appliedPiece.constructeurId, originalPiece?.constructeurs, originalPiece?.constructeur, originalPiece?.constructeurIds, originalPiece?.constructeurId)
|
||||
|
||||
return {
|
||||
...basePiece,
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || basePiece.constructeur || null,
|
||||
constructeurId: (constructeurs[0] as AnyRecord)?.id || basePiece.constructeurId || null,
|
||||
productId: resolvedProductId || appliedPiece.productId || null,
|
||||
product: resolvedProduct || appliedPiece.product || null,
|
||||
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedPiece.product || null, productId: resolvedProductId || appliedPiece.productId || null } as AnyRecord, findProductById),
|
||||
}
|
||||
}
|
||||
|
||||
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
||||
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
||||
|
||||
const machineComponentLinkId = normalizeComponentLinkId(link)
|
||||
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
|
||||
|
||||
const compOverrides = (link.overrides || null) as AnyRecord | null
|
||||
|
||||
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
|
||||
|
||||
const linkedPieces = Array.isArray(link.pieceLinks)
|
||||
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
|
||||
// If no linked pieces exist, build read-only entries from the composant's structure
|
||||
const structurePieceDefs = (!linkedPieces.length && appliedComponent.structure && typeof appliedComponent.structure === 'object')
|
||||
? (Array.isArray((appliedComponent.structure as AnyRecord).pieces) ? (appliedComponent.structure as AnyRecord).pieces as AnyRecord[] : [])
|
||||
: []
|
||||
const structurePieces = structurePieceDefs.map((def, index) => {
|
||||
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
|
||||
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
|
||||
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
|
||||
return {
|
||||
...(resolved || {}),
|
||||
id: resolved?.id || `structure-piece-${composantId}-${index}`,
|
||||
pieceId: resolved?.id || null,
|
||||
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
|
||||
reference: resolved?.reference || definition.reference || def.reference || null,
|
||||
prix: resolved?.prix ?? null,
|
||||
constructeurs: resolved?.constructeurs || [],
|
||||
documents: [],
|
||||
quantity,
|
||||
slotId: def.slotId || definition.slotId || null,
|
||||
typePieceId: resolved?.typePieceId || definition.typePieceId || def.typePieceId || null,
|
||||
typePiece: resolved?.typePiece || null,
|
||||
parentComponentLinkId: machineComponentLinkId,
|
||||
parentComponentName: componentName,
|
||||
_structurePiece: true,
|
||||
}
|
||||
}) as AnyRecord[]
|
||||
|
||||
const pieces = linkedPieces.length ? linkedPieces : structurePieces
|
||||
|
||||
const subComponents = Array.isArray(link.childLinks)
|
||||
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
|
||||
: []
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedComponent.productId, (appliedComponent.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalComponent?.productId, (originalComponent?.product as AnyRecord)?.id)
|
||||
const resolvedProduct = (appliedComponent.product || link.product || originalComponent?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
|
||||
|
||||
const baseComponent: AnyRecord = {
|
||||
...appliedComponent,
|
||||
id: appliedComponent.id || composantId || machineComponentLinkId || `component-${machineComponentLinkId}`,
|
||||
composantId,
|
||||
name: componentName,
|
||||
reference: compOverrides?.reference || appliedComponent.reference || (appliedComponent.definition as AnyRecord)?.reference || originalComponent?.reference || null,
|
||||
prix: compOverrides?.prix ?? appliedComponent.prix ?? originalComponent?.prix ?? null,
|
||||
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
|
||||
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
|
||||
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
|
||||
typeComposant: appliedComponent.typeComposant || null,
|
||||
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || null,
|
||||
overrides: compOverrides,
|
||||
machineComponentLinkOverrides: compOverrides,
|
||||
definitionOverrides: compOverrides,
|
||||
originalComposant: originalComponent,
|
||||
machineComponentLink: link,
|
||||
machineComponentLinkId,
|
||||
linkId: machineComponentLinkId,
|
||||
componentLinkId: machineComponentLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||
customFields: appliedComponent.customFields || [],
|
||||
pieces,
|
||||
subComponents,
|
||||
subcomponents: subComponents,
|
||||
sousComposants: subComponents,
|
||||
}
|
||||
|
||||
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)
|
||||
|
||||
return {
|
||||
...baseComponent,
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || baseComponent.constructeur || null,
|
||||
constructeurId: (constructeurs[0] as AnyRecord)?.id || baseComponent.constructeurId || null,
|
||||
productId: resolvedProductId || appliedComponent.productId || null,
|
||||
product: resolvedProduct || appliedComponent.product || null,
|
||||
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedComponent.product || null, productId: resolvedProductId || appliedComponent.productId || null } as AnyRecord, findProductById),
|
||||
}
|
||||
}
|
||||
|
||||
const rootComponents = (Array.isArray(componentLinks) ? componentLinks : [])
|
||||
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
|
||||
.map(createComponentNode)
|
||||
.filter(Boolean) as AnyRecord[]
|
||||
|
||||
const machinePieces = (Array.isArray(pieceLinks) ? pieceLinks : [])
|
||||
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
|
||||
.map((link) => createPieceNode(link, null))
|
||||
.filter(Boolean) as AnyRecord[]
|
||||
|
||||
return { components: rootComponents, machinePieces }
|
||||
}
|
||||
163
frontend/app/composables/useMachinePrint.ts
Normal file
163
frontend/app/composables/useMachinePrint.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Machine print selection and execution logic.
|
||||
*
|
||||
* Extracted from pages/machine/[id].vue.
|
||||
*/
|
||||
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
export interface PrintSelection {
|
||||
machine: { info: boolean; customFields: boolean; documents: boolean }
|
||||
components: Record<string, boolean>
|
||||
pieces: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function useMachinePrint() {
|
||||
const printModalOpen = ref(false)
|
||||
const printSelection = reactive<PrintSelection>({
|
||||
machine: { info: true, customFields: true, documents: true },
|
||||
components: {},
|
||||
pieces: {},
|
||||
})
|
||||
|
||||
const ensurePrintSelectionEntries = (
|
||||
components: AnyRecord[],
|
||||
machinePieces: AnyRecord[],
|
||||
) => {
|
||||
printSelection.machine.info ??= true
|
||||
printSelection.machine.customFields ??= true
|
||||
printSelection.machine.documents ??= true
|
||||
|
||||
const ensureComponent = (component: AnyRecord) => {
|
||||
if (component?.id !== undefined && printSelection.components[component.id as string] === undefined) {
|
||||
printSelection.components[component.id as string] = true
|
||||
}
|
||||
;((component.pieces || []) as AnyRecord[]).forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
|
||||
printSelection.pieces[piece.id as string] = true
|
||||
}
|
||||
})
|
||||
;((component.subComponents || []) as AnyRecord[]).forEach(ensureComponent)
|
||||
}
|
||||
|
||||
components.forEach(ensureComponent)
|
||||
machinePieces.forEach((piece) => {
|
||||
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
|
||||
printSelection.pieces[piece.id as string] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setAllPrintSelection = (
|
||||
value: boolean,
|
||||
components: AnyRecord[],
|
||||
machinePieces: AnyRecord[],
|
||||
) => {
|
||||
ensurePrintSelectionEntries(components, machinePieces)
|
||||
printSelection.machine.info = value
|
||||
printSelection.machine.customFields = value
|
||||
printSelection.machine.documents = value
|
||||
Object.keys(printSelection.components).forEach((key) => {
|
||||
printSelection.components[key] = value
|
||||
})
|
||||
Object.keys(printSelection.pieces).forEach((key) => {
|
||||
printSelection.pieces[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
const openPrintModal = (components: AnyRecord[], machinePieces: AnyRecord[]) => {
|
||||
ensurePrintSelectionEntries(components, machinePieces)
|
||||
printModalOpen.value = true
|
||||
}
|
||||
|
||||
const closePrintModal = () => {
|
||||
printModalOpen.value = false
|
||||
}
|
||||
|
||||
const printMachine = (
|
||||
machine: AnyRecord,
|
||||
machineName: string,
|
||||
machineReference: string,
|
||||
machinePieces: AnyRecord[],
|
||||
components: AnyRecord[],
|
||||
currentSelection: PrintSelection = printSelection,
|
||||
) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// machineReport.js has no type annotations; cast to avoid inferred never[] params
|
||||
const context = (buildMachinePrintContext as unknown as (config: Record<string, unknown>) => Record<string, unknown>)({
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machinePieces,
|
||||
components,
|
||||
selection: currentSelection,
|
||||
})
|
||||
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
|
||||
.map((node) => node.outerHTML)
|
||||
.join('')
|
||||
|
||||
const htmlContent = buildMachinePrintHtml(context, styles)
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.position = 'fixed'
|
||||
iframe.style.right = '0'
|
||||
iframe.style.bottom = '0'
|
||||
iframe.style.width = '0'
|
||||
iframe.style.height = '0'
|
||||
iframe.style.border = '0'
|
||||
iframe.setAttribute('title', 'print-frame')
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const iframeWindow = iframe.contentWindow
|
||||
const iframeDocument = iframe.contentDocument || iframeWindow?.document
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
iframe.remove()
|
||||
return
|
||||
}
|
||||
|
||||
iframeDocument.open()
|
||||
iframeDocument.write(htmlContent)
|
||||
iframeDocument.close()
|
||||
|
||||
const triggerPrint = () => {
|
||||
iframeWindow.focus()
|
||||
iframeWindow.print()
|
||||
setTimeout(() => {
|
||||
iframe.remove()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
if (iframeDocument.readyState === 'complete') {
|
||||
setTimeout(triggerPrint, 50)
|
||||
} else {
|
||||
iframe.onload = () => setTimeout(triggerPrint, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrintConfirm = async (
|
||||
machine: AnyRecord,
|
||||
machineName: string,
|
||||
machineReference: string,
|
||||
machinePieces: AnyRecord[],
|
||||
components: AnyRecord[],
|
||||
) => {
|
||||
closePrintModal()
|
||||
await nextTick()
|
||||
printMachine(machine, machineName, machineReference, machinePieces, components, printSelection)
|
||||
}
|
||||
|
||||
return {
|
||||
printModalOpen,
|
||||
printSelection,
|
||||
ensurePrintSelectionEntries,
|
||||
setAllPrintSelection,
|
||||
openPrintModal,
|
||||
closePrintModal,
|
||||
printMachine,
|
||||
handlePrintConfirm,
|
||||
}
|
||||
}
|
||||
266
frontend/app/composables/useMachines.ts
Normal file
266
frontend/app/composables/useMachines.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Machine {
|
||||
id: string
|
||||
name?: string
|
||||
siteId?: string | null
|
||||
componentLinks?: unknown[]
|
||||
pieceLinks?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const machines = ref<Machine[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalizeMachineResponse = (payload: unknown): Machine | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = payload as Record<string, unknown>
|
||||
const container = raw.machine && typeof raw.machine === 'object'
|
||||
? raw.machine as Record<string, unknown>
|
||||
: raw
|
||||
|
||||
const normalized: Record<string, unknown> = { ...container }
|
||||
|
||||
if (!normalized.siteId) {
|
||||
const siteId = extractRelationId(container.site)
|
||||
if (siteId) {
|
||||
normalized.siteId = siteId
|
||||
}
|
||||
}
|
||||
|
||||
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
|
||||
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
|
||||
[]
|
||||
const pieceLinks = resolveLinkCollection(raw, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
resolveLinkCollection(container, ['pieceLinks', 'machinePieceLinks']) ??
|
||||
[]
|
||||
|
||||
normalized.componentLinks = componentLinks
|
||||
normalized.pieceLinks = pieceLinks
|
||||
|
||||
return normalized as Machine
|
||||
}
|
||||
|
||||
export function useMachines() {
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadMachines = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/machines')
|
||||
if (result.success) {
|
||||
const machineList = extractCollection(result.data)
|
||||
const normalized = machineList
|
||||
.map((item) => normalizeMachineResponse(item))
|
||||
.filter((item): item is Machine => item !== null)
|
||||
machines.value = normalized
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des machines:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
|
||||
const result = await post('/machines', normalizedPayload)
|
||||
if (result.success) {
|
||||
const createdMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
if (createdMachine) {
|
||||
machines.value.push(createdMachine)
|
||||
}
|
||||
const displayName = createdMachine?.name || machineData?.name || ''
|
||||
showSuccess(`Machine "${displayName}" créée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
|
||||
const result = await patch(`/machines/${id}`, normalizedPayload)
|
||||
if (result.success) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
const index = machines.value.findIndex((machine) => machine.id === id)
|
||||
if (index !== -1 && updatedMachine) {
|
||||
machines.value[index] = {
|
||||
...machines.value[index],
|
||||
...updatedMachine,
|
||||
}
|
||||
}
|
||||
showSuccess(`Machine "${updatedMachine?.name || machineData.name || ''}" mise à jour avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateStructure = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
return { success: false, error: 'Identifiant de machine manquant' }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/machines/${machineId}/structure`, payload)
|
||||
if (result.success) {
|
||||
const index = machines.value.findIndex((machine) => machine.id === machineId)
|
||||
if (index !== -1) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
machines.value[index]
|
||||
machines.value[index] = {
|
||||
...machines.value[index],
|
||||
...updatedMachine,
|
||||
} as Machine
|
||||
}
|
||||
showSuccess('Structure de la machine mise à jour avec succès')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la structure de la machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post(`/machines/${sourceId}/clone`, data)
|
||||
if (result.success) {
|
||||
const clonedMachine = normalizeMachineResponse(result.data) ||
|
||||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
|
||||
null
|
||||
if (clonedMachine) {
|
||||
machines.value.push(clonedMachine)
|
||||
}
|
||||
showSuccess(`Machine "${clonedMachine?.name || data.name}" clonée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du clonage de la machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMissingCustomFields = async (machineId: string, { showToast: shouldShowToast = true } = {}): Promise<ApiResponse> => {
|
||||
if (!machineId) {
|
||||
const error = 'Identifiant de machine manquant'
|
||||
if (shouldShowToast) {
|
||||
showError(error)
|
||||
}
|
||||
return { success: false, error }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await post(`/machines/${machineId}/add-custom-fields`)
|
||||
if (result.success) {
|
||||
if (shouldShowToast) {
|
||||
showSuccess('Champs personnalisés complétés avec succès')
|
||||
}
|
||||
} else if (shouldShowToast && result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'ajout des champs personnalisés manquants:', error)
|
||||
if (shouldShowToast) {
|
||||
showError('Erreur lors de la complétion des champs personnalisés')
|
||||
}
|
||||
return { success: false, error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMachine = async (id: string): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/machines/${id}`)
|
||||
if (result.success) {
|
||||
const deletedMachine = machines.value.find((machine) => machine.id === id)
|
||||
machines.value = machines.value.filter((machine) => machine.id !== id)
|
||||
showSuccess(`Machine "${deletedMachine?.name || 'inconnu'}" supprimée avec succès`)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la machine:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineById = (id: string): Machine | undefined => {
|
||||
return machines.value.find((machine) => machine.id === id)
|
||||
}
|
||||
|
||||
const getMachinesBySite = (siteId: string): Machine[] => {
|
||||
return machines.value.filter((machine) => machine.siteId === siteId)
|
||||
}
|
||||
|
||||
const getMachines = (): Machine[] => machines.value
|
||||
const isLoading = (): boolean => loading.value
|
||||
|
||||
return {
|
||||
machines,
|
||||
loading,
|
||||
loadMachines,
|
||||
createMachine,
|
||||
updateMachine: updateMachineData,
|
||||
updateStructure,
|
||||
cloneMachine,
|
||||
deleteMachine,
|
||||
getMachineById,
|
||||
getMachinesBySite,
|
||||
getMachines,
|
||||
isLoading,
|
||||
addMissingCustomFields,
|
||||
}
|
||||
}
|
||||
65
frontend/app/composables/useNavDropdown.ts
Normal file
65
frontend/app/composables/useNavDropdown.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
|
||||
export function useNavDropdown() {
|
||||
const openDropdown = ref<string | null>(null)
|
||||
let dropdownCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const route = useRoute()
|
||||
|
||||
const setDropdown = (name: string) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
if (openDropdown.value !== name) {
|
||||
openDropdown.value = name
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleDropdownClose = (name: string) => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
}
|
||||
dropdownCloseTimer = setTimeout(() => {
|
||||
if (openDropdown.value === name) {
|
||||
openDropdown.value = null
|
||||
}
|
||||
dropdownCloseTimer = null
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const closeDropdownNow = () => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
openDropdown.value = null
|
||||
}
|
||||
|
||||
const toggleDropdown = (name: string) => {
|
||||
if (openDropdown.value === name) {
|
||||
closeDropdownNow()
|
||||
return
|
||||
}
|
||||
setDropdown(name)
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
closeDropdownNow()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dropdownCloseTimer) {
|
||||
clearTimeout(dropdownCloseTimer)
|
||||
dropdownCloseTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
openDropdown,
|
||||
setDropdown,
|
||||
scheduleDropdownClose,
|
||||
closeDropdownNow,
|
||||
toggleDropdown,
|
||||
}
|
||||
}
|
||||
41
frontend/app/composables/usePermissions.ts
Normal file
41
frontend/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,
|
||||
}
|
||||
}
|
||||
53
frontend/app/composables/usePersistedSort.ts
Normal file
53
frontend/app/composables/usePersistedSort.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useCookie } from '#imports'
|
||||
|
||||
type SortCookie = {
|
||||
field?: string
|
||||
direction?: string
|
||||
}
|
||||
|
||||
const readSortCookie = (value: unknown): SortCookie | null => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return value as SortCookie
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value) as SortCookie
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const usePersistedSort = <
|
||||
TField extends string,
|
||||
TDirection extends string,
|
||||
>(
|
||||
key: string,
|
||||
defaults: { field: TField; direction: TDirection },
|
||||
) => {
|
||||
const cookie = useCookie<string | null>(`sort:${key}`, {
|
||||
sameSite: 'lax',
|
||||
})
|
||||
const stored = readSortCookie(cookie.value)
|
||||
const sortField = ref<TField>((stored?.field as TField) || defaults.field)
|
||||
const sortDirection = ref<TDirection>(
|
||||
(stored?.direction as TDirection) || defaults.direction,
|
||||
)
|
||||
|
||||
watch([sortField, sortDirection], () => {
|
||||
cookie.value = JSON.stringify({
|
||||
field: sortField.value,
|
||||
direction: sortDirection.value,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortDirection,
|
||||
}
|
||||
}
|
||||
34
frontend/app/composables/usePersistedValue.ts
Normal file
34
frontend/app/composables/usePersistedValue.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useCookie } from '#imports'
|
||||
|
||||
const parseValue = <T>(value: unknown, fallback: T): T => {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value) as T
|
||||
} catch {
|
||||
return value as unknown as T
|
||||
}
|
||||
}
|
||||
return value as T
|
||||
}
|
||||
|
||||
export const usePersistedValue = <T>(key: string, fallback: T) => {
|
||||
const cookie = useCookie<string | null>(`pref:${key}`, {
|
||||
sameSite: 'lax',
|
||||
})
|
||||
const initial = parseValue(cookie.value, fallback)
|
||||
const state = ref<T>(initial)
|
||||
|
||||
watch(
|
||||
state,
|
||||
(value) => {
|
||||
cookie.value = JSON.stringify(value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
482
frontend/app/composables/usePieceEdit.ts
Normal file
482
frontend/app/composables/usePieceEdit.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from '#imports'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { usePieceHistory } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
getStructureProducts,
|
||||
buildProductRequirementDescriptions,
|
||||
buildProductRequirementEntries,
|
||||
resizeProductSelections,
|
||||
areProductSelectionsFilled,
|
||||
applyProductSelection,
|
||||
collectNormalizedProductIds,
|
||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
customFields?: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
export function usePieceEdit(pieceId: string) {
|
||||
const { canEdit } = usePermissions()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { updatePiece } = usePieces()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = usePieceHistory()
|
||||
|
||||
const piece = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const pieceDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
typePiece: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
productIds: 'Produits liés',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const pieceTypeDetails = ref<any | null>(null)
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
description: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
||||
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
||||
)
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: PieceModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? resolvedStructure.value ?? null
|
||||
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
return
|
||||
}
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||
if (!documentId) {
|
||||
return
|
||||
}
|
||||
const result = await deleteDocument(documentId, { updateStore: false })
|
||||
if (result.success) {
|
||||
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files: File[]) => {
|
||||
if (!files?.length || !piece.value?.id) {
|
||||
return
|
||||
}
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const result = await uploadDocuments(
|
||||
{
|
||||
files,
|
||||
context: { pieceId: piece.value.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (result.success) {
|
||||
selectedFiles.value = []
|
||||
await refreshDocuments()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
if (!piece.value?.id) {
|
||||
pieceDocuments.value = []
|
||||
return
|
||||
}
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
||||
|
||||
const selectedType = computed(() => {
|
||||
if (!selectedTypeId.value) {
|
||||
return null
|
||||
}
|
||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const structureProducts = computed(() =>
|
||||
getStructureProducts(resolvedStructure.value),
|
||||
)
|
||||
|
||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||
|
||||
const productRequirementDescriptions = computed(() =>
|
||||
buildProductRequirementDescriptions(structureProducts.value),
|
||||
)
|
||||
|
||||
const ensureProductSelections = (count: number) => {
|
||||
productSelections.value = resizeProductSelections(productSelections.value, count)
|
||||
}
|
||||
|
||||
let pendingProductIds: string[] = []
|
||||
|
||||
const productRequirementEntries = computed(() =>
|
||||
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
areProductSelectionsFilled(
|
||||
requiresProductSelection.value,
|
||||
productRequirementEntries.value,
|
||||
productSelections.value,
|
||||
),
|
||||
)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||
}
|
||||
|
||||
watch(structureProducts, (products) => {
|
||||
ensureProductSelections(products.length)
|
||||
if (!pendingProductIds.length || products.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = Array.from(
|
||||
{ length: products.length },
|
||||
(_, index) => pendingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(
|
||||
canEdit.value
|
||||
&& piece.value
|
||||
&& editionForm.name
|
||||
&& requiredCustomFieldsFilled.value
|
||||
&& productSelectionsFilled.value
|
||||
&& !saving.value,
|
||||
),
|
||||
)
|
||||
|
||||
const fetchPiece = async () => {
|
||||
if (!pieceId || typeof pieceId !== 'string') {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
return
|
||||
}
|
||||
const result = await get(`/pieces/${pieceId}`)
|
||||
if (result.success) {
|
||||
piece.value = result.data
|
||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||
loadPieceTypeDetailsFromCache(result.data)
|
||||
|
||||
// History is non-blocking — template handles its own loading state
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
}
|
||||
else {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
||||
const typeId = currentPiece?.typePieceId
|
||||
|| extractRelationId(currentPiece?.typePiece)
|
||||
|| ''
|
||||
if (!typeId) {
|
||||
pieceTypeDetails.value = null
|
||||
return
|
||||
}
|
||||
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
||||
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||
if (cachedType) {
|
||||
pieceTypeDetails.value = cachedType
|
||||
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
return
|
||||
}
|
||||
// Fallback: fetch if not in cache (edge case)
|
||||
getModelType(typeId).then((type) => {
|
||||
if (type && typeof type === 'object') {
|
||||
pieceTypeDetails.value = type
|
||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
}
|
||||
}).catch(() => {
|
||||
pieceTypeDetails.value = null
|
||||
})
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
watch(
|
||||
[piece, selectedType],
|
||||
([currentPiece, _currentType]) => {
|
||||
if (!currentPiece || initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedTypeId = currentPiece.typePieceId
|
||||
|| extractRelationId(currentPiece.typePiece)
|
||||
|| ''
|
||||
if (resolvedTypeId && !currentPiece.typePieceId) {
|
||||
currentPiece.typePieceId = resolvedTypeId
|
||||
}
|
||||
selectedTypeId.value = resolvedTypeId
|
||||
|
||||
editionForm.name = currentPiece.name || ''
|
||||
editionForm.description = currentPiece.description || ''
|
||||
editionForm.reference = currentPiece.reference || ''
|
||||
// Load constructeur links
|
||||
fetchLinks('piece', pieceId).then((links) => {
|
||||
constructeurLinks.value = links
|
||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
})
|
||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||
|
||||
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
|
||||
? currentPiece.productIds.map((id: unknown) => String(id))
|
||||
: currentPiece.product?.id || currentPiece.productId
|
||||
? [String(currentPiece.product?.id || currentPiece.productId)]
|
||||
: []
|
||||
pendingProductIds = existingProductIds
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
if (existingProductIds.length && structureProducts.value.length) {
|
||||
const next = Array.from(
|
||||
{ length: structureProducts.value.length },
|
||||
(_, index) => existingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
}
|
||||
|
||||
// After setting selectedTypeId, read selectedType.value (now updated) instead of
|
||||
// the stale destructured currentType which was captured before the ID change.
|
||||
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
|
||||
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
|
||||
|
||||
initialized = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selectedType, (currentType) => {
|
||||
if (!piece.value || !currentType) {
|
||||
return
|
||||
}
|
||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||
})
|
||||
|
||||
watch(resolvedStructure, (currentStructure) => {
|
||||
if (!piece.value) {
|
||||
return
|
||||
}
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
||||
})
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!piece.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.prix === 'string'
|
||||
? editionForm.prix.trim()
|
||||
: editionForm.prix === null || editionForm.prix === undefined
|
||||
? ''
|
||||
: String(editionForm.prix).trim()
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
description: editionForm.description.trim() || null,
|
||||
}
|
||||
|
||||
const reference = editionForm.reference.trim()
|
||||
payload.reference = reference ? reference : null
|
||||
|
||||
const normalizedProductIds = collectNormalizedProductIds(
|
||||
productRequirementEntries.value,
|
||||
productSelections.value,
|
||||
)
|
||||
|
||||
payload.productIds = normalizedProductIds
|
||||
payload.productId = normalizedProductIds[0] || null
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
else {
|
||||
payload.prix = null
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updatePiece(piece.value.id, payload)
|
||||
if (result.success && result.data) {
|
||||
const updatedPiece = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'piece',
|
||||
updatedPiece.id,
|
||||
[
|
||||
updatedPiece?.typePiece?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
toast.showSuccess('Pièce mise à jour avec succès.')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la mise à jour de la pièce')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
piece,
|
||||
loading,
|
||||
saving,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
pieceDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
productSelections,
|
||||
customFieldInputs,
|
||||
canEdit,
|
||||
|
||||
// Computed
|
||||
pieceTypeList,
|
||||
selectedType,
|
||||
resolvedStructure,
|
||||
structureProducts,
|
||||
productRequirementDescriptions,
|
||||
productRequirementEntries,
|
||||
canSubmit,
|
||||
historyFieldLabels,
|
||||
|
||||
// History
|
||||
history,
|
||||
historyLoading,
|
||||
historyError,
|
||||
|
||||
// Methods
|
||||
openPreview,
|
||||
closePreview,
|
||||
removeDocument,
|
||||
handleFilesAdded,
|
||||
setProductSelection,
|
||||
submitEdition,
|
||||
fetchPiece,
|
||||
formatPieceStructurePreview,
|
||||
}
|
||||
}
|
||||
12
frontend/app/composables/usePieceHistory.ts
Normal file
12
frontend/app/composables/usePieceHistory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type PieceHistoryActor = EntityHistoryActor
|
||||
export type PieceHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function usePieceHistory() {
|
||||
return useEntityHistory('piece')
|
||||
}
|
||||
440
frontend/app/composables/usePieceStructureEditorLogic.ts
Normal file
440
frontend/app/composables/usePieceStructureEditorLogic.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import type {
|
||||
PieceModelCustomField,
|
||||
PieceModelCustomFieldType,
|
||||
PieceModelProduct,
|
||||
PieceModelStructure,
|
||||
PieceModelStructureEditorField,
|
||||
} from '~/shared/types/inventory'
|
||||
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
|
||||
export type EditorField = PieceModelStructureEditorField & { uid: string }
|
||||
export type EditorProduct = {
|
||||
uid: string
|
||||
typeProductId: string
|
||||
typeProductLabel: string
|
||||
familyCode: string
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
props: {
|
||||
modelValue?: PieceModelStructure | null
|
||||
}
|
||||
emit: (event: 'update:modelValue', value: PieceModelStructure) => void
|
||||
}
|
||||
|
||||
// --- Pure helpers ---
|
||||
|
||||
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
|
||||
Array.isArray(value) ? value : []
|
||||
|
||||
const normalizeLineEndings = (value: string): string =>
|
||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const safeClone = <T,>(value: T, fallback: T): T => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
||||
}
|
||||
catch {
|
||||
return JSON.parse(JSON.stringify(fallback)) as T
|
||||
}
|
||||
}
|
||||
|
||||
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const entries = Object.entries(structure).filter(
|
||||
([key]) => key !== 'customFields' && key !== 'products',
|
||||
)
|
||||
return safeClone(Object.fromEntries(entries), {})
|
||||
}
|
||||
|
||||
let uidCounter = 0
|
||||
const createUid = (scope: 'field' | 'product'): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
uidCounter += 1
|
||||
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
|
||||
}
|
||||
|
||||
// --- Hydration ---
|
||||
|
||||
const toEditorField = (
|
||||
input: Partial<PieceModelStructureEditorField> | null | undefined,
|
||||
index: number,
|
||||
): EditorField => {
|
||||
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
|
||||
const optionsText = normalizeLineEndings(
|
||||
typeof input?.optionsText === 'string'
|
||||
? input.optionsText
|
||||
: Array.isArray(input?.options)
|
||||
? input.options.join('\n')
|
||||
: '',
|
||||
)
|
||||
|
||||
return {
|
||||
uid: createUid('field'),
|
||||
name: typeof input?.name === 'string' ? input.name : '',
|
||||
type: baseType as PieceModelCustomFieldType,
|
||||
required: Boolean(input?.required),
|
||||
optionsText,
|
||||
defaultValue:
|
||||
input?.defaultValue !== undefined && input.defaultValue !== null && input.defaultValue !== ''
|
||||
? String(input.defaultValue)
|
||||
: null,
|
||||
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
|
||||
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
|
||||
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
|
||||
const source = ensureArray(structure?.customFields)
|
||||
return source
|
||||
.map((field, index) => toEditorField(field, index))
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.map((field, index) => ({ ...field, orderIndex: index }))
|
||||
}
|
||||
|
||||
const toEditorProduct = (
|
||||
input: Partial<PieceModelProduct> | null | undefined,
|
||||
): EditorProduct => ({
|
||||
uid: createUid('product'),
|
||||
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
|
||||
typeProductLabel:
|
||||
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
|
||||
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
|
||||
})
|
||||
|
||||
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
|
||||
const source = Array.isArray(structure?.products) ? structure?.products : []
|
||||
return source.map(product => toEditorProduct(product))
|
||||
}
|
||||
|
||||
// --- Payload ---
|
||||
|
||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
list.map((field, index) => ({
|
||||
...field,
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
|
||||
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
|
||||
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
|
||||
|
||||
if (!typeProductId && !familyCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: PieceModelProduct = {}
|
||||
if (typeProductId) {
|
||||
payload.typeProductId = typeProductId
|
||||
}
|
||||
if (familyCode) {
|
||||
payload.familyCode = familyCode
|
||||
}
|
||||
if (product.typeProductLabel) {
|
||||
payload.typeProductLabel = product.typeProductLabel
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const buildPayload = (
|
||||
fieldsSource: EditorField[],
|
||||
productsSource: EditorProduct[],
|
||||
restSource: Record<string, unknown>,
|
||||
): PieceModelStructure => {
|
||||
const normalizedFields = fieldsSource
|
||||
.map<PieceModelCustomField | null>((field, index) => {
|
||||
const name = field.name.trim()
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = (field.type || 'text') as PieceModelCustomFieldType
|
||||
const required = Boolean(field.required)
|
||||
const payload: PieceModelCustomField = {
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
orderIndex: index,
|
||||
}
|
||||
|
||||
if (field.id) {
|
||||
payload.id = field.id
|
||||
}
|
||||
if (field.customFieldId) {
|
||||
payload.customFieldId = field.customFieldId
|
||||
}
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
|
||||
payload.defaultValue = String(field.defaultValue)
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
const options = normalizeLineEndings(field.optionsText)
|
||||
.split('\n')
|
||||
.map(option => option.trim())
|
||||
.filter(option => option.length > 0)
|
||||
if (options.length > 0) {
|
||||
payload.options = options
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
})
|
||||
.filter((field): field is PieceModelCustomField => Boolean(field))
|
||||
|
||||
const normalizedProducts = productsSource
|
||||
.map(product => normalizeProductEntry(product))
|
||||
.filter((product): product is PieceModelProduct => Boolean(product))
|
||||
|
||||
const draft: PieceModelStructure = {
|
||||
...safeClone(restSource, {}),
|
||||
products: normalizedProducts,
|
||||
customFields: normalizedFields,
|
||||
}
|
||||
|
||||
return normalizePieceStructureForSave(draft)
|
||||
}
|
||||
|
||||
const serializeStructure = (structure?: PieceModelStructure | null): string => {
|
||||
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
|
||||
}
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function usePieceStructureEditorLogic(deps: Deps) {
|
||||
const { props, emit } = deps
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
|
||||
// --- State ---
|
||||
|
||||
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||
|
||||
|
||||
// --- Product types ---
|
||||
|
||||
const productTypeOptions = computed(() => productTypes.value ?? [])
|
||||
|
||||
const productTypeMap = computed(() => {
|
||||
const map = new Map<string, any>()
|
||||
productTypeOptions.value.forEach((type: any) => {
|
||||
if (type?.id) {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const formatProductTypeOption = (type: any) => {
|
||||
if (!type) {
|
||||
return ''
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (type.code) {
|
||||
parts.push(type.code)
|
||||
}
|
||||
if (type.name) {
|
||||
parts.push(type.name)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : type.id || ''
|
||||
}
|
||||
|
||||
const updateProductTypeMetadata = (product: EditorProduct) => {
|
||||
const option = product.typeProductId
|
||||
? productTypeMap.value.get(product.typeProductId)
|
||||
: null
|
||||
product.typeProductLabel = option?.name ?? ''
|
||||
}
|
||||
|
||||
const handleProductTypeSelect = (product: EditorProduct) => {
|
||||
const option = product.typeProductId
|
||||
? productTypeMap.value.get(product.typeProductId)
|
||||
: null
|
||||
product.typeProductLabel = option?.name ?? ''
|
||||
if (option?.code) {
|
||||
product.familyCode = option.code
|
||||
}
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
const createEmptyProduct = (): EditorProduct => ({
|
||||
uid: createUid('product'),
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
})
|
||||
|
||||
const addProduct = () => {
|
||||
products.value.push(createEmptyProduct())
|
||||
}
|
||||
|
||||
const removeProduct = (index: number) => {
|
||||
products.value = products.value.filter((_, idx) => idx !== index)
|
||||
}
|
||||
|
||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||
uid: createUid('field'),
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
orderIndex,
|
||||
})
|
||||
|
||||
const addField = () => {
|
||||
const next = fields.value.slice()
|
||||
next.push(createEmptyField(next.length))
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
const removeField = (index: number) => {
|
||||
const next = fields.value.filter((_, i) => i !== index)
|
||||
fields.value = applyOrderIndex(next)
|
||||
}
|
||||
|
||||
// --- Drag & drop ---
|
||||
|
||||
const dragState = reactive({
|
||||
draggingIndex: null as number | null,
|
||||
dropTargetIndex: null as number | null,
|
||||
})
|
||||
|
||||
const resetDragState = () => {
|
||||
dragState.draggingIndex = null
|
||||
dragState.dropTargetIndex = null
|
||||
}
|
||||
|
||||
const reorderFields = (from: number, to: number) => {
|
||||
if (from === to) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const list = fields.value.slice()
|
||||
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const [moved] = list.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
list.splice(to, 0, moved)
|
||||
fields.value = applyOrderIndex(list)
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const onDragStart = (index: number, event: DragEvent) => {
|
||||
dragState.draggingIndex = index
|
||||
dragState.dropTargetIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (index: number) => {
|
||||
if (dragState.draggingIndex === null) {
|
||||
return
|
||||
}
|
||||
dragState.dropTargetIndex = index
|
||||
}
|
||||
|
||||
const onDrop = (index: number) => {
|
||||
if (dragState.draggingIndex === null) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
reorderFields(dragState.draggingIndex, index)
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
resetDragState()
|
||||
}
|
||||
|
||||
const reorderClass = (index: number) => {
|
||||
if (dragState.draggingIndex === index) {
|
||||
return 'border-dashed border-primary bg-primary/5'
|
||||
}
|
||||
if (
|
||||
dragState.draggingIndex !== null
|
||||
&& dragState.dropTargetIndex === index
|
||||
&& dragState.draggingIndex !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/10'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// --- Emit ---
|
||||
|
||||
let lastEmitted = serializeStructure(props.modelValue)
|
||||
|
||||
const emitUpdate = () => {
|
||||
const payload = buildPayload(fields.value, products.value, restState.value)
|
||||
const serialized = JSON.stringify(payload)
|
||||
if (serialized !== lastEmitted) {
|
||||
lastEmitted = serialized
|
||||
emit('update:modelValue', payload)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
|
||||
watch(fields, emitUpdate, { deep: true })
|
||||
watch(products, emitUpdate, { deep: true })
|
||||
watch(productTypeOptions, () => {
|
||||
products.value.forEach(product => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const incomingSerialized = serializeStructure(value)
|
||||
if (incomingSerialized === lastEmitted) {
|
||||
return
|
||||
}
|
||||
restState.value = extractRest(value)
|
||||
fields.value = hydrateFields(value)
|
||||
products.value = hydrateProducts(value)
|
||||
products.value.forEach(product => updateProductTypeMetadata(product))
|
||||
lastEmitted = incomingSerialized
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMounted(async () => {
|
||||
if (!productTypeOptions.value.length) {
|
||||
await loadProductTypes()
|
||||
}
|
||||
products.value.forEach(product => updateProductTypeMetadata(product))
|
||||
})
|
||||
|
||||
return {
|
||||
fields,
|
||||
products,
|
||||
productTypeOptions,
|
||||
formatProductTypeOption,
|
||||
handleProductTypeSelect,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addField,
|
||||
removeField,
|
||||
reorderClass,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}
|
||||
}
|
||||
29
frontend/app/composables/usePieceTypes.ts
Normal file
29
frontend/app/composables/usePieceTypes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityTypes.
|
||||
* Preserves the original API surface (renamed fields) so consumers need no changes.
|
||||
*/
|
||||
import { useEntityTypes, type EntityType } from './useEntityTypes'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface PieceType extends EntityType {
|
||||
structure: PieceModelStructure | null
|
||||
}
|
||||
|
||||
export function usePieceTypes() {
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'PIECE',
|
||||
label: 'pièce',
|
||||
})
|
||||
|
||||
return {
|
||||
pieceTypes: types as Ref<PieceType[]>,
|
||||
loadingPieceTypes: loading,
|
||||
loadPieceTypes: loadTypes,
|
||||
createPieceType: createType,
|
||||
updatePieceType: updateType,
|
||||
deletePieceType: deleteType,
|
||||
getPieceTypes: () => types.value as PieceType[],
|
||||
isPieceTypeLoading: () => loading.value,
|
||||
}
|
||||
}
|
||||
290
frontend/app/composables/usePieces.ts
Normal file
290
frontend/app/composables/usePieces.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Piece {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
referenceAuto?: string | null
|
||||
description?: string | null
|
||||
typePieceId?: string | null
|
||||
typePiece?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
productIds?: string[]
|
||||
product?: { id: string; name?: string } | null
|
||||
constructeurs?: Constructeur[]
|
||||
constructeurIds?: string[]
|
||||
documents?: unknown[]
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface PieceListResult {
|
||||
success: boolean
|
||||
data?: { items: Piece[]; total: number; page: number; itemsPerPage: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface PieceSingleResult {
|
||||
success: boolean
|
||||
data?: Piece
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LoadPiecesOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typePieceId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const pieces = ref<Piece[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function usePieces() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (piece: Piece): Promise<Piece> => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return piece
|
||||
}
|
||||
if (!piece.typePieceId) {
|
||||
const typePieceId = extractRelationId(piece.typePiece)
|
||||
if (typePieceId) {
|
||||
piece.typePieceId = typePieceId
|
||||
}
|
||||
}
|
||||
if (!piece.productId) {
|
||||
const productId = extractRelationId(piece.product)
|
||||
if (productId) {
|
||||
piece.productId = productId
|
||||
}
|
||||
}
|
||||
const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
|
||||
if (productIds.length === 0 && piece.productId) {
|
||||
piece.productIds = [piece.productId]
|
||||
} else if (productIds.length > 0) {
|
||||
piece.productIds = productIds.map((id) => String(id))
|
||||
if (!piece.productId) {
|
||||
piece.productId = piece.productIds[0] || null
|
||||
}
|
||||
}
|
||||
const ids = uniqueConstructeurIds(
|
||||
piece.constructeurIds,
|
||||
piece.constructeurs,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
Array.isArray(piece.constructeurs) &&
|
||||
piece.constructeurs.length > 0 &&
|
||||
piece.constructeurs.every((item) => item && typeof item === 'object')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
if (resolved.length) {
|
||||
piece.constructeurs = resolved
|
||||
piece.constructeurIds = ids
|
||||
}
|
||||
}
|
||||
return piece
|
||||
}
|
||||
|
||||
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typePieceId,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
// Only use cache for unfiltered full-catalog loads
|
||||
if (!force && loaded.value && !search && !typeName && !typePieceId && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
// For filtered queries, don't block on global loading state
|
||||
if (!typePieceId && loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('search', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typePiece.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typePieceId) {
|
||||
params.set('typePiece', typePieceId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
// Only update global cache for unfiltered queries
|
||||
if (!typePieceId) {
|
||||
pieces.value = enrichedItems
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
}
|
||||
return result as PieceListResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await post('/pieces', normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Piece)
|
||||
pieces.value.unshift(enriched)
|
||||
total.value += 1
|
||||
const definition = (pieceData as Record<string, unknown>)?.definition as Record<string, unknown> | undefined
|
||||
const displayName =
|
||||
(result.data as Piece)?.name ||
|
||||
(definition?.name as string | undefined) ||
|
||||
pieceData?.name ||
|
||||
'Pièce'
|
||||
showSuccess(`Pièce "${displayName}" créée avec succès`)
|
||||
return { success: true, data: enriched }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création de la pièce:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await patch(`/pieces/${id}`, normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const updated = await withResolvedConstructeurs(result.data as Piece)
|
||||
const index = pieces.value.findIndex((piece) => piece.id === id)
|
||||
if (index !== -1) {
|
||||
pieces.value[index] = updated
|
||||
}
|
||||
showSuccess(`Pièce "${updated?.name || pieceData.name || ''}" mise à jour avec succès`)
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la pièce:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deletePiece = async (id: string): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/pieces/${id}`)
|
||||
if (result.success) {
|
||||
const deletedPiece = pieces.value.find((piece) => piece.id === id)
|
||||
pieces.value = pieces.value.filter((piece) => piece.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
|
||||
return { success: true }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression de la pièce:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getPieces = () => pieces.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
const clearPiecesCache = () => {
|
||||
pieces.value = []
|
||||
total.value = 0
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
pieces,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadPieces,
|
||||
createPiece,
|
||||
updatePiece: updatePieceData,
|
||||
deletePiece,
|
||||
getPieces,
|
||||
isLoading,
|
||||
clearPiecesCache,
|
||||
}
|
||||
}
|
||||
12
frontend/app/composables/useProductHistory.ts
Normal file
12
frontend/app/composables/useProductHistory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityHistory.
|
||||
* Real logic lives in useEntityHistory.ts.
|
||||
*/
|
||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
||||
|
||||
export type ProductHistoryActor = EntityHistoryActor
|
||||
export type ProductHistoryEntry = EntityHistoryEntry
|
||||
|
||||
export function useProductHistory() {
|
||||
return useEntityHistory('product')
|
||||
}
|
||||
27
frontend/app/composables/useProductTypes.ts
Normal file
27
frontend/app/composables/useProductTypes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Backward-compatible wrapper around useEntityTypes.
|
||||
* Preserves the original API surface (renamed fields) so consumers need no changes.
|
||||
*/
|
||||
import { useEntityTypes, type EntityType } from './useEntityTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface ProductType extends EntityType {
|
||||
structure: ProductModelStructure | null
|
||||
}
|
||||
|
||||
export function useProductTypes() {
|
||||
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
|
||||
category: 'PRODUCT',
|
||||
label: 'produit',
|
||||
})
|
||||
|
||||
return {
|
||||
productTypes: types as Ref<ProductType[]>,
|
||||
loadingProductTypes: loading,
|
||||
loadProductTypes: loadTypes,
|
||||
createProductType: createType,
|
||||
updateProductType: updateType,
|
||||
deleteProductType: deleteType,
|
||||
}
|
||||
}
|
||||
324
frontend/app/composables/useProducts.ts
Normal file
324
frontend/app/composables/useProducts.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
typeProductId?: string | null
|
||||
typeProduct?: { id: string; name?: string } | null
|
||||
constructeurs?: Constructeur[]
|
||||
constructeurIds?: string[]
|
||||
supplierPrice?: number | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
documents?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ProductListResult {
|
||||
success: boolean
|
||||
data?: { items: Product[]; total: number; page: number; itemsPerPage: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ProductSingleResult {
|
||||
success: boolean
|
||||
data?: Product
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface LoadProductsOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typeProductId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const products = ref<Product[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const replaceInCache = (item: Product): boolean => {
|
||||
if (!item?.id) {
|
||||
return false
|
||||
}
|
||||
const index = products.value.findIndex((product) => product.id === item.id)
|
||||
if (index === -1) {
|
||||
products.value.unshift(item)
|
||||
return true
|
||||
}
|
||||
const clone = products.value.slice()
|
||||
clone[index] = item
|
||||
products.value = clone
|
||||
return false
|
||||
}
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') {
|
||||
return p.totalItems
|
||||
}
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
||||
return p['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useProducts() {
|
||||
const { showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
|
||||
const withResolvedConstructeurs = async (product: Product): Promise<Product> => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return product
|
||||
}
|
||||
if (!product.typeProductId) {
|
||||
const typeProductId = extractRelationId(product.typeProduct)
|
||||
if (typeProductId) {
|
||||
product.typeProductId = typeProductId
|
||||
}
|
||||
}
|
||||
const ids = uniqueConstructeurIds(
|
||||
product.constructeurIds,
|
||||
product.constructeurs,
|
||||
)
|
||||
const hasResolvedConstructeurs =
|
||||
Array.isArray(product.constructeurs) &&
|
||||
product.constructeurs.length > 0 &&
|
||||
product.constructeurs.every((item) => item && typeof item === 'object')
|
||||
|
||||
if (ids.length && !hasResolvedConstructeurs) {
|
||||
const resolved = await ensureConstructeurs(ids)
|
||||
if (resolved.length) {
|
||||
product.constructeurs = resolved
|
||||
product.constructeurIds = ids
|
||||
}
|
||||
}
|
||||
return product
|
||||
}
|
||||
|
||||
const loadProducts = async (options: LoadProductsOptions = {}): Promise<ProductListResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typeProductId,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && !typeName && !typeProductId && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (!typeProductId && loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('search', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeProduct.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typeProductId) {
|
||||
params.set('typeProduct', typeProductId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
if (!typeProductId) {
|
||||
products.value = enrichedItems
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
}
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(`Impossible de charger les produits: ${result.error}`)
|
||||
}
|
||||
return result as ProductListResult
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement des produits:', err)
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(`Impossible de charger les produits.`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await post('/products', normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
const added = replaceInCache(enriched)
|
||||
if (added) {
|
||||
total.value += 1
|
||||
}
|
||||
return { success: true, data: enriched }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la création du produit:', err)
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError('Impossible de créer le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await patch(`/products/${id}`, normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
replaceInCache(enriched)
|
||||
return { success: true, data: enriched }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError('Impossible de mettre à jour le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProduct = async (id: string): Promise<ProductSingleResult> => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await del(`/products/${id}`)
|
||||
if (result.success) {
|
||||
products.value = products.value.filter((product) => product.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
return { success: true }
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(result.error)
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la suppression du produit:', err)
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError('Impossible de supprimer le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getProduct = async (id: string, options: { force?: boolean } = {}): Promise<ProductSingleResult> => {
|
||||
const shouldForce = !!options.force
|
||||
if (!shouldForce) {
|
||||
const cached = products.value.find((product) => product.id === id)
|
||||
if (cached && Array.isArray(cached.constructeurs) && cached.constructeurs.length > 0) {
|
||||
return { success: true, data: cached }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await get(`/products/${id}`)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Product)
|
||||
replaceInCache(enriched)
|
||||
return { success: true, data: enriched }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement du produit:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
const clearProductsCache = () => {
|
||||
products.value = []
|
||||
total.value = 0
|
||||
loaded.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
products,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
loadProducts,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
getProduct,
|
||||
clearProductsCache,
|
||||
}
|
||||
}
|
||||
78
frontend/app/composables/useProfileSession.ts
Normal file
78
frontend/app/composables/useProfileSession.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useRuntimeConfig } from '#imports'
|
||||
import type { Profile } from './useProfiles'
|
||||
|
||||
const buildUrl = (path: string): string => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = ((config.public.apiBaseUrl as string) || '').replace(/\/$/, '')
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfileSession() {
|
||||
const activeProfile = useState<Profile | null>('profileSession:active', () => null)
|
||||
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
||||
const loading = useState<boolean>('profileSession:loading', () => false)
|
||||
|
||||
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
||||
loading.value = true
|
||||
try {
|
||||
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
} catch (error) {
|
||||
const err = error as { status?: number }
|
||||
if (err?.status === 401) {
|
||||
activeProfile.value = null
|
||||
} else {
|
||||
console.error('Erreur lors du chargement du profil actif', error)
|
||||
activeProfile.value = null
|
||||
}
|
||||
} finally {
|
||||
sessionLoaded.value = true
|
||||
loading.value = false
|
||||
}
|
||||
return activeProfile.value
|
||||
}
|
||||
|
||||
const ensureSession = (): Promise<Profile | null> => {
|
||||
if (!sessionLoaded.value) {
|
||||
return fetchCurrentProfile()
|
||||
}
|
||||
return Promise.resolve(activeProfile.value)
|
||||
}
|
||||
|
||||
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'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
})
|
||||
await fetchCurrentProfile()
|
||||
}
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await $fetch(buildUrl('/session/profile'), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
} finally {
|
||||
activeProfile.value = null
|
||||
sessionLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeProfile,
|
||||
loading,
|
||||
sessionLoaded,
|
||||
ensureSession,
|
||||
fetchCurrentProfile,
|
||||
activateProfile,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
49
frontend/app/composables/useProfiles.ts
Normal file
49
frontend/app/composables/useProfiles.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState, useRuntimeConfig } from '#imports'
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string | null
|
||||
isActive?: boolean
|
||||
hasPassword?: boolean
|
||||
roles?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const buildUrl = (path: string): string => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = (config.public.apiBaseUrl as string)?.replace(/\/$/, '') || ''
|
||||
return `${base}${path}`
|
||||
}
|
||||
|
||||
export function useProfiles() {
|
||||
const profiles = useState<Profile[]>('profiles:list', () => [])
|
||||
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
||||
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
||||
|
||||
const fetchProfiles = async (): Promise<Profile[]> => {
|
||||
loadingProfiles.value = true
|
||||
try {
|
||||
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
profilesLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des profils', error)
|
||||
profiles.value = []
|
||||
profilesLoaded.value = false
|
||||
} finally {
|
||||
loadingProfiles.value = false
|
||||
}
|
||||
return profiles.value
|
||||
}
|
||||
|
||||
return {
|
||||
profiles,
|
||||
loadingProfiles,
|
||||
profilesLoaded,
|
||||
fetchProfiles,
|
||||
}
|
||||
}
|
||||
337
frontend/app/composables/useSiteManagement.ts
Normal file
337
frontend/app/composables/useSiteManagement.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { navigateTo, useRoute } from '#imports'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
type SiteForm = {
|
||||
name: string
|
||||
color: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactAddress: string
|
||||
contactPostalCode: string
|
||||
contactCity: string
|
||||
}
|
||||
|
||||
type SiteDocument = {
|
||||
id: string
|
||||
name?: string
|
||||
filename?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
path?: string
|
||||
fileUrl?: string
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
type SiteWithDocuments = {
|
||||
id: string
|
||||
name?: string
|
||||
color?: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
contactAddress?: string
|
||||
contactPostalCode?: string
|
||||
contactCity?: string
|
||||
documents?: SiteDocument[]
|
||||
machines?: Array<unknown>
|
||||
}
|
||||
|
||||
export function useSiteManagement() {
|
||||
const route = useRoute()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { sites, loading, loadSites, createSite, updateSite, deleteSite } = useSites()
|
||||
const { uploadDocuments, deleteDocument, loadDocumentsBySite } = useDocuments()
|
||||
const { confirm: confirmDialog } = useConfirm()
|
||||
|
||||
const showAddSiteModal = ref(false)
|
||||
const showEditSiteModal = ref(false)
|
||||
|
||||
const siteBeingEdited = ref<SiteWithDocuments | null>(null)
|
||||
|
||||
const newSite = reactive<SiteForm>({
|
||||
name: '',
|
||||
color: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactAddress: '',
|
||||
contactPostalCode: '',
|
||||
contactCity: ''
|
||||
})
|
||||
|
||||
const editSiteForm = reactive<SiteForm>({
|
||||
name: '',
|
||||
color: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactAddress: '',
|
||||
contactPostalCode: '',
|
||||
contactCity: ''
|
||||
})
|
||||
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const previewDocument = ref<SiteDocument | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const siteDocuments = computed(() => siteBeingEdited.value?.documents || [])
|
||||
const documentIcon = (doc: SiteDocument) =>
|
||||
getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
|
||||
const resetNewSite = () => {
|
||||
newSite.name = ''
|
||||
newSite.color = ''
|
||||
newSite.contactName = ''
|
||||
newSite.contactPhone = ''
|
||||
newSite.contactAddress = ''
|
||||
newSite.contactPostalCode = ''
|
||||
newSite.contactCity = ''
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showAddSiteModal.value = false
|
||||
resetNewSite()
|
||||
}
|
||||
|
||||
const openCreateSiteModal = () => {
|
||||
resetNewSite()
|
||||
showAddSiteModal.value = true
|
||||
}
|
||||
|
||||
const handleCreateSite = async () => {
|
||||
const result = await createSite({
|
||||
name: newSite.name,
|
||||
color: newSite.color,
|
||||
contactName: newSite.contactName,
|
||||
contactPhone: newSite.contactPhone,
|
||||
contactAddress: newSite.contactAddress,
|
||||
contactPostalCode: newSite.contactPostalCode,
|
||||
contactCity: newSite.contactCity
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
closeCreateModal()
|
||||
}
|
||||
}
|
||||
|
||||
const editSite = (site: SiteWithDocuments) => {
|
||||
siteBeingEdited.value = site
|
||||
editSiteForm.name = site.name || ''
|
||||
editSiteForm.color = site.color || ''
|
||||
editSiteForm.contactName = site.contactName || ''
|
||||
editSiteForm.contactPhone = site.contactPhone || ''
|
||||
editSiteForm.contactAddress = site.contactAddress || ''
|
||||
editSiteForm.contactPostalCode = site.contactPostalCode || ''
|
||||
editSiteForm.contactCity = site.contactCity || ''
|
||||
selectedFiles.value = []
|
||||
refreshSiteDocuments(site.id)
|
||||
showEditSiteModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditSiteModal.value = false
|
||||
siteBeingEdited.value = null
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
const updateSiteInCollection = (id: string, updated: Partial<SiteWithDocuments>) => {
|
||||
const index = sites.value.findIndex(site => site.id === id)
|
||||
if (index !== -1) {
|
||||
sites.value[index] = {
|
||||
...sites.value[index],
|
||||
...updated,
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSite = async () => {
|
||||
if (!siteBeingEdited.value) return
|
||||
|
||||
const baseUpdate = {
|
||||
name: editSiteForm.name,
|
||||
color: editSiteForm.color,
|
||||
contactName: editSiteForm.contactName,
|
||||
contactPhone: editSiteForm.contactPhone,
|
||||
contactAddress: editSiteForm.contactAddress,
|
||||
contactPostalCode: editSiteForm.contactPostalCode,
|
||||
contactCity: editSiteForm.contactCity
|
||||
}
|
||||
|
||||
const result = await updateSite(siteBeingEdited.value.id, baseUpdate)
|
||||
|
||||
if (!result.success) return
|
||||
|
||||
let uploadedDocuments: SiteDocument[] = []
|
||||
const existingDocuments = siteBeingEdited.value?.documents || []
|
||||
if (selectedFiles.value.length) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
{
|
||||
files: selectedFiles.value,
|
||||
context: { siteId: siteBeingEdited.value.id }
|
||||
},
|
||||
{ updateStore: false }
|
||||
)
|
||||
uploadingDocuments.value = false
|
||||
|
||||
if (uploadResult.success && uploadResult.data) {
|
||||
const data = uploadResult.data
|
||||
uploadedDocuments = (Array.isArray(data) ? data : [data]) as SiteDocument[]
|
||||
selectedFiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedDocuments.length) {
|
||||
const mergedDocuments = [...uploadedDocuments, ...existingDocuments]
|
||||
if (siteBeingEdited.value) {
|
||||
siteBeingEdited.value.documents = mergedDocuments
|
||||
}
|
||||
updateSiteInCollection(siteBeingEdited.value.id, {
|
||||
...baseUpdate,
|
||||
documents: mergedDocuments
|
||||
})
|
||||
} else {
|
||||
updateSiteInCollection(siteBeingEdited.value.id, baseUpdate)
|
||||
}
|
||||
|
||||
closeEditModal()
|
||||
}
|
||||
|
||||
const handleRemoveSiteDocument = async (documentId: string) => {
|
||||
if (!documentId) return
|
||||
|
||||
const result = await deleteDocument(documentId, { updateStore: false })
|
||||
if (!result.success) return
|
||||
|
||||
if (siteBeingEdited.value) {
|
||||
siteBeingEdited.value.documents = (siteBeingEdited.value.documents || []).filter(
|
||||
doc => doc.id !== documentId
|
||||
)
|
||||
updateSiteInCollection(siteBeingEdited.value.id, {
|
||||
documents: siteBeingEdited.value.documents
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const downloadDocument = (doc: SiteDocument) => {
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
const url = doc?.fileUrl || doc?.path
|
||||
if (!url) return
|
||||
|
||||
if (url.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc: SiteDocument) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const refreshSiteDocuments = async (siteId: string) => {
|
||||
if (!siteId) return
|
||||
const result = await loadDocumentsBySite(siteId, { updateStore: false })
|
||||
if (result.success && siteBeingEdited.value && siteBeingEdited.value.id === siteId) {
|
||||
const cloned = Array.isArray(result.data) ? [...result.data] : []
|
||||
siteBeingEdited.value.documents = cloned
|
||||
updateSiteInCollection(siteId, { documents: cloned })
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (size?: number | null) => {
|
||||
if (size === undefined || size === null) return '—'
|
||||
if (size === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const confirmDeleteSite = async (site: SiteWithDocuments) => {
|
||||
if (
|
||||
!await confirmDialog({
|
||||
message: `Êtes-vous sûr de vouloir supprimer le site "${site.name}" ? Cette action est irréversible.`,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deleteSite(site.id)
|
||||
if (result.success) {
|
||||
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
||||
} else {
|
||||
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
showError(`Impossible de supprimer le site : ${humanizeError(error.message)}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.add,
|
||||
async (shouldOpen) => {
|
||||
if (shouldOpen === 'true') {
|
||||
openCreateSiteModal()
|
||||
await navigateTo('/sites', { replace: true })
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
sites,
|
||||
loading,
|
||||
showAddSiteModal,
|
||||
showEditSiteModal,
|
||||
siteBeingEdited,
|
||||
newSite,
|
||||
editSiteForm,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
siteDocuments,
|
||||
documentIcon,
|
||||
openCreateSiteModal,
|
||||
closeCreateModal,
|
||||
handleCreateSite,
|
||||
editSite,
|
||||
handleUpdateSite,
|
||||
closeEditModal,
|
||||
handleRemoveSiteDocument,
|
||||
downloadDocument,
|
||||
openPreview,
|
||||
closePreview,
|
||||
refreshSiteDocuments,
|
||||
formatSize,
|
||||
confirmDeleteSite,
|
||||
canPreviewDocument
|
||||
}
|
||||
}
|
||||
124
frontend/app/composables/useSites.ts
Normal file
124
frontend/app/composables/useSites.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
name?: string
|
||||
color?: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
contactAddress?: string
|
||||
contactPostalCode?: string
|
||||
contactCity?: string
|
||||
machines?: unknown[]
|
||||
documents?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface SiteResult {
|
||||
success: boolean
|
||||
data?: Site
|
||||
error?: string
|
||||
}
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSites() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
|
||||
if (!options.force && loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/sites')
|
||||
if (result.success) {
|
||||
const collection = extractCollection(result.data)
|
||||
sites.value = collection
|
||||
loaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sites:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createSite = async (siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/sites', siteData)
|
||||
if (result.success && result.data) {
|
||||
sites.value.push(result.data as Site)
|
||||
showSuccess(`Site "${siteData.name}" créé avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSite = async (id: string, siteData: Partial<Site>): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/sites/${id}`, siteData)
|
||||
if (result.success && result.data) {
|
||||
const index = sites.value.findIndex((site) => site.id === id)
|
||||
if (index !== -1) {
|
||||
sites.value[index] = result.data as Site
|
||||
}
|
||||
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSite = async (id: string): Promise<SiteResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/sites/${id}`)
|
||||
if (result.success) {
|
||||
const deletedSite = sites.value.find((site) => site.id === id)
|
||||
sites.value = sites.value.filter((site) => site.id !== id)
|
||||
showSuccess(`Site "${deletedSite?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result as SiteResult
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du site:', error)
|
||||
return { success: false, error: (error as Error).message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSiteById = (id: string): Site | undefined => {
|
||||
return sites.value.find((site) => site.id === id)
|
||||
}
|
||||
|
||||
const getSites = () => sites.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
return {
|
||||
sites,
|
||||
loading,
|
||||
loadSites,
|
||||
createSite,
|
||||
updateSite,
|
||||
deleteSite,
|
||||
getSiteById,
|
||||
getSites,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
380
frontend/app/composables/useStructureAssignmentFetch.ts
Normal file
380
frontend/app/composables/useStructureAssignmentFetch.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import {
|
||||
componentOptionDescription,
|
||||
componentOptionLabel,
|
||||
describePieceRequirement as _describePieceRequirement,
|
||||
describeProductRequirement as _describeProductRequirement,
|
||||
pieceOptionDescription,
|
||||
pieceOptionLabel,
|
||||
productOptionDescription,
|
||||
productOptionLabel,
|
||||
} from '~/shared/utils/structureAssignmentLabels'
|
||||
import type {
|
||||
ComponentOption,
|
||||
PieceOption,
|
||||
ProductOption,
|
||||
StructureAssignmentNode,
|
||||
StructurePieceAssignment,
|
||||
StructureProductAssignment,
|
||||
} from '~/shared/utils/structureAssignmentLabels'
|
||||
|
||||
export type {
|
||||
ComponentOption,
|
||||
PieceOption,
|
||||
ProductOption,
|
||||
StructureAssignmentNode,
|
||||
StructurePieceAssignment,
|
||||
StructureProductAssignment,
|
||||
} from '~/shared/utils/structureAssignmentLabels'
|
||||
|
||||
export interface StructureAssignmentFetchDeps {
|
||||
assignment: StructureAssignmentNode
|
||||
pieces: PieceOption[] | null
|
||||
products: ProductOption[] | null
|
||||
components: ComponentOption[] | null
|
||||
isRoot: () => boolean
|
||||
pieceTypeLabelMap: Record<string, string>
|
||||
productTypeLabelMap: Record<string, string>
|
||||
componentTypeLabelMap: Record<string, string>
|
||||
}
|
||||
|
||||
export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps) {
|
||||
const { get } = useApi()
|
||||
|
||||
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({})
|
||||
const productOptionsByPath = ref<Record<string, ProductOption[]>>({})
|
||||
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({})
|
||||
const pieceLoadingByPath = ref<Record<string, boolean>>({})
|
||||
const productLoadingByPath = ref<Record<string, boolean>>({})
|
||||
const componentLoadingByPath = ref<Record<string, boolean>>({})
|
||||
|
||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
||||
target[key] = value
|
||||
}
|
||||
|
||||
const typeIri = (id: string) => `/api/model_types/${id}`
|
||||
const primedPiecePaths = new Set<string>()
|
||||
const primedProductPaths = new Set<string>()
|
||||
const primedComponentPaths = new Set<string>()
|
||||
|
||||
// --- Component options ---
|
||||
|
||||
const componentOptions = computed(() => {
|
||||
if (deps.isRoot()) {
|
||||
return []
|
||||
}
|
||||
const cached = componentOptionsByPath.value[deps.assignment.path]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const definition = deps.assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || null
|
||||
const requiredFamilyCode = definition.familyCode || null
|
||||
|
||||
return (deps.components || []).filter((component) => {
|
||||
if (!component || typeof component !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
return component.typeComposantId === requiredTypeId
|
||||
}
|
||||
if (requiredFamilyCode) {
|
||||
return (
|
||||
component.typeComposant?.code === requiredFamilyCode
|
||||
|| component.typeComposantId === requiredFamilyCode
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const fetchComponentOptions = async (term = '') => {
|
||||
if (deps.isRoot()) {
|
||||
return
|
||||
}
|
||||
const key = deps.assignment.path
|
||||
if (componentLoadingByPath.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = deps.assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '200')
|
||||
if (term.trim()) {
|
||||
params.set('search', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeComposant', typeIri(requiredTypeId))
|
||||
}
|
||||
|
||||
setLoading(componentLoadingByPath.value, key, true)
|
||||
try {
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
if (result.success) {
|
||||
componentOptionsByPath.value[key] = extractCollection(result.data)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(componentLoadingByPath.value, key, false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Piece options ---
|
||||
|
||||
const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
const cached = pieceOptionsByPath.value[assignment.path]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const definition = assignment.definition
|
||||
const requiredTypeId =
|
||||
definition.typePieceId
|
||||
|| definition.typePiece?.id
|
||||
|| definition.familyCode
|
||||
|| null
|
||||
|
||||
return (deps.pieces || []).filter((piece) => {
|
||||
if (!piece || typeof piece !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (!requiredTypeId) {
|
||||
return true
|
||||
}
|
||||
if (definition.typePieceId || definition.typePiece?.id) {
|
||||
return (
|
||||
piece.typePieceId === requiredTypeId
|
||||
|| piece.typePiece?.id === requiredTypeId
|
||||
)
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return (
|
||||
piece.typePiece?.code === requiredTypeId
|
||||
|| piece.typePieceId === requiredTypeId
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
|
||||
const key = assignment.path
|
||||
if (pieceLoadingByPath.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typePieceId || definition.typePiece?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '200')
|
||||
if (term.trim()) {
|
||||
params.set('search', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typePiece', typeIri(requiredTypeId))
|
||||
}
|
||||
|
||||
setLoading(pieceLoadingByPath.value, key, true)
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
pieceOptionsByPath.value[key] = extractCollection(result.data)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(pieceLoadingByPath.value, key, false)
|
||||
}
|
||||
}
|
||||
|
||||
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||
const options = getPieceOptions(assignment)
|
||||
return _describePieceRequirement(assignment, options, deps.pieceTypeLabelMap)
|
||||
}
|
||||
|
||||
// --- Product options ---
|
||||
|
||||
const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
const cached = productOptionsByPath.value[assignment.path]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const definition = assignment.definition
|
||||
const requiredTypeId =
|
||||
definition.typeProductId
|
||||
|| definition.typeProduct?.id
|
||||
|| definition.familyCode
|
||||
|| null
|
||||
|
||||
return (deps.products || []).filter((product) => {
|
||||
if (!product || typeof product !== 'object') {
|
||||
return false
|
||||
}
|
||||
if (!requiredTypeId) {
|
||||
return true
|
||||
}
|
||||
if (definition.typeProductId || definition.typeProduct?.id) {
|
||||
return (
|
||||
product.typeProductId === requiredTypeId
|
||||
|| product.typeProduct?.id === requiredTypeId
|
||||
)
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return (
|
||||
product.typeProduct?.code === requiredTypeId
|
||||
|| product.typeProductId === requiredTypeId
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
|
||||
const key = assignment.path
|
||||
if (productLoadingByPath.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {}
|
||||
const requiredTypeId =
|
||||
definition.typeProductId || definition.typeProduct?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '200')
|
||||
if (term.trim()) {
|
||||
params.set('search', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeProduct', typeIri(requiredTypeId))
|
||||
}
|
||||
|
||||
setLoading(productLoadingByPath.value, key, true)
|
||||
try {
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
if (result.success) {
|
||||
productOptionsByPath.value[key] = extractCollection(result.data)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(productLoadingByPath.value, key, false)
|
||||
}
|
||||
}
|
||||
|
||||
const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||
const options = getProductOptions(assignment)
|
||||
return _describeProductRequirement(assignment, options, deps.productTypeLabelMap)
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
|
||||
watch(
|
||||
componentOptions,
|
||||
(options) => {
|
||||
if (deps.isRoot()) {
|
||||
return
|
||||
}
|
||||
// Only clear if we have loaded options (cache or catalog); skip when options are empty
|
||||
// because the fetch may not have completed yet.
|
||||
if (!options.length) {
|
||||
return
|
||||
}
|
||||
const hasMatch = options.some(
|
||||
(component) => component.id === deps.assignment.selectedComponentId,
|
||||
)
|
||||
if (!hasMatch) {
|
||||
deps.assignment.selectedComponentId = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [deps.pieces, deps.assignment.pieces],
|
||||
() => {
|
||||
for (const pieceAssignment of deps.assignment.pieces) {
|
||||
const hasCachedOptions = !!pieceOptionsByPath.value[pieceAssignment.path]
|
||||
// Only clear selections when we have loaded options (cached or from catalog).
|
||||
// When no cache exists, a fetch is about to fire — clearing now would lose
|
||||
// user input before the real option list arrives.
|
||||
if (hasCachedOptions) {
|
||||
const options = getPieceOptions(pieceAssignment)
|
||||
if (
|
||||
pieceAssignment.selectedPieceId
|
||||
&& !options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
||||
) {
|
||||
pieceAssignment.selectedPieceId = ''
|
||||
}
|
||||
}
|
||||
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
|
||||
primedPiecePaths.add(pieceAssignment.path)
|
||||
fetchPieceOptions(pieceAssignment).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [deps.products, deps.assignment.products],
|
||||
() => {
|
||||
for (const productAssignment of deps.assignment.products) {
|
||||
const hasCachedOptions = !!productOptionsByPath.value[productAssignment.path]
|
||||
if (hasCachedOptions) {
|
||||
const options = getProductOptions(productAssignment)
|
||||
if (
|
||||
productAssignment.selectedProductId
|
||||
&& !options.some((product) => product.id === productAssignment.selectedProductId)
|
||||
) {
|
||||
productAssignment.selectedProductId = ''
|
||||
}
|
||||
}
|
||||
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
|
||||
primedProductPaths.add(productAssignment.path)
|
||||
fetchProductOptions(productAssignment).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => deps.assignment.definition,
|
||||
() => {
|
||||
if (deps.isRoot()) {
|
||||
return
|
||||
}
|
||||
const key = deps.assignment.path
|
||||
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
|
||||
primedComponentPaths.add(key)
|
||||
fetchComponentOptions().catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
pieceLoadingByPath,
|
||||
productLoadingByPath,
|
||||
componentLoadingByPath,
|
||||
componentOptions,
|
||||
componentOptionLabel,
|
||||
componentOptionDescription,
|
||||
fetchComponentOptions,
|
||||
getPieceOptions,
|
||||
pieceOptionLabel,
|
||||
pieceOptionDescription,
|
||||
fetchPieceOptions,
|
||||
describePieceRequirement,
|
||||
getProductOptions,
|
||||
productOptionLabel,
|
||||
productOptionDescription,
|
||||
fetchProductOptions,
|
||||
describeProductRequirement,
|
||||
}
|
||||
}
|
||||
161
frontend/app/composables/useStructureNodeCrud.ts
Normal file
161
frontend/app/composables/useStructureNodeCrud.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
|
||||
|
||||
export interface StructureNodeCrudDeps {
|
||||
node: EditableStructureNode
|
||||
canManageSubcomponents: () => boolean
|
||||
}
|
||||
|
||||
export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
|
||||
// --- Helpers ---
|
||||
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
|
||||
if (!Array.isArray((props.node as any)[key])) {
|
||||
if (key === 'subcomponents') {
|
||||
props.node.subcomponents = []
|
||||
} else if (key === 'products') {
|
||||
props.node.products = []
|
||||
} else {
|
||||
(props.node as any)[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom field reindex ---
|
||||
const reindexCustomFields = () => {
|
||||
if (!Array.isArray(props.node.customFields)) {
|
||||
return
|
||||
}
|
||||
props.node.customFields.forEach((field: any, index: number) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
field.orderIndex = index
|
||||
})
|
||||
}
|
||||
|
||||
// --- Drag reorder ---
|
||||
const customFieldDrag = useDragReorder(
|
||||
() => props.node.customFields,
|
||||
{ onReorder: reindexCustomFields },
|
||||
)
|
||||
|
||||
const pieceDrag = useDragReorder(() => props.node.pieces)
|
||||
const productDrag = useDragReorder(() => props.node.products)
|
||||
const subcomponentDrag = useDragReorder(
|
||||
() => props.node.subcomponents,
|
||||
{ draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
|
||||
)
|
||||
|
||||
// --- CRUD functions ---
|
||||
const addCustomField = () => {
|
||||
ensureArray('customFields')
|
||||
const fields = props.node.customFields!
|
||||
const nextIndex = fields.length
|
||||
fields.push({
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
options: [],
|
||||
orderIndex: nextIndex,
|
||||
})
|
||||
reindexCustomFields()
|
||||
}
|
||||
|
||||
const removeCustomField = (index: number) => {
|
||||
if (!Array.isArray(props.node.customFields)) return
|
||||
props.node.customFields.splice(index, 1)
|
||||
reindexCustomFields()
|
||||
}
|
||||
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
familyCode: '',
|
||||
role: '',
|
||||
quantity: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const removePiece = (index: number) => {
|
||||
if (!Array.isArray(props.node.pieces)) return
|
||||
props.node.pieces.splice(index, 1)
|
||||
}
|
||||
|
||||
const addProduct = () => {
|
||||
ensureArray('products')
|
||||
props.node.products!.push({
|
||||
typeProductId: '',
|
||||
typeProductLabel: '',
|
||||
familyCode: '',
|
||||
})
|
||||
}
|
||||
|
||||
const removeProduct = (index: number) => {
|
||||
if (!Array.isArray(props.node.products)) return
|
||||
props.node.products.splice(index, 1)
|
||||
}
|
||||
|
||||
const addSubComponent = () => {
|
||||
if (!props.canManageSubcomponents()) {
|
||||
return
|
||||
}
|
||||
ensureArray('subcomponents')
|
||||
props.node.subcomponents.push({
|
||||
typeComposantId: '',
|
||||
typeComposantLabel: '',
|
||||
modelId: '',
|
||||
familyCode: '',
|
||||
alias: '',
|
||||
subcomponents: [],
|
||||
})
|
||||
}
|
||||
|
||||
const removeSubComponent = (index: number) => {
|
||||
if (!Array.isArray(props.node.subcomponents)) return
|
||||
props.node.subcomponents.splice(index, 1)
|
||||
}
|
||||
|
||||
return {
|
||||
// Helpers exposed for watchers
|
||||
reindexCustomFields,
|
||||
// CRUD
|
||||
addCustomField,
|
||||
removeCustomField,
|
||||
addPiece,
|
||||
removePiece,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addSubComponent,
|
||||
removeSubComponent,
|
||||
// Drag reorder — custom fields
|
||||
onCustomFieldDragStart: customFieldDrag.onDragStart,
|
||||
onCustomFieldDragEnter: customFieldDrag.onDragEnter,
|
||||
onCustomFieldDrop: customFieldDrag.onDrop,
|
||||
onCustomFieldDragEnd: customFieldDrag.onDragEnd,
|
||||
customFieldReorderClass: customFieldDrag.reorderClass,
|
||||
// Drag reorder — pieces
|
||||
onPieceDragStart: pieceDrag.onDragStart,
|
||||
onPieceDragEnter: pieceDrag.onDragEnter,
|
||||
onPieceDragOver: pieceDrag.onDragOver,
|
||||
onPieceDrop: pieceDrag.onDrop,
|
||||
onPieceDragEnd: pieceDrag.onDragEnd,
|
||||
pieceReorderClass: pieceDrag.reorderClass,
|
||||
// Drag reorder — products
|
||||
onProductDragStart: productDrag.onDragStart,
|
||||
onProductDragEnter: productDrag.onDragEnter,
|
||||
onProductDragOver: productDrag.onDragOver,
|
||||
onProductDrop: productDrag.onDrop,
|
||||
onProductDragEnd: productDrag.onDragEnd,
|
||||
productReorderClass: productDrag.reorderClass,
|
||||
// Drag reorder — subcomponents
|
||||
onSubcomponentDragStart: subcomponentDrag.onDragStart,
|
||||
onSubcomponentDragEnter: subcomponentDrag.onDragEnter,
|
||||
onSubcomponentDragOver: subcomponentDrag.onDragOver,
|
||||
onSubcomponentDrop: subcomponentDrag.onDrop,
|
||||
onSubcomponentDragEnd: subcomponentDrag.onDragEnd,
|
||||
subcomponentReorderClass: subcomponentDrag.reorderClass,
|
||||
}
|
||||
}
|
||||
453
frontend/app/composables/useStructureNodeLogic.ts
Normal file
453
frontend/app/composables/useStructureNodeLogic.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
|
||||
import { useStructureNodeCrud } from '~/composables/useStructureNodeCrud'
|
||||
|
||||
export type ModelTypeOption = {
|
||||
id: string
|
||||
name: string
|
||||
code?: string | null
|
||||
}
|
||||
|
||||
export type EditableStructureNode = ComponentModelStructureNode & {
|
||||
customFields?: any[]
|
||||
pieces?: ComponentModelPiece[]
|
||||
products?: ComponentModelProduct[]
|
||||
}
|
||||
|
||||
export interface StructureNodeLogicDeps {
|
||||
node: EditableStructureNode
|
||||
depth: number
|
||||
componentTypes: ModelTypeOption[]
|
||||
pieceTypes: ModelTypeOption[]
|
||||
productTypes: ModelTypeOption[]
|
||||
isRoot: boolean
|
||||
lockType: boolean
|
||||
lockedTypeLabel: string
|
||||
allowSubcomponents: boolean
|
||||
maxSubcomponentDepth: number
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
|
||||
// --- Computed props ---
|
||||
const isLocked = computed(() => props.isLocked === true)
|
||||
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const productTypes = computed(() => props.productTypes ?? [])
|
||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||
const maxSubcomponentDepth = computed(() =>
|
||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||
)
|
||||
const currentDepth = computed(() => Math.max(0, props.depth ?? 0))
|
||||
const canManageSubcomponents = computed(
|
||||
() => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value,
|
||||
)
|
||||
const childAllowSubcomponents = computed(
|
||||
() => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value,
|
||||
)
|
||||
const hasSubcomponents = computed(
|
||||
() => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0,
|
||||
)
|
||||
|
||||
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
|
||||
const containerClass = computed(() => {
|
||||
const level = currentDepth.value
|
||||
const index = Math.min(level, depthClasses.length - 1)
|
||||
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
|
||||
})
|
||||
|
||||
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
|
||||
|
||||
// --- Type maps ---
|
||||
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
type?.name ?? ''
|
||||
|
||||
const componentTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
componentTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const componentTypeCodeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
componentTypes.value.forEach((type) => {
|
||||
const code = typeof type?.code === 'string' ? type.code.trim() : ''
|
||||
if (code) {
|
||||
map.set(code, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
pieceTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const productTypeMap = computed(() => {
|
||||
const map = new Map<string, ModelTypeOption>()
|
||||
productTypes.value.forEach((type) => {
|
||||
if (type && typeof type.id === 'string') {
|
||||
map.set(type.id, type)
|
||||
}
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// --- Label getters ---
|
||||
const getComponentTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(componentTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const getPieceTypeLabel = (id?: string) => {
|
||||
if (!id) return ''
|
||||
return formatModelTypeOption(pieceTypeMap.value.get(id))
|
||||
}
|
||||
|
||||
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||
formatModelTypeOption(type)
|
||||
|
||||
const lockedTypeDisplay = computed(() => {
|
||||
if (props.lockedTypeLabel) {
|
||||
return props.lockedTypeLabel
|
||||
}
|
||||
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
|
||||
})
|
||||
|
||||
// --- Sync functions ---
|
||||
const syncComponentType = (component: EditableStructureNode) => {
|
||||
if (!component) {
|
||||
return
|
||||
}
|
||||
if (props.isRoot) {
|
||||
component.typeComposantId = ''
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
if (component.alias) {
|
||||
component.alias = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
const id = typeof component.typeComposantId === 'string'
|
||||
? component.typeComposantId
|
||||
: ''
|
||||
|
||||
if (!id) {
|
||||
const code =
|
||||
typeof component.familyCode === 'string' && component.familyCode
|
||||
? component.familyCode
|
||||
: ''
|
||||
if (code) {
|
||||
const codeMatch = componentTypeCodeMap.value.get(code)
|
||||
if (codeMatch?.id) {
|
||||
component.typeComposantId = codeMatch.id
|
||||
component.typeComposantLabel = formatModelTypeOption(codeMatch)
|
||||
component.familyCode = codeMatch.code ?? component.familyCode
|
||||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||||
component.alias = codeMatch.name || component.typeComposantLabel
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
return
|
||||
}
|
||||
|
||||
const option = componentTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
component.typeComposantLabel = ''
|
||||
component.familyCode = ''
|
||||
return
|
||||
}
|
||||
|
||||
component.typeComposantLabel = formatModelTypeOption(option)
|
||||
component.familyCode = option.code ?? component.familyCode
|
||||
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
|
||||
component.alias = option.name || component.typeComposantLabel
|
||||
}
|
||||
}
|
||||
|
||||
const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
|
||||
if (!piece) return
|
||||
|
||||
if (piece.typePieceId) {
|
||||
const option = pieceTypeMap.value.get(piece.typePieceId)
|
||||
if (option) {
|
||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (piece.typePieceLabel) {
|
||||
const normalized = piece.typePieceLabel.trim().toLowerCase()
|
||||
if (normalized) {
|
||||
const match = pieceTypes.value.find((type) => {
|
||||
const formatted = formatPieceTypeOption(type).toLowerCase()
|
||||
const name = (type?.name ?? '').toLowerCase()
|
||||
const code = (type?.code ?? '').toLowerCase()
|
||||
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||
})
|
||||
if (match) {
|
||||
piece.typePieceId = match.id
|
||||
piece.typePieceLabel = formatPieceTypeOption(match)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
|
||||
if (!product) return
|
||||
|
||||
if (product.typeProductId) {
|
||||
const option = productTypeMap.value.get(product.typeProductId)
|
||||
if (option) {
|
||||
product.typeProductLabel = formatProductTypeOption(option)
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (product.typeProductLabel) {
|
||||
const normalized = product.typeProductLabel.trim().toLowerCase()
|
||||
if (normalized) {
|
||||
const match = productTypes.value.find((type) => {
|
||||
const formatted = formatProductTypeOption(type).toLowerCase()
|
||||
const name = (type?.name ?? '').toLowerCase()
|
||||
const code = (type?.code ?? '').toLowerCase()
|
||||
return formatted === normalized || name === normalized || (!!code && code === normalized)
|
||||
})
|
||||
if (match) {
|
||||
product.typeProductId = match.id
|
||||
product.typeProductLabel = formatProductTypeOption(match)
|
||||
product.familyCode = match.code ?? product.familyCode ?? ''
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const syncPieceLabels = (pieces?: any[]) => {
|
||||
if (!Array.isArray(pieces)) {
|
||||
return
|
||||
}
|
||||
pieces.forEach((piece) => {
|
||||
updatePieceTypeLabel(piece)
|
||||
})
|
||||
}
|
||||
|
||||
const syncProductLabels = (products?: any[]) => {
|
||||
if (!Array.isArray(products)) {
|
||||
return
|
||||
}
|
||||
products.forEach((product) => {
|
||||
updateProductTypeLabel(product)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Handler functions ---
|
||||
const handleComponentTypeSelect = (component: any) => {
|
||||
syncComponentType(component)
|
||||
}
|
||||
|
||||
const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
|
||||
if (!piece) {
|
||||
return
|
||||
}
|
||||
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
|
||||
if (!id) {
|
||||
piece.typePieceLabel = ''
|
||||
return
|
||||
}
|
||||
const option = pieceTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
piece.typePieceId = ''
|
||||
piece.typePieceLabel = ''
|
||||
return
|
||||
}
|
||||
piece.typePieceLabel = formatPieceTypeOption(option)
|
||||
}
|
||||
|
||||
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
|
||||
if (!product) {
|
||||
return
|
||||
}
|
||||
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
|
||||
if (!id) {
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
const option = productTypeMap.value.get(id)
|
||||
if (!option) {
|
||||
product.typeProductId = ''
|
||||
product.typeProductLabel = ''
|
||||
return
|
||||
}
|
||||
product.typeProductLabel = formatProductTypeOption(option)
|
||||
product.familyCode = option.code ?? product.familyCode ?? ''
|
||||
}
|
||||
|
||||
// --- CRUD & Lock (delegated to useStructureNodeCrud) ---
|
||||
const crud = useStructureNodeCrud({
|
||||
node: props.node,
|
||||
canManageSubcomponents: () => canManageSubcomponents.value,
|
||||
})
|
||||
|
||||
// --- Watchers ---
|
||||
watch(
|
||||
canManageSubcomponents,
|
||||
(allowed) => {
|
||||
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
|
||||
props.node.subcomponents.splice(0, props.node.subcomponents.length)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(componentTypes, () => {
|
||||
syncComponentType(props.node)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.typeComposantId,
|
||||
() => {
|
||||
syncComponentType(props.node)
|
||||
},
|
||||
)
|
||||
|
||||
watch(pieceTypes, () => {
|
||||
syncPieceLabels(props.node?.pieces)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.pieces,
|
||||
(value) => {
|
||||
syncPieceLabels(value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(productTypes, () => {
|
||||
syncProductLabels(props.node?.products)
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
watch(
|
||||
() => props.node.products,
|
||||
(value) => {
|
||||
syncProductLabels(value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.node.customFields,
|
||||
(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return
|
||||
}
|
||||
value.sort((a: any, b: any) => {
|
||||
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||||
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||||
return left - right
|
||||
})
|
||||
crud.reindexCustomFields()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.lockedTypeLabel, props.lockType],
|
||||
() => {
|
||||
if (props.lockType && props.isRoot) {
|
||||
const label = props.lockedTypeLabel || lockedTypeDisplay.value
|
||||
props.node.typeComposantLabel = label
|
||||
if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
|
||||
props.node.alias = label
|
||||
}
|
||||
if (props.node.typeComposantId) {
|
||||
const option = componentTypeMap.value.get(props.node.typeComposantId)
|
||||
props.node.familyCode = option?.code ?? props.node.familyCode
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
// Computed state
|
||||
isLocked,
|
||||
componentTypes,
|
||||
pieceTypes,
|
||||
productTypes,
|
||||
canManageSubcomponents,
|
||||
childAllowSubcomponents,
|
||||
hasSubcomponents,
|
||||
containerClass,
|
||||
headingClass,
|
||||
lockedTypeDisplay,
|
||||
// Label getters & formatters
|
||||
getComponentTypeLabel,
|
||||
getPieceTypeLabel,
|
||||
formatComponentTypeOption,
|
||||
formatPieceTypeOption,
|
||||
formatProductTypeOption,
|
||||
// Handlers
|
||||
handleComponentTypeSelect,
|
||||
handlePieceTypeSelect,
|
||||
handleProductTypeSelect,
|
||||
// CRUD
|
||||
addCustomField: crud.addCustomField,
|
||||
removeCustomField: crud.removeCustomField,
|
||||
addPiece: crud.addPiece,
|
||||
removePiece: crud.removePiece,
|
||||
addProduct: crud.addProduct,
|
||||
removeProduct: crud.removeProduct,
|
||||
addSubComponent: crud.addSubComponent,
|
||||
removeSubComponent: crud.removeSubComponent,
|
||||
// Drag reorder — custom fields
|
||||
onCustomFieldDragStart: crud.onCustomFieldDragStart,
|
||||
onCustomFieldDragEnter: crud.onCustomFieldDragEnter,
|
||||
onCustomFieldDrop: crud.onCustomFieldDrop,
|
||||
onCustomFieldDragEnd: crud.onCustomFieldDragEnd,
|
||||
customFieldReorderClass: crud.customFieldReorderClass,
|
||||
// Drag reorder — pieces
|
||||
onPieceDragStart: crud.onPieceDragStart,
|
||||
onPieceDragEnter: crud.onPieceDragEnter,
|
||||
onPieceDragOver: crud.onPieceDragOver,
|
||||
onPieceDrop: crud.onPieceDrop,
|
||||
onPieceDragEnd: crud.onPieceDragEnd,
|
||||
pieceReorderClass: crud.pieceReorderClass,
|
||||
// Drag reorder — products
|
||||
onProductDragStart: crud.onProductDragStart,
|
||||
onProductDragEnter: crud.onProductDragEnter,
|
||||
onProductDragOver: crud.onProductDragOver,
|
||||
onProductDrop: crud.onProductDrop,
|
||||
onProductDragEnd: crud.onProductDragEnd,
|
||||
productReorderClass: crud.productReorderClass,
|
||||
// Drag reorder — subcomponents
|
||||
onSubcomponentDragStart: crud.onSubcomponentDragStart,
|
||||
onSubcomponentDragEnter: crud.onSubcomponentDragEnter,
|
||||
onSubcomponentDragOver: crud.onSubcomponentDragOver,
|
||||
onSubcomponentDrop: crud.onSubcomponentDrop,
|
||||
onSubcomponentDragEnd: crud.onSubcomponentDragEnd,
|
||||
subcomponentReorderClass: crud.subcomponentReorderClass,
|
||||
}
|
||||
}
|
||||
92
frontend/app/composables/useToast.ts
Normal file
92
frontend/app/composables/useToast.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
const MAX_TOASTS = 3
|
||||
let nextId = 1
|
||||
|
||||
// Anti-doublon : ignore un toast identique affiché dans les 2 dernières secondes
|
||||
const recentMessages = new Map<string, number>()
|
||||
const DEDUP_WINDOW = 2000
|
||||
|
||||
export function useToast() {
|
||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
||||
const dedupKey = `${type}::${message}`
|
||||
const lastShown = recentMessages.get(dedupKey)
|
||||
if (lastShown && Date.now() - lastShown < DEDUP_WINDOW) {
|
||||
return -1
|
||||
}
|
||||
recentMessages.set(dedupKey, Date.now())
|
||||
|
||||
const id = nextId++
|
||||
const toast: Toast = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
}
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
toasts.value.shift()
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
// Auto-remove after duration
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const showSuccess = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
const showError = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'error', duration)
|
||||
}
|
||||
|
||||
const showWarning = (message: string, duration = 6000): number => {
|
||||
return showToast(message, 'warning', duration)
|
||||
}
|
||||
|
||||
const showInfo = (message: string, duration = 5000): number => {
|
||||
return showToast(message, 'info', duration)
|
||||
}
|
||||
|
||||
const removeToast = (id: number): void => {
|
||||
const index = toasts.value.findIndex((toast) => toast.id === id)
|
||||
if (index !== -1 && toasts.value[index]) {
|
||||
toasts.value[index].visible = false
|
||||
setTimeout(() => {
|
||||
toasts.value.splice(index, 1)
|
||||
}, 300) // Animation duration
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
toasts.value = []
|
||||
recentMessages.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
removeToast,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
116
frontend/app/composables/useUrlState.ts
Normal file
116
frontend/app/composables/useUrlState.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ref, watch, nextTick, type Ref } from 'vue'
|
||||
import { useRoute, useRouter } from '#imports'
|
||||
|
||||
interface ParamDef<T extends string | number = string | number> {
|
||||
default: T
|
||||
type?: 'string' | 'number'
|
||||
/** Debounce URL writes (ms). Default: 0 (immediate). */
|
||||
debounce?: number
|
||||
}
|
||||
|
||||
type ParamDefs = Record<string, ParamDef>
|
||||
|
||||
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
|
||||
|
||||
type StateRefs<T extends ParamDefs> = {
|
||||
[K in keyof T]: InferRef<T[K]>
|
||||
}
|
||||
|
||||
interface UseUrlStateOptions {
|
||||
/** Called when state is restored from URL (back/forward navigation). */
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function useUrlState<T extends ParamDefs>(
|
||||
params: T,
|
||||
options?: UseUrlStateOptions,
|
||||
): StateRefs<T> {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const keys = Object.keys(params) as (keyof T & string)[]
|
||||
const refs: Record<string, Ref<string | number>> = {}
|
||||
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
|
||||
|
||||
for (const key of keys) {
|
||||
refs[key] = ref(parseValue(route.query[key], params[key]!))
|
||||
timers[key] = null
|
||||
}
|
||||
|
||||
let isProgrammatic = false
|
||||
|
||||
const buildQuery = (): Record<string, string> => {
|
||||
const q: Record<string, string> = {}
|
||||
for (const key of keys) {
|
||||
const val = refs[key]!.value
|
||||
if (val !== params[key]!.default) {
|
||||
q[key] = String(val)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
const pushToUrl = () => {
|
||||
if (isProgrammatic) return
|
||||
isProgrammatic = true
|
||||
const query = buildQuery()
|
||||
router
|
||||
.replace({ path: route.path, query })
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
nextTick(() => {
|
||||
isProgrammatic = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const ms = params[key]!.debounce ?? 0
|
||||
watch(refs[key]!, () => {
|
||||
if (isProgrammatic) return
|
||||
if (ms > 0) {
|
||||
if (timers[key]) clearTimeout(timers[key]!)
|
||||
timers[key] = setTimeout(pushToUrl, ms)
|
||||
} else {
|
||||
pushToUrl()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({ ...route.query }),
|
||||
(newQuery) => {
|
||||
if (isProgrammatic) return
|
||||
isProgrammatic = true
|
||||
let changed = false
|
||||
for (const key of keys) {
|
||||
const parsed = parseValue(newQuery[key], params[key]!)
|
||||
if (refs[key]!.value !== parsed) {
|
||||
refs[key]!.value = parsed
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
isProgrammatic = false
|
||||
if (changed && options?.onRestore) {
|
||||
options.onRestore()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
return refs as StateRefs<T>
|
||||
}
|
||||
|
||||
function parseValue(
|
||||
raw: unknown,
|
||||
def: ParamDef,
|
||||
): string | number {
|
||||
const str = typeof raw === 'string' ? raw : null
|
||||
if (str === null) return def.default
|
||||
if (def.type === 'number' || typeof def.default === 'number') {
|
||||
const n = Number(str)
|
||||
return Number.isFinite(n) ? n : def.default
|
||||
}
|
||||
return str
|
||||
}
|
||||
Reference in New Issue
Block a user