feat: implement discount and transport features in the quote system
- Added maximum discount settings configurable by the administrator. - Implemented discount calculations in the quote confirmation process. - Introduced transport options with associated costs, including handling for exceeding maximum piece limits. - Updated frontend to display discounts and transport information in the quote summary. - Created new database migrations for storing discount and transport settings. - Developed a new Filament page for managing global discount settings. - Enhanced email templates to include discount and transport details. - Added tests to ensure proper functionality of discounts and transport calculations.
This commit is contained in:
@@ -18,6 +18,51 @@ const form = reactive({
|
||||
})
|
||||
const anagraficaCaricata = ref(false)
|
||||
|
||||
// Sconti applicabili dal cliente loggato (massimi decisi dall'amministratore). Il backend
|
||||
// ricalcola e valida: qui servono solo per il riepilogo.
|
||||
const scontiMassimi = reactive({ max_1: 0, max_2: 0 })
|
||||
const sconti = reactive({ sconto_1: 0, sconto_2: 0 })
|
||||
const puoScontare = computed(() => scontiMassimi.max_1 > 0 || scontiMassimi.max_2 > 0)
|
||||
|
||||
// Contributo trasporto: opzioni da /api/config, importo per pezzo (null = da quantificare).
|
||||
const opzioniTrasporto = ref([])
|
||||
const notaTrasporto = ref('')
|
||||
const trasporto = ref('')
|
||||
const maxPezziTrasporto = ref(null)
|
||||
const etichettaOltreMax = ref('')
|
||||
// Oltre il massimo di pezzi niente scelta: il trasporto è da quantificare.
|
||||
const trasportoOltreMax = computed(() => maxPezziTrasporto.value != null && pezzi.value > maxPezziTrasporto.value)
|
||||
const zonaTrasporto = computed(() => opzioniTrasporto.value.find((z) => z.valore === trasporto.value))
|
||||
const pezzi = computed(() => carrello.items.reduce((n, item) => n + (item.quantita || 0), 0))
|
||||
|
||||
function percentuale(valore, max) {
|
||||
const n = Number(valore)
|
||||
return Number.isFinite(n) ? Math.min(Math.max(n, 0), max) : 0
|
||||
}
|
||||
|
||||
// Stessa regola del backend: 1° sconto sul totale parziale, 2° extra sul totale già scontato.
|
||||
const riepilogoSconti = computed(() => {
|
||||
const parziale = carrello.totale || 0
|
||||
const s1 = Math.round(parziale * percentuale(sconti.sconto_1, scontiMassimi.max_1)) / 100
|
||||
const s2 = Math.round((parziale - s1) * percentuale(sconti.sconto_2, scontiMassimi.max_2)) / 100
|
||||
const imponibile = Math.round((parziale - s1 - s2) * 100) / 100
|
||||
const perPezzo = trasportoOltreMax.value ? null : zonaTrasporto.value?.importo
|
||||
const trasportoImporto = perPezzo == null ? null : Math.round(perPezzo * pezzi.value * 100) / 100
|
||||
return { parziale, s1, s2, imponibile, trasportoImporto, totale: imponibile + (trasportoImporto || 0) }
|
||||
})
|
||||
|
||||
async function caricaOpzioniTrasporto() {
|
||||
try {
|
||||
const data = await api.config()
|
||||
opzioniTrasporto.value = data.trasporto || []
|
||||
notaTrasporto.value = data.trasporto_nota || ''
|
||||
maxPezziTrasporto.value = data.trasporto_max_pezzi ?? null
|
||||
etichettaOltreMax.value = data.trasporto_etichetta_oltre_max || ''
|
||||
} catch (e) {
|
||||
// Senza opzioni il backend risponderà con l'errore di validazione sul campo.
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => !carrello.items || carrello.items.length === 0)
|
||||
|
||||
// Se il cliente è loggato nel configuratore, precompila il form con i suoi dati già
|
||||
@@ -31,6 +76,7 @@ async function precompilaAnagrafica() {
|
||||
|
||||
try {
|
||||
const data = await api.me(clienteToken)
|
||||
Object.assign(scontiMassimi, data.sconti || {})
|
||||
const campi = ['nome', 'cognome', 'ragione_sociale', 'email', 'telefono', 'indirizzo', 'cap', 'citta', 'provincia', 'note']
|
||||
for (const campo of campi) {
|
||||
if (!form[campo] && data.cliente[campo]) {
|
||||
@@ -45,6 +91,7 @@ async function precompilaAnagrafica() {
|
||||
function vaiAlCheckout() {
|
||||
step.value = 'checkout'
|
||||
precompilaAnagrafica()
|
||||
if (!opzioniTrasporto.value.length) caricaOpzioniTrasporto()
|
||||
}
|
||||
|
||||
async function loadCarrello() {
|
||||
@@ -83,7 +130,14 @@ async function conferma() {
|
||||
errorMsg.value = ''
|
||||
fieldErrors.value = {}
|
||||
try {
|
||||
const data = await api.conferma(token.value, { ...form })
|
||||
const data = await api.conferma(token.value, {
|
||||
...form,
|
||||
...(trasportoOltreMax.value ? {} : { trasporto: trasporto.value }),
|
||||
...(puoScontare.value ? {
|
||||
sconto_1: percentuale(sconti.sconto_1, scontiMassimi.max_1),
|
||||
sconto_2: percentuale(sconti.sconto_2, scontiMassimi.max_2),
|
||||
} : {}),
|
||||
})
|
||||
numeroConfermato.value = data.numero
|
||||
clearToken()
|
||||
step.value = 'done'
|
||||
@@ -224,6 +278,56 @@ onMounted(loadCarrello)
|
||||
<textarea v-model="form.note" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="trasportoOltreMax" class="field">
|
||||
<label>Trasporto</label>
|
||||
<div class="nota-trasporto">
|
||||
<b>{{ etichettaOltreMax }}</b>: il preventivo contiene {{ pezzi }} pezzi e supera il limite di
|
||||
{{ maxPezziTrasporto }} pezzi previsto per il contributo cadauno. {{ notaTrasporto }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="field">
|
||||
<label>Trasporto *</label>
|
||||
<select v-model="trasporto">
|
||||
<option value="" disabled>Seleziona…</option>
|
||||
<option v-for="zona in opzioniTrasporto" :key="zona.valore" :value="zona.valore">
|
||||
{{ zona.etichetta }}{{ zona.importo != null ? ` — ${formatEuro(zona.importo)} cad.` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="fieldErrors.trasporto" class="err">{{ fieldErrors.trasporto[0] }}</div>
|
||||
<div v-if="zonaTrasporto && zonaTrasporto.importo == null" class="nota-trasporto">{{ notaTrasporto }}</div>
|
||||
</div>
|
||||
|
||||
<div class="sconti">
|
||||
<p v-if="puoScontare" class="sconti-titolo">Sconti</p>
|
||||
<div v-if="puoScontare" class="grid-2">
|
||||
<div v-if="scontiMassimi.max_1 > 0" class="field">
|
||||
<label>1° sconto concesso (max {{ scontiMassimi.max_1 }}%)</label>
|
||||
<input v-model.number="sconti.sconto_1" type="number" min="0" :max="scontiMassimi.max_1" step="0.5" />
|
||||
<div v-if="fieldErrors.sconto_1" class="err">{{ fieldErrors.sconto_1[0] }}</div>
|
||||
</div>
|
||||
<div v-if="scontiMassimi.max_2 > 0" class="field">
|
||||
<label>2° sconto extra (max {{ scontiMassimi.max_2 }}%)</label>
|
||||
<input v-model.number="sconti.sconto_2" type="number" min="0" :max="scontiMassimi.max_2" step="0.5" />
|
||||
<div v-if="fieldErrors.sconto_2" class="err">{{ fieldErrors.sconto_2[0] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sconti-riga"><span>Totale parziale</span><span>{{ formatEuro(riepilogoSconti.parziale) }}</span></div>
|
||||
<div v-if="riepilogoSconti.s1" class="sconti-riga"><span>1° sconto</span><span>− {{ formatEuro(riepilogoSconti.s1) }}</span></div>
|
||||
<div v-if="riepilogoSconti.s2" class="sconti-riga"><span>2° sconto extra</span><span>− {{ formatEuro(riepilogoSconti.s2) }}</span></div>
|
||||
<div class="sconti-riga forte"><span>Totale imponibile offerta</span><span>{{ formatEuro(riepilogoSconti.imponibile) }}</span></div>
|
||||
<div v-if="trasportoOltreMax" class="sconti-riga">
|
||||
<span>Trasporto</span><span>da quantificare</span>
|
||||
</div>
|
||||
<div v-else-if="zonaTrasporto" class="sconti-riga">
|
||||
<span>Trasporto<template v-if="riepilogoSconti.trasportoImporto != null"> ({{ pezzi }} pz)</template></span>
|
||||
<span>{{ riepilogoSconti.trasportoImporto != null ? formatEuro(riepilogoSconti.trasportoImporto) : 'da quantificare' }}</span>
|
||||
</div>
|
||||
<div class="totale-row">
|
||||
<span>Totale <span class="iva-nota">IVA esclusa</span></span>
|
||||
<span>{{ formatEuro(riepilogoSconti.totale) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" :disabled="submitting" @click="conferma">
|
||||
{{ submitting ? 'Invio in corso…' : 'Conferma e invia' }}
|
||||
</button>
|
||||
|
||||
@@ -20,6 +20,7 @@ async function request(path, options = {}) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: () => request('/config'),
|
||||
getCarrello: (token) => request(`/carrello/${token}`),
|
||||
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
|
||||
// Se l'utente era loggato nel configuratore, passiamo il suo token al backend così
|
||||
|
||||
@@ -78,7 +78,7 @@ a.btn-link { display: inline-block; margin-top: 16px; text-align: center; }
|
||||
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
.field input, .field textarea {
|
||||
.field input, .field textarea, .field select {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font: inherit;
|
||||
}
|
||||
.field .err { color: var(--danger); font-size: 12px; margin-top: 3px; }
|
||||
@@ -91,3 +91,9 @@ a.btn-link { display: inline-block; margin-top: 16px; text-align: center; }
|
||||
.success-icon { font-size: 48px; }
|
||||
.done-logo { width: auto; border-radius: 3px; margin-bottom: 16px; }
|
||||
.alert { background: #fdecea; color: var(--danger); padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; font-size: 14px; }
|
||||
|
||||
.sconti { border-top: 1px solid var(--border); margin: 8px 0 16px; padding-top: 12px; }
|
||||
.sconti-titolo { font-weight: 600; margin: 0 0 8px; }
|
||||
.sconti-riga { display: flex; justify-content: space-between; font-size: 14px; color: var(--muted); padding: 2px 0; }
|
||||
.sconti-riga.forte { color: var(--text); font-weight: 600; }
|
||||
.nota-trasporto { margin-top: 6px; font-size: 13px; color: #7a5d00; background: #fff8d6; border-radius: 6px; padding: 6px 8px; }
|
||||
|
||||
Reference in New Issue
Block a user