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:
parent
28a5789fe0
commit
e1d3212bf7
|
|
@ -98,6 +98,18 @@ export async function updateJob (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) {
|
||||
const res = await authFetch(
|
||||
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
||||
|
|
|
|||
39
apps/ops/src/components/shared/TicketStatusMenu.vue
Normal file
39
apps/ops/src/components/shared/TicketStatusMenu.vue
Normal 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>
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
<template>
|
||||
<!-- Quick actions -->
|
||||
<!-- 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"
|
||||
@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>
|
||||
|
||||
<!-- 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'" />
|
||||
<span class="tech-banner-lbl">Technicien</span>
|
||||
<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>
|
||||
<span v-else class="text-caption text-grey-6">Non assigné</span>
|
||||
</div>
|
||||
|
|
@ -86,7 +91,10 @@
|
|||
</div>
|
||||
<div class="if-row" v-if="doc.raised_by">
|
||||
<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>
|
||||
|
||||
|
|
@ -230,7 +238,7 @@
|
|||
<div class="thread-wrap">
|
||||
<div v-for="m in thread" :key="m.id" class="msg-bubble" :class="'msg-' + m.kind">
|
||||
<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>
|
||||
<!-- 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 }]"
|
||||
|
|
@ -331,8 +339,11 @@ import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
|||
import TaskNode from 'src/components/shared/TaskNode.vue'
|
||||
import TagEditor from 'src/components/shared/TagEditor.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 { 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.
|
||||
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.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 replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client
|
||||
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 showProjectWizard = ref(false)
|
||||
const availReasonOpen = ref(false)
|
||||
|
|
@ -429,8 +452,8 @@ async function toggleMsgVisibility (m) {
|
|||
const thread = computed(() => {
|
||||
const items = []
|
||||
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.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.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, 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 || '')))
|
||||
})
|
||||
|
||||
|
|
@ -598,6 +621,43 @@ const primaryJob = computed(() => {
|
|||
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]
|
||||
})
|
||||
// ── 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 »).
|
||||
const onsiteAuto = computed(() => {
|
||||
const j = primaryJob.value; if (!j) return false
|
||||
|
|
@ -857,12 +917,25 @@ async function resolveClientEmail () {
|
|||
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.
|
||||
// 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).
|
||||
async function sendReply () {
|
||||
async function doSendReply (isPublic) {
|
||||
if (!replyContent.value?.trim() || sendingReply.value) return
|
||||
const isPublic = replyToClient.value
|
||||
const text = replyContent.value.trim()
|
||||
sendingReply.value = true
|
||||
try {
|
||||
|
|
@ -900,7 +973,7 @@ async function sendReply () {
|
|||
}
|
||||
}
|
||||
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)
|
||||
Notify.create({
|
||||
type: 'positive',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user