gigafibre-fsm/apps/ops/src/pages/SubcontractorsPage.vue
louispaulb 7ab71ce6a8 refactor(ui): T1 (suite) — unifier les formatters variants vers les canoniques (formats cohérents)
Sur autorisation user (changement d'affichage OK si ça améliore l'UI) :
- date+heure → formatDateTimeShort : Settings, GiftsInventory, EmailQueue
- date → formatDate : Subcontractors
- initials → staffInitials (1er+dernier) : Historique
- fmtDur → useHelpers.fmtDur («30m»/«1h30») : RendezVous
LAISSÉS (UX volontaire/niche) : ConversationPanel formatDate (relatif auj/hier), PublishScheduleModal (d/m compact),
TechTasks fmtDate (weekday terrain), Planif fmtH (axe timeline), SupplierInvoices fmtMoney (multi-devise).
Build vert, leak 0 ; 12 copies locales éliminées au total (T1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:42:17 -04:00

359 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-page class="q-pa-md" style="max-width:1000px;margin:0 auto">
<div class="text-h5 q-mb-md">Sous-traitants payés à l'acte</div>
<!-- Générer / envoyer un lien de saisie -->
<q-card flat bordered class="q-mb-md">
<q-card-section>
<div class="text-subtitle1 q-mb-sm">Envoyer un lien de saisie</div>
<div class="row q-col-gutter-sm items-end">
<q-input class="col-12 col-sm-3" dense outlined v-model="send.ticket" label="N° ticket" />
<q-input class="col-12 col-sm-3" dense outlined v-model="send.sub" label="Sous-traitant" />
<q-input class="col-12 col-sm-3" dense outlined v-model="send.phone" label="Tél (SMS, optionnel)" />
<div class="col-12 col-sm-3">
<q-btn color="primary" :label="send.phone ? 'Envoyer (SMS)' : 'Générer le lien'" :loading="send.loading" :disable="!send.ticket" @click="doSend" class="full-width" />
</div>
</div>
<div v-if="send.link" class="q-mt-sm">
<q-input dense outlined readonly :model-value="send.link" @focus="e => e.target.select()">
<template #append><q-btn flat dense round icon="content_copy" @click="copy(send.link)" /></template>
</q-input>
<div v-if="send.sent" class="text-positive text-caption q-mt-xs">Lien envoyé par {{ send.sent }}.</div>
</div>
</q-card-section>
</q-card>
<!-- Entente de services : lien/PDF des conditions montrées au sous-traitant à la certification -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="q-py-sm">
<div class="row items-center q-col-gutter-sm">
<div class="col-12 col-sm-auto text-subtitle2">Entente de services</div>
<div class="col">
<span v-if="entente.url" class="text-caption">Conditions actuelles :
<a :href="entente.url" target="_blank" rel="noopener">{{ entente.isPdf ? 'PDF de lentente' : entente.url }}</a>
</span>
<span v-else class="text-caption text-grey">Aucune — le formulaire affiche « entente de services » sans lien.</span>
</div>
<q-btn v-if="canApprove" class="col-auto" dense flat icon="edit" :label="entente.edit ? 'Fermer' : 'Modifier'" @click="entente.edit = !entente.edit" />
</div>
<div v-if="canApprove && entente.edit" class="q-mt-sm">
<div class="row q-col-gutter-sm items-end">
<q-input class="col" dense outlined v-model="entente.input" label="Lien web des conditions" placeholder="https://…" @keyup.enter="saveEntenteUrl" />
<q-btn class="col-auto" dense unelevated color="primary" label="Enregistrer le lien" :loading="entente.saving" :disable="!entente.input" @click="saveEntenteUrl" />
</div>
<div class="row items-center q-gutter-sm q-mt-xs">
<q-btn dense outline icon="upload_file" label="Téléverser un PDF" :loading="entente.saving" @click="$refs.entFile.click()" />
<input ref="entFile" type="file" accept="application/pdf" style="display:none" @change="onEntPdf" />
<q-space />
<q-btn v-if="entente.url" dense flat color="negative" label="Retirer" @click="clearEntente" />
</div>
<div class="text-caption text-grey q-mt-xs">Lien web (page de conditions) ou PDF (max 10 Mo). Affiché sur le formulaire du sous-traitant, près de la case « Je certifie… ».</div>
</div>
</q-card-section>
</q-card>
<q-tabs v-model="tab" dense align="left" class="q-mb-sm text-primary" active-color="primary" indicator-color="primary">
<q-tab name="bareme" label="Barème" />
<q-tab name="soum" label="Soumissions" />
<q-tab name="releve" label="Relevés / Paie" />
</q-tabs>
<!-- Barème : prix par acte -->
<q-card v-show="tab === 'bareme'" flat bordered>
<q-card-section>
<div class="row items-center q-gutter-sm q-mb-sm">
<q-select dense outlined style="min-width:210px" v-model="rates.editSub" :options="editSubOptions" emit-value map-options label="Barème à éditer" @update:model-value="loadRates" />
<q-select dense outlined style="min-width:250px" v-model="addTechModel" :options="techOptions" use-input input-debounce="200" clearable emit-value map-options label="+ Ajouter (chercher un technicien)" @filter="filterTechs" @update:model-value="onAddTech">
<template #no-option><q-item><q-item-section class="text-grey-6">Tapez pour chercher un technicien…</q-item-section></q-item></template>
</q-select>
<q-space />
<q-btn v-if="rates.editSub" flat dense no-caps icon="content_copy" label="Copier depuis" color="primary">
<q-menu auto-close>
<q-list dense style="min-width:190px">
<q-item clickable @click="copyFrom('')"><q-item-section>Barème global (modèle)</q-item-section></q-item>
<template v-for="n in subNames" :key="n"><q-item v-if="n !== rates.editSub" clickable @click="copyFrom(n)"><q-item-section>{{ n }}</q-item-section></q-item></template>
</q-list>
</q-menu>
</q-btn>
<q-btn color="primary" dense unelevated label="Enregistrer" :loading="rates.saving" @click="saveRatesNow" />
</div>
<div class="text-caption text-grey q-mb-sm">
<template v-if="rates.editSub"><q-icon name="person" size="14px" /> Liste de prix de <b>{{ rates.editSub }}</b> — hérite du barème global ; un prix modifié le surcharge. 0 $ = non défini.</template>
<template v-else>Barème global — modèle de base pour tous les sous-traitants. 0 $ = non défini.</template>
</div>
<q-inner-loading :showing="rates.loading" />
<q-list separator>
<q-item v-for="r in rates.list" :key="r.code">
<q-item-section>
<q-item-label>{{ r.label }}<q-badge v-if="rates.editSub && r._override" color="green-2" text-color="green-9" label="perso" class="q-ml-xs" /></q-item-label>
<q-item-label caption>
{{ unitLabel(r.unit) }}<span v-if="r.grp"> · groupe « {{ r.grp }} »</span><span v-if="r.implies"> · coche « {{ r.implies }} »</span>
</q-item-label>
</q-item-section>
<q-item-section side style="min-width:128px">
<q-input dense outlined type="number" step="0.01" min="0" v-model.number="r.price" prefix="$" class="no-spin" @focus="ev => { rateFocus(r); ev.target && ev.target.select && ev.target.select() }" @blur="rateBlur(r)" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<!-- Soumissions : revue -->
<q-card v-show="tab === 'soum'" flat bordered>
<q-card-section>
<div class="row items-center q-mb-sm q-gutter-sm">
<q-input dense outlined v-model="soum.ticket" label="Filtrer par n° ticket" clearable style="max-width:240px" @keyup.enter="loadSubs" @clear="loadSubs" />
<q-btn flat dense round icon="search" @click="loadSubs" />
<q-space /><q-btn flat dense round icon="refresh" @click="loadSubs" />
</div>
<q-inner-loading :showing="soum.loading" />
<div v-if="!soum.loading && !soum.list.length" class="text-grey q-pa-lg text-center">Aucune soumission.</div>
<q-list separator>
<q-item v-for="s in soum.list" :key="s.id">
<q-item-section>
<q-item-label class="text-weight-medium">{{ s.ref }} · ticket #{{ s.ticket }}</q-item-label>
<q-item-label caption>{{ s.subcontractor || '' }} · {{ fmtDate(s.created_at) }}</q-item-label>
<q-item-label caption :class="s.status === 'infructueuse' ? 'text-warning' : ''">{{ actsSummary(s) }}</q-item-label>
<q-item-label v-if="s.notes" caption class="text-italic text-grey-8">📝 {{ s.notes }}</q-item-label>
<div v-if="photosOf(s).length" class="row q-gutter-sm q-mt-xs">
<div v-for="(p, i) in photosOf(s)" :key="i" class="acte-ph">
<img :src="imgUrl(p.file)" class="acte-thumb" @click="viewImg(p.file)" />
<a v-if="p.lat" :href="mapsUrl(p.lat, p.lon)" target="_blank" rel="noopener" class="acte-geo" :title="'Position de la photo (±' + (p.acc || '?') + ' m)'" @click.stop>📍</a>
</div>
</div>
</q-item-section>
<q-item-section side top class="text-right">
<q-chip dense :color="statusColor(s.status)" text-color="white" class="q-ma-none">{{ statusLabel(s.status) }}</q-chip>
<div class="text-weight-bold q-mt-xs">{{ money(s.amount) }}</div>
<div v-if="canApprove && s.status === 'soumis'" class="q-mt-xs q-gutter-xs">
<q-btn dense unelevated size="sm" color="green-7" label="Approuver" @click="doApprove(s, 'approve')" />
<q-btn dense outline size="sm" color="grey-7" label="Refuser" @click="doApprove(s, 'reject')" />
</div>
<div v-else-if="s.approved_by" class="text-caption text-grey-7 q-mt-xs">par {{ s.approved_by }}</div>
<div v-if="s.reject_reason" class="text-caption text-warning">{{ s.reject_reason }}</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<!-- Relevés / Paie : autofacturation par sous-traitant + période -->
<q-card v-show="tab === 'releve'" flat bordered>
<q-card-section>
<div class="row q-col-gutter-sm items-end q-mb-md">
<q-select class="col-12 col-sm-4" dense outlined v-model="rel.sub" :options="subNames" label="Sous-traitant" use-input input-debounce="0" new-value-mode="add-unique" @filter="filterSubs" />
<q-input class="col-6 col-sm-3" dense outlined type="date" v-model="rel.from" label="Du" stack-label />
<q-input class="col-6 col-sm-3" dense outlined type="date" v-model="rel.to" label="Au" stack-label />
<div class="col-12 col-sm-2"><q-btn color="primary" class="full-width" label="Relevé" :loading="rel.loading" :disable="!rel.sub" @click="loadReleve" /></div>
</div>
<!-- statut fiscal du sous-traitant (sert à l'autofacturation) -->
<q-banner v-if="rel.data" dense rounded class="bg-grey-2 q-mb-md">
<div class="row items-center q-col-gutter-md">
<q-toggle class="col-auto" v-model="cfg.registrant" label="Inscrit TPS/TVQ" :disable="!canApprove" />
<q-input v-if="cfg.registrant" class="col-auto" dense outlined v-model="cfg.gst_no" label="N° TPS" :disable="!canApprove" style="min-width:160px" />
<q-input v-if="cfg.registrant" class="col-auto" dense outlined v-model="cfg.qst_no" label="N° TVQ" :disable="!canApprove" style="min-width:160px" />
<q-input class="col-auto" dense outlined v-model="cfg.email" label="Courriel (copie)" :disable="!canApprove" style="min-width:200px" />
<q-btn v-if="canApprove" class="col-auto" flat dense color="primary" label="Enregistrer le statut" :loading="cfg.saving" @click="saveCfg" />
</div>
<div class="text-caption text-grey-7 q-mt-xs">{{ cfg.registrant ? 'Taxes ajoutées au relevé (autofacturation d\'un fournisseur inscrit).' : 'Aucune taxe — petit fournisseur / non inscrit.' }}</div>
</q-banner>
<q-inner-loading :showing="rel.loading" />
<template v-if="rel.data">
<q-list separator>
<q-item v-for="l in rel.data.lines" :key="l.id">
<q-item-section>
<q-item-label>{{ l.ref }} · ticket #{{ l.ticket }}</q-item-label>
<q-item-label caption>{{ fmtDate(l.created_at) }}<span v-if="l.paid_at"> · déjà payé</span></q-item-label>
</q-item-section>
<q-item-section side><div class="text-weight-medium">{{ money(l.amount) }}</div></q-item-section>
</q-item>
</q-list>
<div v-if="!rel.data.lines.length" class="text-grey q-pa-lg text-center">Aucune soumission approuvée sur la période.</div>
<q-separator class="q-my-sm" />
<div class="releve-tot">
<div class="row"><div class="col">Sous-total ({{ rel.data.count }} acte{{ rel.data.count > 1 ? 's' : '' }})</div><div class="col-auto">{{ money(rel.data.subtotal) }}</div></div>
<div v-if="rel.data.config.registrant" class="row text-grey-8"><div class="col">TPS (5 %)</div><div class="col-auto">{{ money(rel.data.tps) }}</div></div>
<div v-if="rel.data.config.registrant" class="row text-grey-8"><div class="col">TVQ (9,975 %)</div><div class="col-auto">{{ money(rel.data.tvq) }}</div></div>
<div class="row text-weight-bold text-subtitle1 q-mt-xs"><div class="col">Total</div><div class="col-auto">{{ money(rel.data.total) }}</div></div>
</div>
<div class="row q-gutter-sm q-mt-md justify-end">
<q-btn outline color="primary" icon="download" label="Export CSV (Acomba)" @click="exportCsv" :disable="!rel.data.count" />
<q-btn v-if="canApprove" unelevated color="info" icon="paid" label="Marquer payé" @click="confirmPaid" :disable="!rel.data.count" />
</div>
</template>
</q-card-section>
</q-card>
<q-dialog v-model="img.show">
<q-card><q-img :src="img.url" style="max-width:90vw;max-height:85vh" fit="contain" /></q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { usePermissions } from 'src/composables/usePermissions'
import { formatMoney as money, formatDate as fmtDate } from 'src/composables/useFormatters'
import { getRates, saveRates, listSubmissions, sendLink, approve, imgUrl, getSubs, saveSub, getReleve, markPaid, exportUrl, getEntente, saveEntente } from 'src/api/subcontractor'
import { listDocs } from 'src/api/erp'
const $q = useQuasar()
const { isAdmin, hasGroup } = usePermissions()
const canApprove = computed(() => isAdmin.value || hasGroup('superviseur') || hasGroup('supervisor'))
const tab = ref('bareme')
const send = reactive({ ticket: '', sub: '', phone: '', link: '', sent: null, loading: false })
const rates = reactive({ list: [], loading: false, saving: false, editSub: null }) // editSub=null → barème global ; sinon liste de prix de ce sous-traitant
const techOptions = ref([]); const addTechModel = ref(null); let _allTechs = []
const editSubOptions = computed(() => [{ label: '— Barème global (modèle) —', value: null }, ...subNames.value.map(n => ({ label: n, value: n }))])
const soum = reactive({ list: [], ticket: '', loading: false })
const img = reactive({ show: false, url: '' })
const rel = reactive({ sub: '', from: '', to: '', loading: false, data: null })
const cfg = reactive({ registrant: false, gst_no: '', qst_no: '', email: '', saving: false })
const subNames = ref([])
let _allNames = []
const entente = reactive({ url: '', isPdf: false, edit: false, input: '', saving: false })
function unitLabel (u) { return ({ case: 'Case (oui/non)', qte: 'Quantité', metres: 'Mètres', heures: 'Heures' })[u] || u }
// money() unifié via useFormatters (import ci-dessus) — format fr-CA cohérent
// fmtDate → useFormatters.formatDate (date canonique, source unique)
function statusColor (s) { return s === 'approuvé' ? 'green-7' : s === 'payé' ? 'blue-7' : s === 'refusé' ? 'red-7' : s === 'infructueuse' ? 'orange-8' : 'grey-6' }
function statusLabel (s) { return s === 'infructueuse' ? 'infructueuse' : (s || 'soumis') }
function photosOf (s) { return (Array.isArray(s.photos) ? s.photos : []).map(p => (typeof p === 'string' ? { file: p } : p)) }
function mapsUrl (lat, lon) { return 'https://www.google.com/maps?q=' + lat + ',' + lon }
function actsSummary (s) {
const a = s.acts || {}
if (s.status === 'infructueuse' || a._outcome === 'infructueuse') return 'Visite infructueuse — ' + (a._reason || '')
const codes = Object.keys(a).filter(k => k[0] !== '_')
return codes.length ? codes.length + ' acte' + (codes.length > 1 ? 's' : '') + ' : ' + codes.join(', ') : 'Aucun acte'
}
function copy (txt) { try { navigator.clipboard.writeText(txt); $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 }) } catch (e) { /* */ } }
function viewImg (f) { img.url = imgUrl(f); img.show = true }
async function doSend () {
send.loading = true; send.link = ''; send.sent = null
try {
const r = await sendLink(send.ticket.trim(), send.sub.trim(), send.phone.trim() || undefined)
send.link = r.link; send.sent = r.sent
$q.notify({ type: 'positive', message: r.sent ? ('Lien envoyé par ' + r.sent) : 'Lien généré', timeout: 1500 })
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) } finally { send.loading = false }
}
async function loadRates () {
rates.loading = true
try { const r = await getRates(rates.editSub || undefined); rates.list = (r.rates || []).map(x => ({ ...x, price: Number(x.price) || 0 })) } catch (e) { $q.notify({ type: 'negative', message: 'Barème : ' + e.message }) } finally { rates.loading = false }
}
async function saveRatesNow () {
rates.saving = true
try {
await saveRates(rates.list.map(r => ({ code: r.code, price: Number(r.price) || 0 })), rates.editSub || undefined)
$q.notify({ type: 'positive', message: rates.editSub ? ('Liste de prix de ' + rates.editSub + ' enregistrée') : 'Barème global enregistré' })
await loadRates()
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) } finally { rates.saving = false }
}
// Le 0 « non défini » disparaît au focus (pour taper directement) et revient si laissé vide.
function rateFocus (r) { if (Number(r.price) === 0) r.price = null }
function rateBlur (r) { if (r.price === null || r.price === '' || Number.isNaN(Number(r.price))) r.price = 0 }
// Techniciens (Dispatch Technician) pour l'auto-suggest « ajouter un sous-traitant ».
async function loadTechs () {
try {
const rows = await listDocs('Dispatch Technician', { fields: ['name', 'full_name'], limit: 300 })
_allTechs = (rows || []).map(t => { const n = (t.full_name || t.name || '').trim(); return { label: n, value: n } }).filter(t => t.value).sort((a, b) => a.label.localeCompare(b.label))
techOptions.value = _allTechs.slice()
} catch (e) { $q.notify({ type: 'negative', message: 'Techniciens : ' + e.message }) }
}
function filterTechs (val, update) { update(() => { const v = (val || '').toLowerCase(); techOptions.value = v ? _allTechs.filter(t => t.label.toLowerCase().includes(v)) : _allTechs.slice() }) }
// Ajoute le technicien choisi comme sous-traitant (crée l'entrée) puis bascule l'édition sur SA liste de prix (héritée du global).
async function onAddTech (val) {
if (!val) return
try {
await saveSub({ name: val })
await loadSubsCfg()
rates.editSub = val
await loadRates()
addTechModel.value = null
$q.notify({ type: 'positive', message: val + ' ajouté — édite sa liste de prix (hérite du barème global).' })
} catch (e) { $q.notify({ type: 'negative', message: 'Ajout : ' + e.message }) }
}
// Copie les prix d'un modèle (barème global si src vide, sinon un autre sous-traitant) dans la liste éditée — sans enregistrer (revue puis « Enregistrer »).
async function copyFrom (src) {
try {
const r = await getRates(src || undefined)
const map = {}; for (const x of (r.rates || [])) map[x.code] = Number(x.price) || 0
rates.list.forEach(row => { if (map[row.code] != null) row.price = map[row.code] })
$q.notify({ type: 'info', message: 'Prix copiés depuis ' + (src || 'le barème global') + ' — vérifie puis « Enregistrer ».' })
} catch (e) { $q.notify({ type: 'negative', message: 'Copie : ' + e.message }) }
}
async function loadSubs () {
soum.loading = true
try { const r = await listSubmissions(soum.ticket && soum.ticket.trim()); soum.list = r.submissions || [] } catch (e) { $q.notify({ type: 'negative', message: 'Soumissions : ' + e.message }) } finally { soum.loading = false }
}
function doApprove (s, action) {
if (action === 'reject') { $q.dialog({ title: 'Refuser la soumission', message: 'Raison du refus (visible au sous-traitant) :', prompt: { model: '', type: 'text' }, cancel: true }).onOk(reason => sendApprove(s, 'reject', reason)); return }
sendApprove(s, 'approve')
}
async function sendApprove (s, action, reason) {
try { const r = await approve(s.id, action, reason); $q.notify({ type: 'positive', message: 'Soumission ' + r.status }); loadSubs() } catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) }
}
// ── Relevés / Paie (autofacturation) ──
function filterSubs (val, update) { update(() => { const v = (val || '').toLowerCase(); subNames.value = v ? _allNames.filter(n => n.toLowerCase().includes(v)) : _allNames.slice() }) }
async function loadSubsCfg () {
try { const r = await getSubs(); const names = new Set([...(r.names || []), ...((r.subs || []).map(s => s.name))]); _allNames = [...names].sort((a, b) => a.localeCompare(b)); subNames.value = _allNames.slice() } catch (e) { /* silencieux */ }
}
function applyCfg (c) { cfg.registrant = !!(c && c.registrant); cfg.gst_no = (c && c.gst_no) || ''; cfg.qst_no = (c && c.qst_no) || ''; cfg.email = (c && c.email) || '' }
async function loadReleve () {
if (!rel.sub) return
rel.loading = true
try { const r = await getReleve(rel.sub, rel.from || undefined, rel.to || undefined); rel.data = r.releve; applyCfg(r.releve.config) } catch (e) { $q.notify({ type: 'negative', message: 'Relevé : ' + e.message }) } finally { rel.loading = false }
}
async function saveCfg () {
cfg.saving = true
try { await saveSub({ name: rel.sub, registrant: cfg.registrant, gst_no: cfg.gst_no || null, qst_no: cfg.qst_no || null, email: cfg.email || null }); $q.notify({ type: 'positive', message: 'Statut fiscal enregistré' }); await loadReleve(); loadSubsCfg() } catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) } finally { cfg.saving = false }
}
function exportCsv () { try { const a = document.createElement('a'); a.href = exportUrl(rel.sub, rel.from || undefined, rel.to || undefined); a.download = ''; document.body.appendChild(a); a.click(); a.remove() } catch (e) { $q.notify({ type: 'negative', message: 'Export : ' + e.message }) } }
function confirmPaid () {
$q.dialog({ title: 'Marquer payé', message: 'Marquer ' + rel.data.count + ' soumission(s) approuvée(s) de « ' + rel.sub + ' » comme PAYÉES (' + money(rel.data.total) + ') ? Réservé et non réversible ici.', cancel: true, ok: { label: 'Marquer payé', color: 'blue-7' } }).onOk(async () => {
try { const r = await markPaid({ subcontractor: rel.sub, from: rel.from || undefined, to: rel.to || undefined }); $q.notify({ type: 'positive', message: (r.paid || 0) + ' soumission(s) payée(s)' }); await loadReleve(); loadSubs() } catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) }
})
}
// ── Entente de services ──
async function loadEntente () {
try { const r = await getEntente(); entente.url = r.url || ''; entente.isPdf = /\/acte\/entente\/doc/.test(entente.url); if (!entente.isPdf) entente.input = entente.url } catch (e) { /* silencieux */ }
}
async function saveEntenteUrl () {
entente.saving = true
try { const r = await saveEntente({ url: entente.input.trim() }); entente.url = r.url; entente.isPdf = false; entente.edit = false; $q.notify({ type: 'positive', message: 'Lien de lentente enregistré' }) } catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) } finally { entente.saving = false }
}
function onEntPdf (e) {
const f = e.target.files && e.target.files[0]; e.target.value = ''
if (!f) return
if (f.size > 10 * 1024 * 1024) { $q.notify({ type: 'negative', message: 'PDF trop volumineux (max 10 Mo)' }); return }
const reader = new FileReader()
reader.onload = async () => {
entente.saving = true
try { const r = await saveEntente({ pdf: reader.result }); entente.url = r.url; entente.isPdf = true; entente.input = ''; entente.edit = false; $q.notify({ type: 'positive', message: 'PDF de lentente enregistré' }) } catch (err) { $q.notify({ type: 'negative', message: 'Échec : ' + err.message }) } finally { entente.saving = false }
}
reader.readAsDataURL(f)
}
function clearEntente () {
$q.dialog({ title: 'Retirer lentente', message: 'Retirer le lien/PDF des conditions ? Le formulaire naffichera plus de lien.', cancel: true }).onOk(async () => {
try { await saveEntente({ clear: true }); entente.url = ''; entente.isPdf = false; entente.input = ''; $q.notify({ type: 'positive', message: 'Entente retirée' }) } catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + e.message }) }
})
}
onMounted(() => { loadRates(); loadSubs(); loadSubsCfg(); loadEntente(); loadTechs() })
</script>
<style scoped>
.acte-ph { position: relative; }
.acte-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid var(--ops-line, #e4e7eb); display: block; }
.acte-geo { position: absolute; left: 1px; bottom: 1px; font-size: 13px; text-decoration: none; filter: drop-shadow(0 0 2px #000); }
.releve-tot { max-width: 360px; margin-left: auto; }
.releve-tot .row { padding: 2px 0; }
/* Flèches haut/bas des champs nombre = inutiles → masquées */
.no-spin :deep(input[type=number]) { -moz-appearance: textfield; }
.no-spin :deep(input[type=number])::-webkit-outer-spin-button,
.no-spin :deep(input[type=number])::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
</style>