feat(front) : écran modification d'un ticket de pesée + bouton imprimer (ERP-190)

This commit is contained in:
2026-06-22 15:29:15 +02:00
parent 9f3fe4da4e
commit b438838465
6 changed files with 713 additions and 0 deletions
@@ -102,4 +102,65 @@ describe('useWeighingTicketForm', () => {
expect(full.fullDsd).toBe(2)
expect(full.fullMode).toBe('AUTO')
})
// ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
it('hydrate pré-remplit l\'état depuis le détail (dates ISO ramenées à YYYY-MM-DD)', () => {
const form = useWeighingTicketForm()
form.hydrate({
id: 9,
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629' },
immatriculation: 'AB-123-CD',
plateFreeFormat: false,
emptyDate: '2026-06-17T09:00:00+02:00',
emptyWeight: 7150,
emptyDsd: 1,
emptyMode: 'AUTO',
fullDate: '2026-06-17T09:12:00+02:00',
fullWeight: 14300,
fullDsd: 2,
fullMode: 'AUTO',
})
expect(form.ticketId.value).toBe(9)
expect(form.counterpartyType.value).toBe('CLIENT')
expect(form.counterpartyField.value).toBe('client')
expect(form.clientIri.value).toBe('/api/clients/629')
expect(form.immatriculation.value).toBe('AB-123-CD')
// Date datetime back -> date seule pour MalioDate.
expect(form.empty.date).toBe('2026-06-17')
expect(form.full.date).toBe('2026-06-17')
expect(form.empty.weight).toBe(7150)
expect(form.full.weight).toBe(14300)
})
it('hydrate gère les champs null omis (skip_null_values) avec des défauts', () => {
const form = useWeighingTicketForm()
form.hydrate({ id: 5, counterpartyType: 'AUTRE', otherLabel: 'Reprise' })
expect(form.otherLabel.value).toBe('Reprise')
expect(form.supplierIri.value).toBeNull()
expect(form.plateFreeFormat.value).toBe(false)
// Pas de date back -> repli sur le jour (stub 2026-06-22).
expect(form.empty.date).toBe('2026-06-22')
expect(form.empty.weight).toBeNull()
})
it('buildUpdatePayload fusionne contrepartie + véhicule + les 2 pesées', () => {
const form = useWeighingTicketForm()
form.hydrate({
id: 9,
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629' },
immatriculation: 'AB-123-CD',
emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
})
const payload = form.buildUpdatePayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload.emptyWeight).toBe(7150)
expect(payload.fullWeight).toBe(14300)
expect(payload.immatriculation).toBe('AB-123-CD')
})
})
@@ -0,0 +1,53 @@
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
import type { CounterpartyType } from '~/modules/logistique/composables/useWeighingTicketForm'
/**
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
* § 4.0.bis). Champs null OMIS du JSON (`skip_null_values`) → tous optionnels,
* lus avec un défaut côté hydratation du formulaire.
*/
export interface WeighingTicketDetail {
id: number
/** Numéro `{siteCode}-TP-{NNNN}` — immuable (RG-5.09). */
number: string
/** Site rattaché (embarqué) — immuable (RG-5.09). */
site?: { id: number, name: string, code: string } | null
counterpartyType: CounterpartyType
client?: { '@id': string, companyName: string } | null
supplier?: { '@id': string, companyName: string } | null
otherLabel?: string | null
immatriculation?: string | null
plateFreeFormat?: boolean
// Pesée à vide
emptyDate?: string | null
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
emptyManualNumber?: string | null
// Pesée à plein
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
fullManualNumber?: string | null
netWeight?: number | null
}
/**
* Charge le détail d'un ticket de pesée pour l'écran de modification (M5,
* ERP-190). `Accept: application/ld+json` impose l'enveloppe Hydra (relations
* embarquées : client/supplier/site). Appel via `useApi()` (jamais `$fetch`).
*/
export function useWeighingTicket() {
const api = useApi()
async function fetchTicket(id: number | string): Promise<WeighingTicketDetail> {
return await api.get<WeighingTicketDetail>(
`/weighing_tickets/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
return { fetchTicket }
}
@@ -39,6 +39,32 @@ export interface WeighingBlockState {
manualNumber: string | null
}
/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
export interface WeighingTicketHydration {
id: number
counterpartyType: CounterpartyType
client?: { '@id': string } | null
supplier?: { '@id': string } | null
otherLabel?: string | null
immatriculation?: string | null
plateFreeFormat?: boolean
emptyDate?: string | null
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
emptyManualNumber?: string | null
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
fullManualNumber?: string | null
}
/** Extrait la partie date `YYYY-MM-DD` d'une chaîne ISO (datetime back) — null si absente. */
function isoDateOnly(value: string | null | undefined): string | null {
return value ? value.slice(0, 10) : null
}
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */
function emptyBlock(today: string): WeighingBlockState {
return {
@@ -137,6 +163,44 @@ export function useWeighingTicketForm() {
}
}
/**
* Pré-remplit le formulaire à partir du détail d'un ticket existant (écran
* Modification, ERP-190). Le numéro et le site sont immuables (RG-5.09) →
* non repris dans l'état éditable (affichés en lecture seule par l'écran).
* Les dates ISO du back (datetime) sont ramenées à `YYYY-MM-DD` pour MalioDate.
*/
function hydrate(detail: WeighingTicketHydration): void {
ticketId.value = detail.id
counterpartyType.value = detail.counterpartyType ?? null
clientIri.value = detail.client?.['@id'] ?? null
supplierIri.value = detail.supplier?.['@id'] ?? null
otherLabel.value = detail.otherLabel ?? null
immatriculation.value = detail.immatriculation ?? null
plateFreeFormat.value = detail.plateFreeFormat ?? false
empty.date = isoDateOnly(detail.emptyDate) ?? today
empty.weight = detail.emptyWeight ?? null
empty.dsd = detail.emptyDsd ?? null
empty.mode = detail.emptyMode ?? null
empty.manualNumber = detail.emptyManualNumber ?? null
full.date = isoDateOnly(detail.fullDate) ?? today
full.weight = detail.fullWeight ?? null
full.dsd = detail.fullDsd ?? null
full.mode = detail.fullMode ?? null
full.manualNumber = detail.fullManualNumber ?? null
}
/**
* Payload de MODIFICATION (PATCH /weighing_tickets/{id}, ERP-190) : tous les
* champs éditables (contrepartie + véhicule + les 2 pesées). Le numéro et le
* site sont immuables (RG-5.09, ignorés par le back même si envoyés). Le net
* est recalculé serveur (RG-5.05).
*/
function buildUpdatePayload(): Record<string, unknown> {
return { ...buildCreatePayload(), ...buildFullPayload() }
}
/**
* Payload de FINALISATION (PATCH /weighing_tickets/{id}, spec-back § 4.4) :
* pesée à PLEIN. Le véhicule (immat / tout format) peut avoir été ajusté entre
@@ -172,7 +236,9 @@ export function useWeighingTicketForm() {
applyReading,
// workflow
ticketId,
hydrate,
buildCreatePayload,
buildFullPayload,
buildUpdatePayload,
}
}
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
// ── Mocks des composables modules (le form RÉEL est conservé pour vérifier le
// pré-remplissage via hydrate). ─────────────────────────────────────────────
const mockFetchTicket = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
}))
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
}))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
}))
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch }))
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() }))
globalThis.open = mockOpen
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) {
return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string })
},
})
// WeighingBlock stubbe : rend le slot counterparty (présent sur le bloc vide).
const BlockStub = defineComponent({
setup(_, { slots }) { return () => h('div', { 'data-testid': 'block' }, slots.counterparty?.()) },
})
const ModalStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
})
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioInputNumber: InputStub,
MalioSelect: InputStub,
MalioDate: InputStub,
MalioCheckbox: InputStub,
MalioModal: ModalStub,
WeighingBlock: BlockStub,
}
// Monte la page (setup async : top-level await) via Suspense.
async function mountPage() {
const wrapper = mount(defineComponent({
components: { EditPage },
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
const DETAIL = {
id: 9,
number: '86-TP-0001',
site: { id: 1, name: 'Chatellerault', code: '86' },
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
immatriculation: 'AB-123-CD',
plateFreeFormat: false,
emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
}
describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => {
beforeEach(() => {
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL })
mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset()
mockOpen.mockReset()
})
it('pré-remplit le numéro et le site en lecture seule (RG-5.09)', async () => {
const wrapper = await mountPage()
expect(mockFetchTicket).toHaveBeenCalledWith('9')
expect(wrapper.find('[data-label="logistique.weighingTickets.form.number"]').attributes('value')).toBe('86-TP-0001')
expect(wrapper.find('[data-label="logistique.weighingTickets.form.site"]').attributes('value')).toBe('Chatellerault')
})
it('bascule des boutons : « Enregistrer » + « Imprimer » présents, pas de « Valider »', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
// « Valider » est le bouton de l'écran d'AJOUT — absent en modification (RG-5.08).
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
})
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
})
it('« Enregistrer » PATCH le ticket puis revient à la liste', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
await flushPromises()
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/9',
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
expect.objectContaining({ toast: false }),
)
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
})
})
@@ -0,0 +1,389 @@
<template>
<div>
<!-- En-tête : retour vers la liste + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('logistique.weighingTickets.form.back')"
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- États de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('logistique.weighingTickets.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
<template v-else>
<!-- Numéro + site : lecture seule, immuables (RG-5.09). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="ticketNumber"
:label="t('logistique.weighingTickets.form.number')"
:readonly="true"
:disabled="true"
/>
<MalioInputText
:model-value="siteName"
:label="t('logistique.weighingTickets.form.site')"
:readonly="true"
:disabled="true"
/>
</div>
<div class="mt-8 flex flex-col gap-8">
<!-- Bloc « Poids à vide » (porte la contrepartie, RG-5.03) -->
<WeighingBlock
block-id="empty"
:title="t('logistique.weighingTickets.form.emptyBlock')"
:block="form.empty"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="emptyBlockErrors"
@update:block="(field, value) => updateBlock('empty', field, value)"
@update:immatriculation="(v) => form.immatriculation.value = v"
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
@request-auto="openAuto('empty')"
@request-manual="openManual('empty')"
>
<template #counterparty>
<MalioSelect
:model-value="form.counterpartyType.value"
:options="counterpartyOptions"
:label="t('logistique.weighingTickets.form.counterparty.type')"
:required="true"
empty-option-label=""
:error="errors.counterpartyType"
@update:model-value="onCounterpartyTypeChange"
/>
<MalioSelect
v-if="form.counterpartyField.value === 'supplier'"
:model-value="form.supplierIri.value"
:options="referentials.suppliers.value"
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
:required="true"
empty-option-label=""
:error="errors.supplier"
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
/>
<MalioSelect
v-else-if="form.counterpartyField.value === 'client'"
:model-value="form.clientIri.value"
:options="referentials.clients.value"
:label="t('logistique.weighingTickets.form.counterparty.client')"
:required="true"
empty-option-label=""
:error="errors.client"
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
/>
<MalioInputText
v-else-if="form.counterpartyField.value === 'other'"
:model-value="form.otherLabel.value"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v"
/>
</template>
</WeighingBlock>
<!-- Bloc « Poids à plein » : le bouton « Enregistrer » du bloc vide
DISPARAÎT en modification (RG-5.08) — on enregistre via le bas. -->
<WeighingBlock
block-id="full"
:title="t('logistique.weighingTickets.form.fullBlock')"
:block="form.full"
:immatriculation="form.immatriculation.value"
:plate-free-format="form.plateFreeFormat.value"
:errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)"
@update:immatriculation="(v) => form.immatriculation.value = v"
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
@request-auto="openAuto('full')"
@request-manual="openManual('full')"
/>
</div>
<!-- Bas d'écran : « Enregistrer » (remplace « Valider », RG-5.08) +
« Imprimer » (absent à l'ajout, RG-5.08). -->
<div class="mt-12 flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:printer-outline"
icon-position="left"
:label="t('logistique.weighingTickets.form.print')"
@click="printTicket"
/>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.save')"
:disabled="saving"
@click="submitSave"
/>
</div>
</template>
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
<MalioModal v-model="autoModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
@click="autoModal.open = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading"
@click="confirmAuto"
/>
</template>
</MalioModal>
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
<MalioModal v-model="manualModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template>
<div class="flex flex-col gap-4">
<MalioInputNumber
v-model="manualModal.weight"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:min="0"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.manualNumber"
:label="t('logistique.weighingTickets.form.manual.number')"
:required="true"
:error="manualModal.errors.manualNumber"
/>
</div>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.cancel')"
@click="manualModal.open = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading"
@click="confirmManual"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
const { t } = useI18n()
const api = useApi()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Modification réservée à `manage` (Admin / Bureau / Usine) — sinon retour liste.
if (!can('logistique.weighing_tickets.manage')) {
await navigateTo('/weighing-tickets')
}
const ticketId = route.params.id as string
const form = useWeighingTicketForm()
const weighbridge = useWeighbridge()
const referentials = useWeighingTicketReferentials()
const { fetchTicket } = useWeighingTicket()
const { errors, clearErrors, handleApiError } = useFormErrors()
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
// Numéro + site immuables (RG-5.09), affichés en lecture seule.
const ticketNumber = ref<string>('')
const siteName = ref<string>('')
const headerTitle = computed(() =>
ticketNumber.value
? t('logistique.weighingTickets.edit.title', { number: ticketNumber.value })
: t('logistique.weighingTickets.edit.titleFallback'),
)
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
/** Retour vers la liste (flèche d'en-tête). */
function goBack(): void {
router.push('/weighing-tickets')
}
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
])
function onCounterpartyTypeChange(value: string | number | null): void {
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
form.setCounterpartyType(type)
}
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate,
weight: errors.emptyWeight,
dsd: errors.emptyDsd,
immatriculation: errors.immatriculation,
}))
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). */
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
(form[target] as Record<string, unknown>)[field as string] = value
}
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
const autoModal = reactive({
open: false,
error: '',
loading: false,
target: 'empty' as 'empty' | 'full',
})
function openAuto(target: 'empty' | 'full'): void {
autoModal.target = target
autoModal.error = ''
autoModal.open = true
}
async function confirmAuto(): Promise<void> {
if (autoModal.loading) return
autoModal.loading = true
autoModal.error = ''
try {
const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading)
autoModal.open = false
}
catch (e) {
autoModal.error = weighbridge.extractWeighbridgeError(e)
}
finally {
autoModal.loading = false
}
}
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
const manualModal = reactive({
open: false,
loading: false,
target: 'empty' as 'empty' | 'full',
weight: null as string | number | null,
manualNumber: null as string | null,
errors: {} as Record<string, string>,
})
function openManual(target: 'empty' | 'full'): void {
manualModal.target = target
manualModal.weight = null
manualModal.manualNumber = null
manualModal.errors = {}
manualModal.open = true
}
async function confirmManual(): Promise<void> {
if (manualModal.loading) return
manualModal.errors = {}
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
const manualNumber = (manualModal.manualNumber ?? '').trim()
if (weight === null || Number.isNaN(weight)) {
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
}
if (manualNumber === '') {
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') }
}
if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true
try {
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
form.applyReading(form[manualModal.target], reading)
manualModal.open = false
}
catch (e) {
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(e) }
}
finally {
manualModal.loading = false
}
}
// ── Soumission / impression ───────────────────────────────────────────────────
/** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */
async function submitSave(): Promise<void> {
if (saving.value) return
saving.value = true
clearErrors()
try {
await api.patch(`/weighing_tickets/${ticketId}`, form.buildUpdatePayload(), { toast: false })
router.push('/weighing-tickets')
}
catch (e) {
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
}
finally {
saving.value = false
}
}
/**
* « Imprimer » : ouvre le bon de pesée PDF servi par le back (Twig, ERP-192).
* Le front ne dessine AUCUN gabarit — il ouvre seulement l'URL (RG-5.08).
*/
function printTicket(): void {
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
}
onMounted(async () => {
// Référentiels (selects contrepartie) en parallèle, non bloquants.
referentials.load().catch(() => {})
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number
siteName.value = detail.site?.name ?? ''
form.hydrate(detail)
}
catch {
error.value = true
}
finally {
loading.value = false
}
})
</script>