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:
22
frontend/app/utils/date.ts
Normal file
22
frontend/app/utils/date.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
|
||||
* Retourne "—" si la valeur est invalide ou absente.
|
||||
*/
|
||||
const frenchDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return frenchDateFormatter.format(date)
|
||||
}
|
||||
29
frontend/app/utils/documentPreview.js
Normal file
29
frontend/app/utils/documentPreview.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getFileIcon } from './fileIcons'
|
||||
|
||||
export const getPreviewType = (document) => {
|
||||
if (!document) { return null }
|
||||
const mime = (document.mimeType || '').toLowerCase()
|
||||
|
||||
if (mime.startsWith('image/')) { return 'image' }
|
||||
if (mime === 'application/pdf') { return 'pdf' }
|
||||
if (mime.startsWith('audio/')) { return 'audio' }
|
||||
if (mime.startsWith('video/')) { return 'video' }
|
||||
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) { return 'text' }
|
||||
return null
|
||||
}
|
||||
|
||||
export const canPreviewDocument = (document = {}) => {
|
||||
if (!getPreviewType(document)) return false
|
||||
return !!(document.fileUrl || document.path)
|
||||
}
|
||||
|
||||
export const isImageDocument = (document = {}) => getPreviewType(document) === 'image'
|
||||
|
||||
export const isPdfDocument = (document = {}) => getPreviewType(document) === 'pdf'
|
||||
|
||||
export const describeDocument = (document) => {
|
||||
if (!document) { return '' }
|
||||
const name = document.filename || document.name || ''
|
||||
const icon = getFileIcon({ name, mime: document.mimeType })
|
||||
return icon.label
|
||||
}
|
||||
41
frontend/app/utils/events.ts
Normal file
41
frontend/app/utils/events.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Type-safe event handlers for form inputs.
|
||||
* These helpers extract values from DOM events in a way that satisfies TypeScript.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the string value from an input event.
|
||||
*/
|
||||
export const getInputValue = (event: Event): string => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.value ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the numeric value from an input event.
|
||||
*/
|
||||
export const getInputNumber = (event: Event): number => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
const value = target?.value ?? ''
|
||||
const parsed = parseFloat(value)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the checked state from a checkbox event.
|
||||
*/
|
||||
export const getCheckboxValue = (event: Event): boolean => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.checked ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an optional numeric value from an input event (empty string = undefined).
|
||||
*/
|
||||
export const getOptionalNumber = (event: Event): number | undefined => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
const value = target?.value ?? ''
|
||||
if (value.trim() === '') return undefined
|
||||
const parsed = parseFloat(value)
|
||||
return Number.isNaN(parsed) ? undefined : parsed
|
||||
}
|
||||
120
frontend/app/utils/fileIcons.js
Normal file
120
frontend/app/utils/fileIcons.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import IconFile from '~icons/lucide/file'
|
||||
import IconFileType from '~icons/lucide/file-type'
|
||||
import IconFileText from '~icons/lucide/file-text'
|
||||
import IconFileSpreadsheet from '~icons/lucide/file-spreadsheet'
|
||||
import IconPresentation from '~icons/lucide/presentation'
|
||||
import IconImage from '~icons/lucide/image'
|
||||
import IconArchive from '~icons/lucide/archive'
|
||||
import IconFileAudio2 from '~icons/lucide/file-audio-2'
|
||||
import IconFileVideo2 from '~icons/lucide/file-video-2'
|
||||
import IconFileCode from '~icons/lucide/file-code'
|
||||
|
||||
const iconMap = [
|
||||
{
|
||||
label: 'PDF',
|
||||
exts: ['pdf'],
|
||||
component: IconFileType,
|
||||
colorClass: 'text-red-500'
|
||||
},
|
||||
{
|
||||
label: 'Word',
|
||||
exts: ['doc', 'docx'],
|
||||
component: IconFileText,
|
||||
colorClass: 'text-blue-500'
|
||||
},
|
||||
{
|
||||
label: 'Excel',
|
||||
exts: ['xls', 'xlsx', 'csv'],
|
||||
component: IconFileSpreadsheet,
|
||||
colorClass: 'text-green-500'
|
||||
},
|
||||
{
|
||||
label: 'PowerPoint',
|
||||
exts: ['ppt', 'pptx'],
|
||||
component: IconPresentation,
|
||||
colorClass: 'text-orange-500'
|
||||
},
|
||||
{
|
||||
label: 'Image',
|
||||
exts: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'heic'],
|
||||
component: IconImage,
|
||||
colorClass: 'text-purple-500'
|
||||
},
|
||||
{
|
||||
label: 'Archive',
|
||||
exts: ['zip', 'rar', '7z', 'tar', 'gz'],
|
||||
component: IconArchive,
|
||||
colorClass: 'text-amber-500'
|
||||
},
|
||||
{
|
||||
label: 'Audio',
|
||||
exts: ['mp3', 'wav', 'ogg', 'flac', 'aac'],
|
||||
component: IconFileAudio2,
|
||||
colorClass: 'text-pink-500'
|
||||
},
|
||||
{
|
||||
label: 'Vidéo',
|
||||
exts: ['mp4', 'mov', 'avi', 'mkv', 'webm'],
|
||||
component: IconFileVideo2,
|
||||
colorClass: 'text-indigo-500'
|
||||
},
|
||||
{
|
||||
label: 'Texte',
|
||||
exts: ['txt', 'md', 'rtf'],
|
||||
component: IconFileText,
|
||||
colorClass: 'text-gray-500'
|
||||
},
|
||||
{
|
||||
label: 'Code',
|
||||
exts: ['json', 'xml', 'yml', 'yaml', 'js', 'ts', 'py', 'java', 'cs'],
|
||||
component: IconFileCode,
|
||||
colorClass: 'text-sky-500'
|
||||
}
|
||||
]
|
||||
|
||||
const mimeGroups = [
|
||||
{ prefix: 'image/', component: IconImage, colorClass: 'text-purple-500', label: 'Image' },
|
||||
{ prefix: 'video/', component: IconFileVideo2, colorClass: 'text-indigo-500', label: 'Vidéo' },
|
||||
{ prefix: 'audio/', component: IconFileAudio2, colorClass: 'text-pink-500', label: 'Audio' },
|
||||
{ prefix: 'text/', component: IconFileText, colorClass: 'text-gray-500', label: 'Texte' },
|
||||
{ prefix: 'application/pdf', component: IconFileType, colorClass: 'text-red-500', label: 'PDF' },
|
||||
{ prefix: 'application/zip', component: IconArchive, colorClass: 'text-amber-500', label: 'Archive' },
|
||||
{ prefix: 'application/x-', component: IconArchive, colorClass: 'text-amber-500', label: 'Archive' }
|
||||
]
|
||||
|
||||
export const getFileIcon = ({ name = '', mime = '' } = {}) => {
|
||||
const extension = name.split('.').pop()?.toLowerCase() || ''
|
||||
|
||||
if (extension) {
|
||||
const match = iconMap.find(entry => entry.exts.includes(extension))
|
||||
if (match) {
|
||||
return {
|
||||
component: match.component,
|
||||
colorClass: match.colorClass,
|
||||
label: match.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mime) {
|
||||
const match = mimeGroups.find(entry => mime.startsWith(entry.prefix))
|
||||
if (match) {
|
||||
return {
|
||||
component: match.component,
|
||||
colorClass: match.colorClass,
|
||||
label: match.label || mime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
component: IconFile,
|
||||
colorClass: 'text-primary',
|
||||
label: 'Document'
|
||||
}
|
||||
}
|
||||
|
||||
export const describeFileType = ({ name = '', mime = '' } = {}) => {
|
||||
const icon = getFileIcon({ name, mime })
|
||||
return icon.label
|
||||
}
|
||||
39
frontend/app/utils/formatters/email.ts
Normal file
39
frontend/app/utils/formatters/email.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Basic helpers around email formatting shared across components and composables.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalises an email by trimming whitespace and converting it to lowercase.
|
||||
*/
|
||||
export const normalizeEmail = (rawValue: string): string => {
|
||||
const value = (rawValue || '').trim()
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks an email address by hiding the characters between the first and last letters
|
||||
* of the local part. Useful for UI fragments where we want partial obfuscation.
|
||||
*/
|
||||
export const maskEmail = (rawValue: string): string => {
|
||||
const value = normalizeEmail(rawValue)
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts = value.split('@')
|
||||
const localPart = parts[0] ?? ''
|
||||
const domain = parts[1]
|
||||
if (!domain || !localPart) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (localPart.length <= 2) {
|
||||
return `${localPart[0] ?? ''}·@${domain}`
|
||||
}
|
||||
|
||||
const start = localPart[0] ?? ''
|
||||
const end = localPart.slice(-1)
|
||||
const masked = '·'.repeat(Math.max(0, localPart.length - 2))
|
||||
|
||||
return `${start}${masked}${end}@${domain}`
|
||||
}
|
||||
90
frontend/app/utils/formatters/phone.ts
Normal file
90
frontend/app/utils/formatters/phone.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Utilities to normalize and format phone numbers without relying on external libraries.
|
||||
* The helpers keep the behaviour permissive to avoid breaking existing flows while
|
||||
* still providing a single place where formatting rules live.
|
||||
*/
|
||||
|
||||
/** Matches characters that should be kept when normalising a phone number. */
|
||||
const PHONE_CHAR_PATTERN = /[^+\d]/g
|
||||
|
||||
/**
|
||||
* Normalises a phone number by trimming whitespace, removing spacing/separators and
|
||||
* converting international prefixes written with `00` to their `+` variant.
|
||||
*/
|
||||
export const normalizePhone = (rawValue: string | null | undefined): string => {
|
||||
const trimmed = typeof rawValue === 'string' ? rawValue.trim() : ''
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const cleaned = trimmed.replace(PHONE_CHAR_PATTERN, '')
|
||||
if (cleaned.startsWith('00')) {
|
||||
return `+${cleaned.slice(2)}`
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a phone number by grouping digits by two and joining them with dots while
|
||||
* keeping any international prefix. The function remains tolerant to partially
|
||||
* entered numbers and returns an empty string for nullish inputs.
|
||||
*/
|
||||
export const formatPhone = (rawValue: string | null | undefined): string => {
|
||||
if (rawValue == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalized = normalizePhone(rawValue)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.startsWith('+33')) {
|
||||
let nationalNumber = normalized.slice(3)
|
||||
if (nationalNumber.startsWith('0')) {
|
||||
nationalNumber = nationalNumber.slice(1)
|
||||
}
|
||||
|
||||
if (nationalNumber.length % 2 !== 0) {
|
||||
nationalNumber = `0${nationalNumber}`
|
||||
}
|
||||
|
||||
const groups = nationalNumber.match(/\d{1,2}/g) ?? []
|
||||
if (groups.length === 0) {
|
||||
return '+33'
|
||||
}
|
||||
|
||||
return ['+33', ...groups].join('.')
|
||||
}
|
||||
|
||||
const hasInternationalPrefix = normalized.startsWith('+')
|
||||
const prefix = hasInternationalPrefix ? normalized.slice(0, 1) : ''
|
||||
const digits = hasInternationalPrefix ? normalized.slice(1) : normalized
|
||||
|
||||
const groups = digits.match(/\d{1,2}/g) ?? []
|
||||
const grouped = groups.join('.')
|
||||
|
||||
return prefix ? `${prefix}${grouped}` : grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks a phone number for display purposes by replacing the middle digits with ·.
|
||||
* Useful for UI fragments where the full number should not be exposed.
|
||||
*/
|
||||
export const maskPhone = (rawValue: string | null | undefined): string => {
|
||||
const normalized = normalizePhone(rawValue)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.length <= 4) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const start = normalized.slice(0, 2)
|
||||
const end = normalized.slice(-2)
|
||||
const maskedMiddle = '·'.repeat(Math.max(0, normalized.length - 4))
|
||||
|
||||
return `${start}${maskedMiddle}${end}`
|
||||
}
|
||||
745
frontend/app/utils/printTemplates/machineReport.js
Normal file
745
frontend/app/utils/printTemplates/machineReport.js
Normal file
@@ -0,0 +1,745 @@
|
||||
import {
|
||||
uniqueConstructeurIds,
|
||||
resolveConstructeurs,
|
||||
formatConstructeurContact,
|
||||
} from '~/shared/constructeurUtils'
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
})
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null
|
||||
}
|
||||
const number = Number(value)
|
||||
if (Number.isNaN(number)) {
|
||||
return null
|
||||
}
|
||||
return currencyFormatter.format(number)
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) { return '—' }
|
||||
if (size === 0) { return '0 B' }
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const renderPrintField = (label, value, fallback = '—') => {
|
||||
const display = value !== undefined && value !== null && value !== '' ? value : fallback
|
||||
return `<div class="print-field"><label>${label}</label><span>${display}</span></div>`
|
||||
}
|
||||
|
||||
const renderPrintCustomFields = (fields = [], title, sectionClass = 'print-section') => {
|
||||
if (!fields.length) { return '' }
|
||||
const items = fields
|
||||
.map(field => `<div class="print-field"><label>${field.label}</label><span>${field.value || '—'}</span></div>`)
|
||||
.join('')
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>${title}</h3>
|
||||
<div class="print-grid">
|
||||
${items}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const renderPrintDocuments = (documents = [], title, sectionClass = 'print-section') => {
|
||||
if (!documents.length) { return '' }
|
||||
const rows = documents
|
||||
.map(doc => `<tr><td>${doc.name}</td><td>${doc.type}</td><td>${doc.size}</td></tr>`)
|
||||
.join('')
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>${title}</h3>
|
||||
<table class="print-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Taille</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const renderPrintProductSummary = (product, title = 'Produit catalogue', sectionClass = 'print-piece-section') => {
|
||||
if (!product) { return '' }
|
||||
|
||||
const infoEntries = [
|
||||
{ label: 'Nom', value: product.name || '—' },
|
||||
{ label: 'Référence', value: product.reference || '—' },
|
||||
{ label: 'Catégorie', value: product.typeName || '—' },
|
||||
{
|
||||
label: 'Prix indicatif',
|
||||
value: product.supplierPrice || '—',
|
||||
},
|
||||
{
|
||||
label: 'Fournisseur(s)',
|
||||
value: product.constructeurs?.length
|
||||
? product.constructeurs.map((constructeur) => constructeur.name).filter(Boolean).join(', ') || '—'
|
||||
: '—',
|
||||
},
|
||||
]
|
||||
|
||||
const infoMarkup = infoEntries
|
||||
.map((field) => `<div class="print-field"><label>${field.label}</label><span>${field.value || '—'}</span></div>`)
|
||||
.join('')
|
||||
|
||||
const customFieldsBlock = product.customFields?.length
|
||||
? renderPrintCustomFields(product.customFields, 'Champs personnalisés du produit', 'print-subsection')
|
||||
: ''
|
||||
|
||||
const documentsBlock = product.documents?.length
|
||||
? renderPrintDocuments(product.documents, 'Documents du produit', 'print-subsection')
|
||||
: ''
|
||||
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h4>${title}</h4>
|
||||
<div class="print-grid">
|
||||
${infoMarkup}
|
||||
</div>
|
||||
${customFieldsBlock}
|
||||
${documentsBlock}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const renderPrintPieces = (
|
||||
pieces = [],
|
||||
title = 'Pièces indépendantes',
|
||||
sectionClass = 'print-section print-section--pieces'
|
||||
) => {
|
||||
if (!pieces.length) { return '' }
|
||||
|
||||
const cards = pieces
|
||||
.map((piece, idx) => {
|
||||
const indexLabel = piece.indexPath ? piece.indexPath.join('.') : `${idx + 1}`
|
||||
const constructeurBadges = (piece.constructeurs || [])
|
||||
.map((constructeur, badgeIdx) => {
|
||||
const suffix = piece.constructeurs.length > 1 ? ` ${badgeIdx + 1}` : ''
|
||||
return `<span class="print-badge print-badge--subtle">Fournisseur${suffix}: ${constructeur.name}</span>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const customFields = (piece.customFields || [])
|
||||
.filter(field => field.value && field.value !== '—' && field.value !== '')
|
||||
.map(
|
||||
field => `
|
||||
<li>
|
||||
<span class="print-list-label">${field.label}</span>
|
||||
<span class="print-list-value">${field.value}</span>
|
||||
</li>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const customFieldsBlock = customFields
|
||||
? `<div class="print-piece-section"><h4>Champs personnalisés</h4><ul class="print-list">${customFields}</ul></div>`
|
||||
: ''
|
||||
|
||||
const documentsBlock = (piece.documents || []).length
|
||||
? `<div class="print-piece-section"><h4>Documents</h4><ul class="print-list">${piece.documents
|
||||
.map(doc => `<li>${doc.name} <span class="print-list-hint">(${doc.type} • ${doc.size})</span></li>`)
|
||||
.join('')}</ul></div>`
|
||||
: ''
|
||||
|
||||
const productBlock = renderPrintProductSummary(piece.product, 'Produit catalogue')
|
||||
|
||||
return `
|
||||
<div class="print-piece-card">
|
||||
<div class="print-piece-header">
|
||||
<span class="print-index print-index--piece">${indexLabel}</span>
|
||||
<div class="print-piece-heading">
|
||||
<div class="print-piece-title">${piece.name}</div>
|
||||
<div class="print-piece-subtitle">${piece.reference || 'Référence non définie'}</div>
|
||||
</div>
|
||||
${constructeurBadges}
|
||||
</div>
|
||||
${piece.description ? `<p class="print-piece-description">${piece.description}</p>` : ''}
|
||||
<div class="print-piece-meta">
|
||||
<div class="print-field-mini">
|
||||
<label>Fournisseur(s)</label>
|
||||
<span>${piece.constructeurs?.length
|
||||
? piece.constructeurs.map(constructeur => constructeur.name).join(', ')
|
||||
: '—'}</span>
|
||||
</div>
|
||||
<div class="print-field-mini">
|
||||
<label>Contact(s)</label>
|
||||
<span>${piece.constructeurs?.length
|
||||
? piece.constructeurs
|
||||
.map(constructeur => constructeur.contact)
|
||||
.filter(Boolean)
|
||||
.join(' • ') || '—'
|
||||
: '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
${productBlock}
|
||||
${customFieldsBlock}
|
||||
${documentsBlock}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>${title}</h3>
|
||||
<div class="print-piece-grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const renderPrintComponents = (components = [], depth = 0, indexPath = []) => {
|
||||
if (!components.length) { return '' }
|
||||
return components
|
||||
.map((component, idx) => {
|
||||
const badges = []
|
||||
if (component.constructeurs?.length) {
|
||||
const label = component.constructeurs.map((constructeur, badgeIdx) => {
|
||||
const suffix = component.constructeurs.length > 1 ? ` ${badgeIdx + 1}` : ''
|
||||
return `Constructeur${suffix}: ${constructeur.name}`
|
||||
})
|
||||
badges.push(...label)
|
||||
}
|
||||
const sectionClass = `print-section print-section--component print-section-depth-${Math.min(depth, 3)}`
|
||||
const currentIndex = [...indexPath, idx + 1]
|
||||
const indexLabel = currentIndex.join('.')
|
||||
const productBlock = renderPrintProductSummary(component.product, 'Produit catalogue', 'print-section print-subsection print-section--product')
|
||||
return `
|
||||
<div class="${sectionClass}">
|
||||
<h3>
|
||||
<span class="print-index print-index--component">${indexLabel}</span>
|
||||
<span>Composant : ${component.name}</span>
|
||||
</h3>
|
||||
${component.description ? `<p class="print-muted">${component.description}</p>` : ''}
|
||||
${badges.length ? `<div class="badge-group">${badges.map(badge => `<span class="print-badge">${badge}</span>`).join('')}</div>` : ''}
|
||||
${productBlock}
|
||||
${renderPrintCustomFields(
|
||||
component.customFields,
|
||||
'Champs personnalisés',
|
||||
'print-section print-subsection print-section--custom-fields'
|
||||
)}
|
||||
${renderPrintPieces(
|
||||
(component.pieces || []).map((piece, pieceIdx) => ({ ...piece, indexPath: [...currentIndex, pieceIdx + 1] })),
|
||||
'Pièces du composant',
|
||||
'print-section print-subsection print-section--pieces'
|
||||
)}
|
||||
${renderPrintDocuments(
|
||||
component.documents,
|
||||
'Documents du composant',
|
||||
'print-section print-subsection print-section--documents'
|
||||
)}
|
||||
${renderPrintComponents(component.subComponents || [], depth + 1, currentIndex)}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const normalizeDocuments = (docs = []) => {
|
||||
return docs.map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.name || doc.filename || 'Document',
|
||||
type: doc.mimeType || doc.type || '—',
|
||||
size: formatSize(doc.size)
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizeCustomFields = (values = []) => {
|
||||
return values.map(value => ({
|
||||
id: value.id,
|
||||
label: value.customField?.name || 'Champ',
|
||||
value: value.value || '—'
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizeConstructeur = (constructeur) => {
|
||||
if (!constructeur) { return null }
|
||||
const contact = formatConstructeurContact(constructeur)
|
||||
return {
|
||||
id: constructeur.id || null,
|
||||
name: constructeur.name || '—',
|
||||
contact: contact || '—'
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeConstructeurList = (...sources) => {
|
||||
const ids = uniqueConstructeurIds(...sources)
|
||||
const pools = sources
|
||||
.flatMap((source) => {
|
||||
if (Array.isArray(source)) {
|
||||
if (source.length && typeof source[0] === 'object') {
|
||||
return [source]
|
||||
}
|
||||
return []
|
||||
}
|
||||
if (source && typeof source === 'object' && 'id' in source) {
|
||||
return [[source]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
.filter(Boolean)
|
||||
const resolved = resolveConstructeurs(ids, ...pools)
|
||||
return resolved
|
||||
.map(normalizeConstructeur)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const normalizeProduct = (product) => {
|
||||
if (!product) { return null }
|
||||
const constructeurs = normalizeConstructeurList(
|
||||
product.constructeurs,
|
||||
product.constructeur,
|
||||
product.constructeurIds,
|
||||
product.constructeurId,
|
||||
)
|
||||
return {
|
||||
id: product.id || null,
|
||||
name: product.name || 'Produit sans nom',
|
||||
reference: product.reference || '',
|
||||
supplierPrice: formatCurrency(product.supplierPrice),
|
||||
typeName: product.typeProduct?.name || null,
|
||||
constructeurs,
|
||||
customFields: normalizeCustomFields(product.customFieldValues || []),
|
||||
documents: normalizeDocuments(product.documents || []),
|
||||
}
|
||||
}
|
||||
|
||||
const normalizePiece = piece => {
|
||||
const rawProduct = piece.product || null
|
||||
const constructeurs = normalizeConstructeurList(
|
||||
piece.constructeurs,
|
||||
piece.constructeur,
|
||||
piece.originalPiece?.constructeurs,
|
||||
piece.originalPiece?.constructeur,
|
||||
piece.constructeurIds,
|
||||
piece.constructeurId,
|
||||
rawProduct?.constructeurs,
|
||||
rawProduct?.constructeur,
|
||||
rawProduct?.constructeurIds,
|
||||
rawProduct?.constructeurId,
|
||||
)
|
||||
const product = normalizeProduct(rawProduct)
|
||||
|
||||
return {
|
||||
id: piece.id,
|
||||
name: piece.name || 'Pièce sans nom',
|
||||
description: piece.description || '',
|
||||
reference: piece.reference || '',
|
||||
customFields: normalizeCustomFields(piece.customFieldValues || []),
|
||||
documents: normalizeDocuments(piece.documents || []),
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || null,
|
||||
product,
|
||||
indexPath: piece.indexPath || null
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeComponent = component => {
|
||||
const rawProduct = component.product || null
|
||||
const constructeurs = normalizeConstructeurList(
|
||||
component.constructeurs,
|
||||
component.constructeur,
|
||||
component.originalComposant?.constructeurs,
|
||||
component.originalComposant?.constructeur,
|
||||
component.constructeurIds,
|
||||
component.constructeurId,
|
||||
rawProduct?.constructeurs,
|
||||
rawProduct?.constructeur,
|
||||
rawProduct?.constructeurIds,
|
||||
rawProduct?.constructeurId,
|
||||
)
|
||||
const product = normalizeProduct(rawProduct)
|
||||
|
||||
return {
|
||||
id: component.id,
|
||||
name: component.name || 'Composant sans nom',
|
||||
description: component.description || '',
|
||||
customFields: normalizeCustomFields(component.customFieldValues || []),
|
||||
documents: normalizeDocuments(component.documents || []),
|
||||
pieces: (component.pieces || []).map(normalizePiece),
|
||||
subComponents: (component.sousComposants || component.subComponents || []).map(normalizeComponent),
|
||||
constructeurs,
|
||||
constructeur: constructeurs[0] || null,
|
||||
product,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMachinePrintContext = ({
|
||||
machine,
|
||||
machineName,
|
||||
machineReference,
|
||||
machinePieces = [],
|
||||
components = [],
|
||||
selection
|
||||
}) => {
|
||||
const selectionState = selection || {}
|
||||
const machineSelection = selectionState.machine || {}
|
||||
const componentSelection = selectionState.components || {}
|
||||
const pieceSelection = selectionState.pieces || {}
|
||||
|
||||
const includeMachineInfo = machineSelection.info !== false
|
||||
const includeMachineCustomFields = machineSelection.customFields !== false
|
||||
const includeMachineDocuments = machineSelection.documents !== false
|
||||
|
||||
const isComponentSelected = (id) => {
|
||||
if (!id) { return true }
|
||||
if (Object.prototype.hasOwnProperty.call(componentSelection, id)) {
|
||||
return componentSelection[id]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const isPieceSelected = (id) => {
|
||||
if (!id) { return true }
|
||||
if (Object.prototype.hasOwnProperty.call(pieceSelection, id)) {
|
||||
return pieceSelection[id]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const machineBadges = []
|
||||
if (machine?.site?.name) {
|
||||
machineBadges.push(`Site: ${machine.site.name}`)
|
||||
}
|
||||
if (machineReference) {
|
||||
machineBadges.push(`Ref: ${machineReference}`)
|
||||
}
|
||||
|
||||
const machineConstructeurs = normalizeConstructeurList(
|
||||
machine?.constructeurs,
|
||||
machine?.constructeur,
|
||||
machine?.constructeurIds,
|
||||
machine?.constructeurId,
|
||||
)
|
||||
|
||||
const machineConstructeurNames = machineConstructeurs.length
|
||||
? machineConstructeurs.map((constructeur) => constructeur.name).join(', ')
|
||||
: ''
|
||||
|
||||
const machineConstructeurContacts = machineConstructeurs.length
|
||||
? machineConstructeurs
|
||||
.map((constructeur) => constructeur.contact)
|
||||
.filter(Boolean)
|
||||
.join(' • ')
|
||||
: ''
|
||||
|
||||
const normalizedPieces = machinePieces
|
||||
.map(normalizePiece)
|
||||
.filter(piece => isPieceSelected(piece.id))
|
||||
.map((piece, idx) => ({
|
||||
...piece,
|
||||
indexPath: [idx + 1]
|
||||
}))
|
||||
|
||||
const normalizedComponents = components.map(normalizeComponent)
|
||||
|
||||
const filterComponentTree = (component) => {
|
||||
const filteredPieces = (component.pieces || []).filter(piece => isPieceSelected(piece.id))
|
||||
const filteredSubComponents = (component.subComponents || [])
|
||||
.map(filterComponentTree)
|
||||
.filter(Boolean)
|
||||
|
||||
const includeSelf = isComponentSelected(component.id)
|
||||
const shouldInclude = includeSelf || filteredPieces.length > 0 || filteredSubComponents.length > 0
|
||||
|
||||
if (!shouldInclude) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...component,
|
||||
pieces: filteredPieces,
|
||||
subComponents: filteredSubComponents
|
||||
}
|
||||
}
|
||||
|
||||
const filteredComponents = normalizedComponents
|
||||
.map(filterComponentTree)
|
||||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toLocaleString('fr-FR'),
|
||||
machine: {
|
||||
id: machine?.id || null,
|
||||
name: machineName,
|
||||
description: machine?.description || '',
|
||||
reference: machineReference,
|
||||
site: machine?.site?.name || '',
|
||||
badges: machineBadges,
|
||||
constructeurs: machineConstructeurs,
|
||||
constructeur: machineConstructeurs[0] || null,
|
||||
constructeurNames: machineConstructeurNames,
|
||||
constructeurContacts: machineConstructeurContacts,
|
||||
includeInfo: includeMachineInfo,
|
||||
customFields: includeMachineCustomFields
|
||||
? normalizeCustomFields(machine?.customFieldValues || [])
|
||||
: [],
|
||||
documents: includeMachineDocuments
|
||||
? normalizeDocuments(machine?.documents || [])
|
||||
: []
|
||||
},
|
||||
components: filteredComponents,
|
||||
pieces: normalizedPieces
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMachinePrintHtml = (context, styles) => {
|
||||
const title = context.machine.name ? `Impression - ${context.machine.name}` : 'Impression machine'
|
||||
const badgesHtml = context.machine.badges
|
||||
.map(badge => `<span class="print-badge">${badge}</span>`)
|
||||
.join('')
|
||||
const sections = []
|
||||
|
||||
sections.push(`
|
||||
<div class="print-metadata">
|
||||
<span>Généré le ${context.generatedAt}</span>
|
||||
<span>Machine ID: ${context.machine.id || '—'}</span>
|
||||
</div>
|
||||
<div class="print-header">
|
||||
<div class="title-block">
|
||||
<div class="print-title">${context.machine.name || 'Machine sans nom'}</div>
|
||||
<div class="print-subtitle">${
|
||||
context.machine.description || context.machine.typeDescription || 'Aucune description disponible'
|
||||
}</div>
|
||||
</div>
|
||||
<div class="badge-group">${badgesHtml}</div>
|
||||
</div>
|
||||
`)
|
||||
|
||||
if (context.machine.includeInfo) {
|
||||
sections.push(`
|
||||
<div class="print-section print-section--machine">
|
||||
<h3>Informations générales</h3>
|
||||
<div class="print-grid">
|
||||
${renderPrintField('Nom', context.machine.name)}
|
||||
${renderPrintField('Référence', context.machine.reference, 'Non définie')}
|
||||
${renderPrintField('Site', context.machine.site, 'Non défini')}
|
||||
${renderPrintField('Constructeur(s)', context.machine.constructeurNames, 'Non défini')}
|
||||
${renderPrintField('Contact(s) Constructeur(s)', context.machine.constructeurContacts, 'Non défini')}
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
const customFieldsSection = renderPrintCustomFields(
|
||||
context.machine.customFields,
|
||||
'Champs personnalisés de la machine',
|
||||
'print-section print-section--custom-fields'
|
||||
)
|
||||
if (customFieldsSection) {
|
||||
sections.push(customFieldsSection)
|
||||
}
|
||||
|
||||
const documentsSection = renderPrintDocuments(
|
||||
context.machine.documents,
|
||||
'Documents liés à la machine',
|
||||
'print-section print-section--documents'
|
||||
)
|
||||
if (documentsSection) {
|
||||
sections.push(documentsSection)
|
||||
}
|
||||
|
||||
const componentsSection = renderPrintComponents(context.components)
|
||||
if (componentsSection) {
|
||||
sections.push(componentsSection)
|
||||
}
|
||||
|
||||
const piecesSection = renderPrintPieces(
|
||||
context.pieces,
|
||||
'Pièces indépendantes',
|
||||
'print-section print-section--pieces'
|
||||
)
|
||||
if (piecesSection) {
|
||||
sections.push(piecesSection)
|
||||
}
|
||||
|
||||
sections.push(`
|
||||
<div class="print-section print-muted">
|
||||
Rapport généré automatiquement par Inventaire Pro.
|
||||
</div>
|
||||
`)
|
||||
|
||||
const content = sections.join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light only" />
|
||||
<title>${title}</title>
|
||||
${styles}
|
||||
<style>
|
||||
/* ============ Design tokens (sobre & pro) ============ */
|
||||
:root{
|
||||
--bg: #ffffff;
|
||||
--ink: #0f172a; /* slate-900 */
|
||||
--muted: #475569; /* slate-600 */
|
||||
--line: #cbd5e1; /* slate-300 */
|
||||
--line-soft: #e2e8f0; /* slate-200 */
|
||||
--accent: #1f2937; /* gray-800 */
|
||||
--accent-ink: #111827; /* gray-900 */
|
||||
--brand: #0ea5e9; /* sky-500 (léger) */
|
||||
--brand-ink: #075985; /* sky-800 */
|
||||
--ok: #059669; /* emerald-600 */
|
||||
--warn: #ea580c; /* orange-600 */
|
||||
--comp: #be185d; /* pink-700 */
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--shadow-0: none;
|
||||
--shadow-1: 0 1px 0 rgba(15,23,42,.04);
|
||||
--shadow-2: 0 2px 8px rgba(15,23,42,.06);
|
||||
}
|
||||
|
||||
/* ============ Base ============ */
|
||||
@page { margin: 16mm 14mm; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
background: #F9FAFB;
|
||||
color: var(--ink);
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
.print-layout { max-width: 1120px; margin: 0 auto; padding: 28px; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-metadata {
|
||||
font-size: 11px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
letter-spacing: .04em; text-transform: uppercase; border-bottom: 1px solid var(--line); padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.print-header {
|
||||
display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;
|
||||
background: var(--bg); padding: 18px 20px; border: 1px solid var(--line); border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.print-header .title-block { flex: 1; }
|
||||
.print-title { font-size: 26px; font-weight: 800; margin: 0 0 6px; color: var(--accent-ink); }
|
||||
.print-subtitle { font-size: 14px; color: var(--muted); margin: 0; line-height: 1.5; }
|
||||
|
||||
.badge-group { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
|
||||
.print-badge {
|
||||
display: inline-flex; align-items: center; padding: 4px 8px; border-radius: var(--radius-sm);
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
background: #e0f2fe; color: var(--brand-ink); border: 1px solid #bae6fd;
|
||||
}
|
||||
.print-badge--subtle { background: #f1f5f9; border-color: var(--line); color: #334155; }
|
||||
|
||||
.print-index {
|
||||
display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 24px;
|
||||
border-radius: var(--radius-sm); font-size: 12px; font-weight: 700; margin-right: 8px;
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.print-index--component { background: #fde2f2; color: var(--comp); }
|
||||
.print-index--piece { background: #ffedd5; color: var(--warn); }
|
||||
|
||||
/* ============ Sections ============ */
|
||||
.print-section, .print-section.print-subsection, .print-piece-card { break-inside: avoid; page-break-inside: avoid; }
|
||||
.print-section {
|
||||
margin: 0; padding: 20px 22px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--line); background: var(--bg); box-shadow: var(--shadow-2); position: relative;
|
||||
}
|
||||
.print-section + .print-section { margin-top: 8px; }
|
||||
.print-section h3 {
|
||||
font-size: 16px; font-weight: 800; margin: 0 0 14px; letter-spacing: .02em; color: var(--accent);
|
||||
border-left: 3px solid var(--accent); padding-left: 10px;
|
||||
}
|
||||
|
||||
.print-section--machine { border-left: 3px solid var(--brand); }
|
||||
.print-section--custom-fields { border-left: 3px solid #7c3aed; }
|
||||
.print-section--documents { border-left: 3px solid var(--ok); }
|
||||
.print-section--component { border-left: 3px solid var(--comp); }
|
||||
.print-section--pieces { border-left: 3px solid var(--warn); }
|
||||
|
||||
.print-section.print-subsection {
|
||||
margin-top: 14px; margin-bottom: 10px; padding: 16px 18px; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-1); background: #fcfcfd;
|
||||
}
|
||||
|
||||
/* ============ Fields (grid) ============ */
|
||||
.print-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.print-field {
|
||||
display: flex; flex-direction: column; padding: 10px 12px; border-radius: var(--radius-sm);
|
||||
background: #fafbfc; border: 1px solid var(--line-soft); min-height: 72px;
|
||||
}
|
||||
.print-field label { font-size: 10px; text-transform: uppercase; color: #64748b; letter-spacing: .08em; margin-bottom: 8px; }
|
||||
.print-field span { font-size: 14px; color: var(--ink); font-weight: 600; line-height: 1.45; }
|
||||
|
||||
/* ============ Pieces ============ */
|
||||
.print-piece-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.print-piece-card {
|
||||
padding: 14px 16px; border-radius: var(--radius-md); border: 1px solid #fed7aa; background: var(--bg); box-shadow: var(--shadow-1);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.print-piece-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.print-piece-heading { flex: 1 1 160px; }
|
||||
.print-piece-title { font-size: 15px; font-weight: 800; color: #7c2d12; }
|
||||
.print-piece-subtitle { font-size: 11px; color: #a16207; text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; }
|
||||
.print-piece-description { font-size: 12px; color: #7c2d12; margin: 0; }
|
||||
|
||||
.print-piece-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
|
||||
.print-field-mini {
|
||||
padding: 8px 10px; border-radius: var(--radius-sm); background: #fff7ed; border: 1px solid #fed7aa; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.print-field-mini label { font-size: 9px; text-transform: uppercase; color: #9a3412; letter-spacing: .06em; }
|
||||
.print-field-mini span { font-size: 12px; font-weight: 700; color: #7c2d12; }
|
||||
.print-piece-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.print-piece-section h4 { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; color: #7c2d12; margin: 0; }
|
||||
.print-list { margin: 0; padding-left: 16px; display: grid; gap: 4px; }
|
||||
.print-list li { font-size: 12px; color: #475569; break-inside: avoid; }
|
||||
.print-list-label { font-weight: 700; color: #7c2d12; margin-right: 4px; }
|
||||
.print-list-value { color: var(--ink); }
|
||||
.print-list-hint { color: #64748B; }
|
||||
|
||||
/* ============ Tables ============ */
|
||||
.print-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 13px; border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.print-table th, .print-table td { border: 1px solid var(--line-soft); padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||
.print-table thead th { background: #f1f5f9; font-weight: 800; letter-spacing: .02em; text-transform: uppercase; color: var(--accent); font-size: 12px; }
|
||||
.print-table tr { break-inside: avoid; page-break-inside: avoid; }
|
||||
.print-section--documents .print-table thead th { background: #ecfdf5; color: #065f46; }
|
||||
.print-section--pieces .print-table thead th { background: #fffbeb; color: #92400e; }
|
||||
.print-section--component .print-table thead th { background: #fdf2f8; color: #831843; }
|
||||
|
||||
/* ============ Utilities ============ */
|
||||
.print-muted { color: var(--muted); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
||||
|
||||
/* ============ Print media ============ */
|
||||
@media print {
|
||||
body { background: white; }
|
||||
.print-layout { padding: 0; max-width: none; }
|
||||
.print-header, .print-section, .print-section.print-subsection, .print-piece-card, .print-table { box-shadow: var(--shadow-0); }
|
||||
.print-section, .print-section.print-subsection, .print-piece-card { break-inside: avoid-page; page-break-inside: avoid; }
|
||||
.badge-group { justify-content: flex-start; }
|
||||
.print-metadata { border-bottom-color: var(--line); }
|
||||
/* Force couleurs d’accent légères pour économie d’encre */
|
||||
.print-badge { background: #e5f3fb !important; border-color: #d1e7f8 !important; }
|
||||
.print-index--component { background: #fbe7f3 !important; }
|
||||
.print-index--piece { background: #fff1e6 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-layout">
|
||||
${content}
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
Reference in New Issue
Block a user