fix(front) : ajustements du formulaire ticket de pesée (ERP-189/190)
- Poids/DSD en champs texte verrouillés sur les chiffres et désactivés. - Boutons de pesée : icône mdi:weight à gauche + gap-8. - Bloc « Poids à vide » réagencé en 3 lignes (contrepartie / Date-Poids-DSD-Immat / Tout format). - Omission des clés null dans les payloads (compact) : requis vides → message NotBlank métier au lieu d'une erreur de type. - Pesée obligatoire (RG-5.07) signalée inline sous Poids/DSD ; toutes les violations affichées d'un seul aller-retour. - Erreur d'immatriculation affichée uniquement sur le bloc « Poids à vide » (plus de doublon sur le bloc plein).
This commit is contained in:
@@ -720,6 +720,8 @@
|
||||
"save": "Enregistrer",
|
||||
"validate": "Valider",
|
||||
"print": "Imprimer",
|
||||
"weightRequired": "Le poids est obligatoire : effectuez une pesée.",
|
||||
"dsdRequired": "Le DSD est obligatoire : effectuez une pesée.",
|
||||
"counterparty": {
|
||||
"type": "Fournisseur / Client / Autre",
|
||||
"supplier": "Fournisseur",
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-8">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:weight"
|
||||
icon-position="left"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.auto')"
|
||||
:disabled="disabled"
|
||||
@click="$emit('request-auto')"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
icon-name="mdi:weight"
|
||||
icon-position="left"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.manual')"
|
||||
:disabled="disabled"
|
||||
@click="$emit('request-manual')"
|
||||
@@ -19,64 +23,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Contrepartie : rendue par le parent (bloc vide uniquement) via le slot. -->
|
||||
<slot name="counterparty" />
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
<!-- Ligne 1 : contrepartie (type en col 1 + champ conditionnel en col 2),
|
||||
rendue par le parent (bloc vide uniquement) via le slot. -->
|
||||
<div v-if="$slots.counterparty" class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<slot name="counterparty" />
|
||||
</div>
|
||||
|
||||
<!-- Date de la pesée — jour par défaut (RG-5.07). MalioDate (composant
|
||||
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
|
||||
<MalioDate
|
||||
:model-value="block.date"
|
||||
:label="t('logistique.weighingTickets.form.date')"
|
||||
:required="true"
|
||||
:editable="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.date"
|
||||
@update:model-value="(v: string | null) => emitBlock('date', v)"
|
||||
/>
|
||||
<!-- Ligne 2 : Date, Poids, DSD, Immatriculation. -->
|
||||
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Date de la pesée — jour par défaut (RG-5.07). MalioDate (composant
|
||||
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
|
||||
<MalioDate
|
||||
:model-value="block.date"
|
||||
:label="t('logistique.weighingTickets.form.date')"
|
||||
:required="true"
|
||||
:editable="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.date"
|
||||
@update:model-value="(v: string | null) => emitBlock('date', v)"
|
||||
/>
|
||||
|
||||
<!-- Poids : readonly, rempli par la pesée (RG-5.07). Unité Kg dans le label. -->
|
||||
<MalioInputNumber
|
||||
:model-value="block.weight"
|
||||
:label="t('logistique.weighingTickets.form.weight')"
|
||||
:required="true"
|
||||
:readonly="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.weight"
|
||||
/>
|
||||
<!-- Poids : champ texte verrouillé sur les chiffres, toujours désactivé
|
||||
(rempli par la pesée, jamais saisi à la main — RG-5.07). Unité Kg
|
||||
dans le label. -->
|
||||
<MalioInputText
|
||||
:model-value="weightDisplay"
|
||||
:mask="NUMERIC_MASK"
|
||||
:label="t('logistique.weighingTickets.form.weight')"
|
||||
:required="true"
|
||||
:disabled="true"
|
||||
:error="errors.weight"
|
||||
/>
|
||||
|
||||
<!-- DSD : readonly, rempli par la pesée (RG-5.04 / RG-5.07). -->
|
||||
<MalioInputNumber
|
||||
:model-value="block.dsd"
|
||||
:label="t('logistique.weighingTickets.form.dsd')"
|
||||
:required="true"
|
||||
:readonly="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.dsd"
|
||||
/>
|
||||
<!-- DSD : champ texte verrouillé sur les chiffres, toujours désactivé
|
||||
(rempli par la pesée — RG-5.04 / RG-5.07). -->
|
||||
<MalioInputText
|
||||
:model-value="dsdDisplay"
|
||||
:mask="NUMERIC_MASK"
|
||||
:label="t('logistique.weighingTickets.form.dsd')"
|
||||
:required="true"
|
||||
:disabled="true"
|
||||
:error="errors.dsd"
|
||||
/>
|
||||
|
||||
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
|
||||
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
|
||||
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
|
||||
<MalioInputText
|
||||
:model-value="immatriculation"
|
||||
:mask="plateFreeFormat ? undefined : PLATE_MASK"
|
||||
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.immatriculation"
|
||||
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
|
||||
/>
|
||||
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
|
||||
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
|
||||
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
|
||||
<MalioInputText
|
||||
:model-value="immatriculation"
|
||||
:mask="plateFreeFormat ? undefined : PLATE_MASK"
|
||||
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.immatriculation"
|
||||
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- « Tout format » : désactive le masque plaque. Partagé entre blocs (RG-5.01). -->
|
||||
<MalioCheckbox
|
||||
:id="`${blockId}-plate-free-format`"
|
||||
:model-value="plateFreeFormat"
|
||||
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||
group-class="self-center"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
|
||||
/>
|
||||
<!-- Ligne 3 : « Tout format » (désactive le masque plaque). Partagé entre
|
||||
blocs (RG-5.01). Sur sa propre ligne. -->
|
||||
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioCheckbox
|
||||
:id="`${blockId}-plate-free-format`"
|
||||
:model-value="plateFreeFormat"
|
||||
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||
group-class="self-center"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -99,6 +115,14 @@ const PLATE_MASK = {
|
||||
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
|
||||
}
|
||||
|
||||
// Masque « chiffres uniquement » (maska, longueur libre) pour Poids et DSD :
|
||||
// ces champs texte sont verrouillés sur des entiers, et de toute façon désactivés
|
||||
// (remplis par la pesée).
|
||||
const NUMERIC_MASK = {
|
||||
mask: 'D',
|
||||
tokens: { D: { pattern: /[0-9]/, multiple: true } },
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
|
||||
blockId: string
|
||||
@@ -125,6 +149,11 @@ const { t } = useI18n()
|
||||
|
||||
const errors = computed(() => props.errors ?? {})
|
||||
|
||||
// Poids / DSD : champs texte → on présente l'entier sous forme de chaîne (vide
|
||||
// tant que la pesée n'a pas rempli la valeur).
|
||||
const weightDisplay = computed(() => (props.block.weight === null ? '' : String(props.block.weight)))
|
||||
const dsdDisplay = computed(() => (props.block.dsd === null ? '' : String(props.block.dsd)))
|
||||
|
||||
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
|
||||
function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
|
||||
emit('update:block', field, value)
|
||||
|
||||
@@ -15,6 +15,31 @@ describe('useWeighingTicketForm', () => {
|
||||
expect(form.counterpartyType.value).toBeNull()
|
||||
})
|
||||
|
||||
// ── Omission des requis vides (compact) ──────────────────────────────────
|
||||
it('buildCreatePayload omet les clés null (requis vides absents, pas envoyés à null)', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
// Formulaire vierge : counterpartyType / immatriculation non remplis.
|
||||
const payload = form.buildCreatePayload()
|
||||
// Absents (et non null) → le back applique NotBlank (message métier) plutôt
|
||||
// qu'une erreur de type opaque (« doit être de type string »).
|
||||
expect(payload).not.toHaveProperty('counterpartyType')
|
||||
expect(payload).not.toHaveProperty('immatriculation')
|
||||
expect(payload).not.toHaveProperty('emptyWeight')
|
||||
// Les non-null restent : date du jour + booléen Tout format.
|
||||
expect(payload.emptyDate).toBe('2026-06-22')
|
||||
expect(payload.plateFreeFormat).toBe(false)
|
||||
})
|
||||
|
||||
// ── Pesée obligatoire front-only (RG-5.07) ───────────────────────────────
|
||||
it('missingWeighingFields liste Poids/DSD manquants, puis vide après pesée', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
expect(form.missingWeighingFields('empty')).toEqual(['emptyWeight', 'emptyDsd'])
|
||||
expect(form.missingWeighingFields('full')).toEqual(['fullWeight', 'fullDsd'])
|
||||
|
||||
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||
expect(form.missingWeighingFields('empty')).toEqual([])
|
||||
})
|
||||
|
||||
// ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
|
||||
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
|
||||
@@ -65,6 +65,19 @@ function isoDateOnly(value: string | null | undefined): string | null {
|
||||
return value ? value.slice(0, 10) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire les clés à valeur `null` d'un payload (pattern « omission des requis
|
||||
* vides » M1). Avec `collectDenormalizationErrors` côté back, envoyer `null` sur
|
||||
* un scalaire requis (ex. `counterpartyType`) produit une violation de TYPE
|
||||
* opaque (« Cette valeur doit être de type string. ») au lieu du message métier
|
||||
* `NotBlank` : une clé ABSENTE laisse au contraire jouer la contrainte `NotBlank`
|
||||
* et son message FR. On omet donc les null ; les champs réellement requis non
|
||||
* remplis déclenchent leur vrai message, les optionnels restent simplement absents.
|
||||
*/
|
||||
function compact(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== null))
|
||||
}
|
||||
|
||||
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */
|
||||
function emptyBlock(today: string): WeighingBlockState {
|
||||
return {
|
||||
@@ -122,6 +135,21 @@ export function useWeighingTicketForm() {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Champs de pesée manquants d'un bloc (Poids / DSD), RG-5.07. Le back rend ces
|
||||
* colonnes nullable (workflow 2 temps) : l'obligation « une pesée a été
|
||||
* effectuée » est donc portée côté front (règle front-only, ERP-101). Renvoie
|
||||
* les `propertyPath` manquants (ex. `['emptyWeight', 'emptyDsd']`), prêts à
|
||||
* être posés en erreur inline via `useFormErrors.setError`.
|
||||
*/
|
||||
function missingWeighingFields(which: 'empty' | 'full'): string[] {
|
||||
const block = which === 'empty' ? empty : full
|
||||
const missing: string[] = []
|
||||
if (block.weight === null) missing.push(`${which}Weight`)
|
||||
if (block.dsd === null) missing.push(`${which}Dsd`)
|
||||
return missing
|
||||
}
|
||||
|
||||
/** Applique une lecture de pesée (bascule/manuelle) à un bloc. */
|
||||
function applyReading(
|
||||
block: WeighingBlockState,
|
||||
@@ -150,7 +178,7 @@ export function useWeighingTicketForm() {
|
||||
* pour que `useFormErrors` mappe les 422 inline.
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
return compact({
|
||||
counterpartyType: counterpartyType.value,
|
||||
...counterpartyPayload(),
|
||||
immatriculation: immatriculation.value || null,
|
||||
@@ -160,7 +188,7 @@ export function useWeighingTicketForm() {
|
||||
emptyDsd: empty.dsd,
|
||||
emptyMode: empty.mode,
|
||||
emptyManualNumber: empty.manualNumber || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +236,7 @@ export function useWeighingTicketForm() {
|
||||
* recalculé serveur (RG-5.05).
|
||||
*/
|
||||
function buildFullPayload(): Record<string, unknown> {
|
||||
return {
|
||||
return compact({
|
||||
immatriculation: immatriculation.value || null,
|
||||
plateFreeFormat: plateFreeFormat.value,
|
||||
fullDate: full.date || null,
|
||||
@@ -216,7 +244,7 @@ export function useWeighingTicketForm() {
|
||||
fullDsd: full.dsd,
|
||||
fullMode: full.mode,
|
||||
fullManualNumber: full.manualNumber || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -234,6 +262,7 @@ export function useWeighingTicketForm() {
|
||||
empty,
|
||||
full,
|
||||
applyReading,
|
||||
missingWeighingFields,
|
||||
// workflow
|
||||
ticketId,
|
||||
hydrate,
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||
vi.stubGlobal('navigateTo', vi.fn())
|
||||
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||
globalThis.open = mockOpen
|
||||
|
||||
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
|
||||
|
||||
@@ -213,7 +213,24 @@ const form = useWeighingTicketForm()
|
||||
const weighbridge = useWeighbridge()
|
||||
const referentials = useWeighingTicketReferentials()
|
||||
const { fetchTicket } = useWeighingTicket()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
/**
|
||||
* Marque Poids/DSD manquants d'un bloc (RG-5.07). `emptyWeight` est validé côté
|
||||
* back (NotBlank → renvoyé avec les autres violations) ; `fullWeight` n'a pas
|
||||
* d'équivalent back (workflow 2 temps) et reste donc front-only. Le DSD est
|
||||
* alloué serveur → simple repère front en miroir du poids. Retourne false si une
|
||||
* pesée manque.
|
||||
*/
|
||||
function validateWeighing(which: 'empty' | 'full'): boolean {
|
||||
const missing = form.missingWeighingFields(which)
|
||||
for (const path of missing) {
|
||||
setError(path, path.endsWith('Weight')
|
||||
? t('logistique.weighingTickets.form.weightRequired')
|
||||
: t('logistique.weighingTickets.form.dsdRequired'))
|
||||
}
|
||||
return missing.length === 0
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
@@ -255,11 +272,13 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||
dsd: errors.emptyDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
// Immatriculation volontairement ABSENTE ici : partagée entre les 2 blocs
|
||||
// (RG-5.01) mais affichée/validée sur le bloc « Poids à vide » uniquement — pas
|
||||
// de doublon d'erreur sur le bloc « Poids à plein ».
|
||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.fullDate,
|
||||
weight: errors.fullWeight,
|
||||
dsd: errors.fullDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
|
||||
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||
@@ -348,8 +367,12 @@ async function confirmManual(): Promise<void> {
|
||||
/** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */
|
||||
async function submitSave(): Promise<void> {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
clearErrors()
|
||||
// Vide : marqué seulement (le back garde emptyWeight et renvoie tout d'un coup).
|
||||
// Plein : bloquant côté front (pas de règle back, workflow 2 temps).
|
||||
validateWeighing('empty')
|
||||
if (!validateWeighing('full')) return
|
||||
saving.value = true
|
||||
try {
|
||||
await api.patch(`/weighing_tickets/${ticketId}`, form.buildUpdatePayload(), { toast: false })
|
||||
router.push('/weighing-tickets')
|
||||
|
||||
@@ -199,7 +199,23 @@ if (!can('logistique.weighing_tickets.manage')) {
|
||||
const form = useWeighingTicketForm()
|
||||
const weighbridge = useWeighbridge()
|
||||
const referentials = useWeighingTicketReferentials()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
/**
|
||||
* Validation front-only de la pesée d'un bloc (Poids + DSD obligatoires, RG-5.07).
|
||||
* Le back rend ces colonnes nullable (workflow 2 temps), l'obligation est donc
|
||||
* portée côté front (ERP-101). Pose l'erreur inline sous chaque champ manquant et
|
||||
* retourne false si une pesée manque.
|
||||
*/
|
||||
function validateWeighing(which: 'empty' | 'full'): boolean {
|
||||
const missing = form.missingWeighingFields(which)
|
||||
for (const path of missing) {
|
||||
setError(path, path.endsWith('Weight')
|
||||
? t('logistique.weighingTickets.form.weightRequired')
|
||||
: t('logistique.weighingTickets.form.dsdRequired'))
|
||||
}
|
||||
return missing.length === 0
|
||||
}
|
||||
|
||||
// Le bloc vide se verrouille une fois le ticket créé (numéro/site attribués).
|
||||
const emptyLocked = computed(() => form.ticketId.value !== null)
|
||||
@@ -232,11 +248,14 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||
dsd: errors.emptyDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
// Immatriculation volontairement ABSENTE ici : elle est partagée entre les 2 blocs
|
||||
// (RG-5.01) mais saisie/validée sur le bloc « Poids à vide ». On n'affiche donc
|
||||
// son erreur que sur le 1er bloc, pas en double sur le bloc « Poids à plein »
|
||||
// (le formulaire se valide en 2 temps).
|
||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.fullDate,
|
||||
weight: errors.fullWeight,
|
||||
dsd: errors.fullDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
|
||||
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||
@@ -331,8 +350,13 @@ interface TicketResponse { id: number }
|
||||
/** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */
|
||||
async function submitCreate(): Promise<void> {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
clearErrors()
|
||||
// Marque Poids/DSD manquants pour un retour immédiat, mais on POSTe quand même :
|
||||
// le back renvoie TOUTES les violations d'un coup (counterparty / immat / poids,
|
||||
// NotBlank sur emptyWeight), comme les autres modules. Le DSD est alloué serveur
|
||||
// (pas de règle back) → simple repère front en miroir du poids.
|
||||
validateWeighing('empty')
|
||||
creating.value = true
|
||||
try {
|
||||
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
@@ -351,8 +375,10 @@ async function submitCreate(): Promise<void> {
|
||||
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */
|
||||
async function submitValidate(): Promise<void> {
|
||||
if (validating.value || form.ticketId.value === null) return
|
||||
validating.value = true
|
||||
clearErrors()
|
||||
// Pesée à plein obligatoire (front-only) avant finalisation/impression.
|
||||
if (!validateWeighing('full')) return
|
||||
validating.value = true
|
||||
try {
|
||||
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false })
|
||||
// Bon de pesée = PDF généré côté back (Twig, ERP-192) — on l'ouvre, on ne
|
||||
|
||||
Reference in New Issue
Block a user