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>
359 lines
25 KiB
Vue
359 lines
25 KiB
Vue
<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 l’entente' : 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 l’entente 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 l’entente enregistré' }) } catch (err) { $q.notify({ type: 'negative', message: 'Échec : ' + err.message }) } finally { entente.saving = false }
|
||
}
|
||
reader.readAsDataURL(f)
|
||
}
|
||
function clearEntente () {
|
||
$q.dialog({ title: 'Retirer l’entente', message: 'Retirer le lien/PDF des conditions ? Le formulaire n’affichera 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>
|