+
+
{ if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
+
+
+ Complète l'installation « {{ saleJob.subject || saleJob.name }} » → active l'abonnement en attente + crée une facture prorata (brouillon, à approuver)
+
((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 ?
Une facture prorata BROUILLON 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 visible par le client 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',