- Add abort controllers and request deduplication to composables - Add entity type cache invalidation on create/update/delete flows - Add 179 new tests (utilities, services, composables, components) - Fix Vue runtime warnings in structure editors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
9.1 KiB
TypeScript
305 lines
9.1 KiB
TypeScript
import { ref } from 'vue'
|
|
import { useToast } from './useToast'
|
|
import { useApi } from './useApi'
|
|
import { buildConstructeurRequestPayload, 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'
|
|
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',
|
|
force = false,
|
|
} = options
|
|
|
|
if (!force && loaded.value && !search && page === 1) {
|
|
return {
|
|
success: true,
|
|
data: { items: products.value, total: total.value, page, itemsPerPage },
|
|
}
|
|
}
|
|
|
|
if (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('name', search.trim())
|
|
}
|
|
|
|
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)))
|
|
products.value = enrichedItems
|
|
total.value = extractTotal(result.data, items.length)
|
|
loaded.value = true
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: enrichedItems,
|
|
total: total.value,
|
|
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 = (err as Error)?.message ?? 'Erreur inconnue'
|
|
error.value = message
|
|
showError(`Impossible de charger les produits: ${message}`)
|
|
return { success: false, error: message }
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
|
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
|
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 = (err as Error)?.message ?? 'Erreur inconnue'
|
|
error.value = message
|
|
showError(message)
|
|
return { success: false, error: message }
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
|
|
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
|
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 = (err as Error)?.message ?? 'Erreur inconnue'
|
|
error.value = message
|
|
showError(message)
|
|
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 = (err as Error)?.message ?? 'Erreur inconnue'
|
|
error.value = message
|
|
showError(message)
|
|
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,
|
|
}
|
|
}
|