feat(ops): ticket Statut control (Complété/Annulé) + sale activation → draft invoice

Add a "Statut" button on the ticket (distinct from the snooze "Reporter"):
Ouvert / En attente / Complété (Resolved) / Annulé (Closed). Additive — does not
touch the shared TicketStatusControl.

For a ticket with a linked open install job, an explicit "Activer + facture
brouillon" button (also offered when set to Complété) reuses the EXISTING hub
flow: POST /dispatch/job-status Completed → chain unblock → activateSubscription
ForJob → one consolidated prorated DRAFT invoice, gated by PRORATION_WRITE /
BILLING_APPROVAL_GATE (never auto-charges; billing manager edits/approves). No
new billing logic, no hub change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-10 15:04:08 -04:00
parent 28a5789fe0
commit e1d3212bf7
3 changed files with 133 additions and 9 deletions

View File

@ -98,6 +98,18 @@ export async function updateJob (name, payload) {
return apiPut('Dispatch Job', name, payload) return apiPut('Dispatch Job', name, payload)
} }
// Complète un job VIA le chaînage canonique du hub (déblocage dépendances + activation abonnement
// + facture prorata BROUILLON en bout de chaîne, gatée PRORATION_WRITE / BILLING_APPROVAL_GATE).
// Réutilisé par la complétion d'un ticket de VENTE/installation. Renvoie { activated, invoices, pending_approval? }.
export async function setJobStatusChain (job, status) {
const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job, status }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || ('Dispatch API ' + res.status))
return data
}
export async function createJob (payload) { export async function createJob (payload) {
const res = await authFetch( const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Job`, `${BASE_URL}/api/resource/Dispatch%20Job`,

View File

@ -0,0 +1,39 @@
<script setup>
/**
* TicketStatusMenu bouton « Statut » du ticket (distinct du bouton « Reporter » = snooze).
*
* Change le STATUT du billet Issue, avec la distinction demandée : fermer en **Complété**
* (résolu avec succès déclenche la fulfillment de vente : activation + facture) VS **Annulé**
* (fermé sans suite). Émet 'set-status' ; le parent écrit et gère la suite (activation vente).
* Purement additif ne touche PAS TicketStatusControl (partagé, = report/snooze).
*/
import { computed } from 'vue'
const props = defineProps({
status: { type: String, default: '' },
})
const emit = defineEmits(['set-status'])
// Complété = Resolved (résolu) · Annulé = Closed (fermé sans suite). Les autres = états de travail.
const META = {
Open: { label: 'Ouvert', color: 'blue-6', icon: 'radio_button_unchecked' },
Replied: { label: 'Répondu', color: 'teal-6', icon: 'reply' },
'On Hold': { label: 'En attente', color: 'orange-7', icon: 'pause_circle' },
Resolved: { label: 'Complété', color: 'green-7', icon: 'check_circle' },
Closed: { label: 'Annulé', color: 'grey-6', icon: 'cancel' },
}
const cur = computed(() => META[props.status] || { label: props.status || 'Statut', color: 'grey-7', icon: 'flag' })
</script>
<template>
<q-btn-dropdown unelevated no-caps :color="cur.color" :icon="cur.icon" :label="'Statut : ' + cur.label" dense>
<q-list dense style="min-width:210px">
<q-item-label header class="q-py-xs">Changer le statut</q-item-label>
<q-item clickable v-close-popup @click="emit('set-status', 'Open')"><q-item-section avatar><q-icon name="radio_button_unchecked" color="blue-6" /></q-item-section><q-item-section>Ouvert</q-item-section></q-item>
<q-item clickable v-close-popup @click="emit('set-status', 'On Hold')"><q-item-section avatar><q-icon name="pause_circle" color="orange-7" /></q-item-section><q-item-section>En attente</q-item-section></q-item>
<q-separator />
<q-item clickable v-close-popup @click="emit('set-status', 'Resolved')"><q-item-section avatar><q-icon name="check_circle" color="green-7" /></q-item-section><q-item-section><q-item-label>Complété</q-item-label><q-item-label caption>Résolu pour une vente : active + facture</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="emit('set-status', 'Closed')"><q-item-section avatar><q-icon name="cancel" color="grey-6" /></q-item-section><q-item-section><q-item-label>Annulé</q-item-label><q-item-label caption>Fermé sans suite (pas de facturation)</q-item-label></q-item-section></q-item>
</q-list>
</q-btn-dropdown>
</template>

View File

@ -1,9 +1,14 @@
<template> <template>
<!-- Quick actions --> <!-- Quick actions -->
<!-- Flow retiré : la relance de flow sera pilotée par l'automatisation (Réglages généraux), pas manuellement depuis le ticket. --> <!-- Flow retiré : la relance de flow sera pilotée par l'automatisation (Réglages généraux), pas manuellement depuis le ticket. -->
<div class="issue-actions q-mb-sm"> <div class="issue-actions q-mb-sm row items-center q-gutter-xs">
<TicketStatusMenu :status="doc.status" @set-status="onSetStatus" />
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status" <TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" /> @changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
<!-- Fulfillment vente : complète l'installation liée → active l'abonnement + facture prorata BROUILLON (flux existant, gaté) -->
<q-btn v-if="saleJob" dense unelevated no-caps color="deep-purple-6" icon="paid" label="Activer + facture brouillon" :loading="activating" @click="activateSale">
<q-tooltip>Complète l'installation « {{ saleJob.subject || saleJob.name }} » → active l'abonnement en attente + crée une facture prorata (brouillon, à approuver)</q-tooltip>
</q-btn>
</div> </div>
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic). <!-- Client lié (comme une conversation : cherchable par nom, association 1 clic).
@ -39,7 +44,7 @@
<q-icon name="engineering" size="18px" :color="assignedPeople.length ? 'indigo-6' : 'grey-5'" /> <q-icon name="engineering" size="18px" :color="assignedPeople.length ? 'indigo-6' : 'grey-5'" />
<span class="tech-banner-lbl">Technicien</span> <span class="tech-banner-lbl">Technicien</span>
<template v-if="assignedPeople.length"> <template v-if="assignedPeople.length">
<q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><q-avatar color="indigo-6" text-color="white" size="20px">{{ initials(p) }}</q-avatar>{{ p }}</q-chip> <q-chip v-for="p in assignedPeople" :key="p" dense color="indigo-1" text-color="indigo-9"><UserAvatar :name="p" :size="20" card class="q-mr-xs" />{{ p }}</q-chip>
</template> </template>
<span v-else class="text-caption text-grey-6">Non assigné</span> <span v-else class="text-caption text-grey-6">Non assigné</span>
</div> </div>
@ -86,7 +91,10 @@
</div> </div>
<div class="if-row" v-if="doc.raised_by"> <div class="if-row" v-if="doc.raised_by">
<span class="if-label">Soumis par</span> <span class="if-label">Soumis par</span>
<span class="if-value">{{ doc.raised_by }}</span> <span class="if-value" style="display:flex;align-items:center;gap:6px;min-width:0">
<UserAvatar :email="doc.raised_by" :name="doc.raised_by" :size="20" card />
<span class="ellipsis">{{ doc.raised_by }}</span>
</span>
</div> </div>
</div> </div>
@ -230,7 +238,7 @@
<div class="thread-wrap"> <div class="thread-wrap">
<div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind"> <div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind">
<div class="msg-head"> <div class="msg-head">
<span class="msg-avatar">{{ initials(m.who) }}</span> <UserAvatar :email="m.email" :name="m.who" :size="22" card class="q-mr-xs" />
<span class="msg-who">{{ m.who }}</span> <span class="msg-who">{{ m.who }}</span>
<!-- Pastille de visibilité CLIQUABLE : bascule Interne Public (relabel seul, aucun courriel) --> <!-- Pastille de visibilité CLIQUABLE : bascule Interne Public (relabel seul, aucun courriel) -->
<span class="msg-vis" :class="[m.public ? 'vis-pub' : 'vis-int', { 'vis-busy': togglingMsg === m.name }]" <span class="msg-vis" :class="[m.public ? 'vis-pub' : 'vis-int', { 'vis-busy': togglingMsg === m.name }]"
@ -331,8 +339,11 @@ import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue' import TaskNode from 'src/components/shared/TaskNode.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' import TagEditor from 'src/components/shared/TagEditor.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue' import AssignmentField from 'src/components/shared/AssignmentField.vue'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré) import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré)
import { useIdentity } from 'src/composables/useIdentity' // identité unifiée : dé-dup assignation (alias/tech = même personne) + nom complet import { useIdentity } from 'src/composables/useIdentity' // identité unifiée : dé-dup assignation (alias/tech = même personne) + nom complet
import TicketStatusMenu from 'src/components/shared/TicketStatusMenu.vue' // bouton « Statut » (Complété/Annulé) distinct du snooze « Reporter »
import { setJobStatusChain } from 'src/api/dispatch' // complète le job lié active abonnement + facture brouillon (flux existant, gaté)
// Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) montés ici pour la tâche dispatch principale du ticket. // Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) montés ici pour la tâche dispatch principale du ticket.
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue' import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue' import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
@ -364,6 +375,18 @@ const senderFull = computed(() => ((userName.value || '').trim() || (authStore.u
const sendingReply = ref(false) const sendingReply = ref(false)
const replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client const replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client
const EMAIL_RE = /.+@.+\..+/ const EMAIL_RE = /.+@.+\..+/
// Ticket INITIÉ par le client ? (soumis par un courriel externe, OU le client a écrit dans le fil).
// si oui, la case « Envoyer au client » est PRÉ-COCHÉE (mais tout envoi public exige une confirmation à la soumission).
// Ticket interne (créé par un agent) case décochée par défaut = note interne.
const clientInitiated = computed(() => {
const me = (authStore.user || '').toLowerCase()
const rb = String(props.doc?.raised_by || '').trim().toLowerCase()
if (rb && EMAIL_RE.test(rb) && rb !== me) return true
return (props.comms || []).some(c => String(c.sent_or_received || '') === 'Received')
})
const clientEmailHint = computed(() => { const rb = props.doc?.raised_by; return (rb && EMAIL_RE.test(rb)) ? rb : '' })
// Cale le défaut de la case à l'ouverture d'un ticket (client coché · interne décoché). Ne clobbe pas un basculement manuel en cours.
watch(() => props.docName, () => { replyToClient.value = clientInitiated.value }, { immediate: true })
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
const showProjectWizard = ref(false) const showProjectWizard = ref(false)
const availReasonOpen = ref(false) const availReasonOpen = ref(false)
@ -429,8 +452,8 @@ async function toggleMsgVisibility (m) {
const thread = computed(() => { const thread = computed(() => {
const items = [] const items = []
const ov = msgPublic.value const ov = msgPublic.value
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, name: c.name, who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm', public: (c.name in ov) ? !!ov[c.name] : true }) for (const c of props.comms || []) items.push({ id: 'e-' + c.name, name: c.name, email: c.sender || c.owner || '', who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm', public: (c.name in ov) ? !!ov[c.name] : true })
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, name: c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note', public: (c.name in ov) ? !!ov[c.name] : false }) for (const c of props.comments || []) items.push({ id: 'c-' + c.name, name: c.name, email: c.comment_email || c.comment_by || '', who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note', public: (c.name in ov) ? !!ov[c.name] : false })
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || ''))) return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
}) })
@ -598,6 +621,43 @@ const primaryJob = computed(() => {
if (!jobs.length) return null if (!jobs.length) return null
return jobs.find(j => (j.latitude && j.longitude) || j.legacy_ticket_id) || jobs.find(j => !j.parent_job) || jobs[0] return jobs.find(j => (j.latitude && j.longitude) || j.legacy_ticket_id) || jobs.find(j => !j.parent_job) || jobs[0]
}) })
// Statut du ticket (bouton « Statut » : Complété/Annulé) + fulfillment de vente
const activating = ref(false)
// Tâche liée encore ouverte (installation à compléter) cible du bouton « Activer + facture brouillon ».
const saleJob = computed(() => (props.dispatchJobs || []).find(j => !['Completed', 'Cancelled'].includes(j.status)) || null)
async function onSetStatus (status) {
try {
await updateDoc('Issue', props.docName, { status })
props.doc.status = status
Notify.create({ type: 'positive', message: 'Statut : ' + status, timeout: 1400 })
emit('reply-sent', props.docName)
// « Complété » sur une vente avec une installation à compléter propose l'activation (pas d'auto : l'agent confirme).
if (status === 'Resolved' && saleJob.value) setTimeout(activateSale, 300)
} catch (e) { Notify.create({ type: 'negative', message: 'Statut : ' + (e.message || e) }) }
}
// Réutilise le flux hub EXISTANT : compléter le job lié déblocage chaîne + activation abonnement + facture prorata BROUILLON
// (gaté PRORATION_WRITE / BILLING_APPROVAL_GATE). N'AUTO-FACTURE jamais : draft à approuver par la facturation.
function activateSale () {
const j = saleJob.value; if (!j || activating.value) return
Dialog.create({
title: 'Activer la vente',
message: `Compléter l'installation « ${j.subject || j.name} » et activer l'abonnement en attente ?<br><br>Une <b>facture prorata BROUILLON</b> sera créée (à éditer/approuver par la facturation) si la facturation est armée — sinon l'abonnement est activé sans facture.`,
html: true, cancel: 'Annuler', ok: { label: 'Activer', color: 'deep-purple-6' },
}).onOk(async () => {
activating.value = true
try {
const r = await setJobStatusChain(j.name, 'Completed')
const acts = (r.activated || []).length; const inv = (r.invoices || [])[0]
let msg
if (r.pending_approval) msg = 'Activation placée EN ATTENTE D\'APPROBATION (facturation)'
else if (inv) msg = acts + ' abonnement(s) activé(s) · facture brouillon ' + inv.name
else if (acts) msg = acts + ' abonnement(s) activé(s) · facture non émise (écriture désarmée)'
else msg = 'Installation complétée — aucun abonnement en attente à activer'
Notify.create({ type: 'positive', message: msg, timeout: 6000 })
j.status = 'Completed'; emit('dispatch-updated', j.name)
} catch (e) { Notify.create({ type: 'negative', message: 'Activation : ' + (e.message || e), timeout: 5000 }) } finally { activating.value = false }
})
}
// Signaux « terrain » affichage AUTO de la section ; sinon bascule manuelle (« nécessite une visite sur place »). // Signaux « terrain » affichage AUTO de la section ; sinon bascule manuelle (« nécessite une visite sur place »).
const onsiteAuto = computed(() => { const onsiteAuto = computed(() => {
const j = primaryJob.value; if (!j) return false const j = primaryJob.value; if (!j) return false
@ -857,12 +917,25 @@ async function resolveClientEmail () {
return '' return ''
} }
// Soumission : une note INTERNE part directement ; un envoi PUBLIC (au client) demande une CONFIRMATION explicite
// (garde-fou, surtout quand la case est pré-cochée pour un ticket initié par le client).
function sendReply () {
if (!replyContent.value?.trim() || sendingReply.value) return
if (!replyToClient.value) { doSendReply(false); return }
Dialog.create({
title: 'Répondre au client ?',
message: `Ce message sera <b>visible par le client</b> et lui sera envoyé par courriel${clientEmailHint.value ? ' (' + clientEmailHint.value + ')' : ''}. Pour une note interne, décochez « Envoyer au client ».`,
html: true,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'deep-orange-9', unelevated: true, label: 'Envoyer au client', icon: 'send' },
}).onOk(() => doSendReply(true))
}
// UNE seule action. Défaut (case décochée) = NOTE INTERNE (privée). Cochée = PUBLIC + courriel au client. // UNE seule action. Défaut (case décochée) = NOTE INTERNE (privée). Cochée = PUBLIC + courriel au client.
// La visibilité est écrite EXPLICITEMENT dans l'overlay (interne=false / public=true) corrige le bug « note interne // La visibilité est écrite EXPLICITEMENT dans l'overlay (interne=false / public=true) corrige le bug « note interne
// affichée Client » (le défaut par doctype classait toute Communication en public). // affichée Client » (le défaut par doctype classait toute Communication en public).
async function sendReply () { async function doSendReply (isPublic) {
if (!replyContent.value?.trim() || sendingReply.value) return if (!replyContent.value?.trim() || sendingReply.value) return
const isPublic = replyToClient.value
const text = replyContent.value.trim() const text = replyContent.value.trim()
sendingReply.value = true sendingReply.value = true
try { try {
@ -900,7 +973,7 @@ async function sendReply () {
} }
} }
replyContent.value = '' replyContent.value = ''
replyToClient.value = false // retour au défaut sûr (interne) après envoi replyToClient.value = clientInitiated.value // retour au défaut selon l'origine (client coché · interne décoché)
emit('reply-sent', props.docName) emit('reply-sent', props.docName)
Notify.create({ Notify.create({
type: 'positive', type: 'positive',