gigafibre-fsm/apps/ops/src/composables/useWizardPublish.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

408 lines
16 KiB
JavaScript
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.

import { Notify } from 'quasar'
import { createJob } from 'src/api/dispatch'
import { getDoc, createDoc } from 'src/api/erp'
import { HUB_URL } from 'src/data/wizard-constants'
export function useWizardPublish ({ props, emit, state }) {
const {
publishing, wizardSteps, orderItems, orderMode,
contractNotes, requireAcceptance, acceptanceMethod,
clientPhone, clientEmail,
publishedDocName, publishedDocType, publishedDone,
pendingAcceptance, acceptanceLinkUrl, acceptanceLinkSent,
acceptanceSentVia, publishedJobCount,
sendTo, sendChannel,
publishedContractName,
} = state
async function resolveAddress () {
try {
const locName = props.issue?.service_location || props.customer?.service_location
if (locName) {
const loc = await getDoc('Service Location', locName)
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
}
} catch {}
return ''
}
async function publish () {
if (publishing.value) return
publishing.value = true
try {
const address = await resolveAddress()
const customer = props.issue?.customer || props.customer?.name || ''
const serviceLocation = props.issue?.service_location || props.customer?.service_location || ''
const today = new Date().toISOString().split('T')[0]
const onetimeItems = orderItems.value.filter(i => i.billing === 'onetime' && i.item_name)
const recurringItems = orderItems.value.filter(i => i.billing === 'recurring' && i.item_name)
let orderDocName = ''
let orderDocType = ''
const isQuotation = orderMode.value === 'quotation'
const needsAcceptance = requireAcceptance.value && isQuotation
const hasPhone = !!clientPhone.value
const hasEmail = !!clientEmail.value
const wizardContext = {
issue: props.issue?.name || '',
customer,
address,
service_location: serviceLocation,
}
// Step extras — each wizardStep with extra_fee > 0 becomes a one-time
// FEE-EXTRA line carrying the step subject so the agent can see what
// the charge covered on the invoice.
const stepExtraItems = wizardSteps.value
.filter(s => Number(s.extra_fee) > 0)
.map(s => {
const label = (s.extra_label || '').trim() || 'Extra'
const subject = s.subject || 'étape'
return {
item_name: `${label}${subject}`,
item_code: 'FEE-EXTRA',
qty: 1,
rate: Number(s.extra_fee),
description: `${label} sur l'étape « ${subject} »`,
applies_to_item: '',
}
})
// Create financial document
if (orderItems.value.some(i => i.item_name) || stepExtraItems.length) {
const allItems = [...onetimeItems, ...recurringItems].map(i => ({
item_name: i.item_name,
item_code: i.item_code || i.item_name,
qty: i.qty,
rate: i.rate,
description: i.billing === 'recurring'
? `${i.item_name}${i.rate}$/mois × ${i.contract_months || 12} mois`
: i.item_name,
// Per-line rebate binding — consumed by the invoice Jinja to net the
// rebate into its parent line for customer-facing docs. Empty for
// non-rebate lines; extra fields are ignored by Frappe if unused.
applies_to_item: i.applies_to_item || '',
})).concat(stepExtraItems)
const baseDoc = {
customer,
company: 'TARGO',
currency: 'CAD',
selling_price_list: 'Standard Selling',
items: allItems,
tc_name: contractNotes.value ? '' : undefined,
terms: contractNotes.value || undefined,
}
try {
if (isQuotation) {
const quotPayload = {
...baseDoc,
quotation_to: 'Customer',
party_name: customer,
valid_till: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
}
if (needsAcceptance) {
quotPayload.wizard_steps = JSON.stringify(wizardSteps.value.map((s, i) => ({
subject: s.subject,
job_type: s.job_type || 'Autre',
priority: s.priority || 'medium',
duration_h: s.duration_h || 1,
assigned_group: s.assigned_group || '',
depends_on_step: s.depends_on_step,
scheduled_date: s.scheduled_date || '',
on_open_webhook: s.on_open_webhook || '',
on_close_webhook: s.on_close_webhook || '',
step_order: i + 1,
})))
quotPayload.wizard_context = JSON.stringify(wizardContext)
}
const quot = await createDoc('Quotation', quotPayload)
orderDocName = quot.name
orderDocType = 'Quotation'
} else if (orderMode.value === 'prepaid') {
const inv = await createDoc('Sales Invoice', {
...baseDoc, posting_date: today, due_date: today, is_pos: 0,
})
orderDocName = inv.name
orderDocType = 'Sales Invoice'
} else {
const so = await createDoc('Sales Order', {
...baseDoc, transaction_date: today, delivery_date: today,
})
orderDocName = so.name
orderDocType = 'Sales Order'
}
} catch (e) {
// Surface the failure loudly — the rep needs to know why the
// Quotation didn't land (commonly missing ERPNext Items like
// TIER-G150, TV-MIX5, TEL-ILL). Service Contract creation below
// still proceeds so the rep has *something* to retrieve.
console.warn('[ProjectWizard] Order doc creation failed:', e.message)
Notify.create({
type: 'negative',
message: 'Soumission non créée',
caption: e.message?.slice(0, 180) || 'Erreur ERPNext',
timeout: 8000,
position: 'top',
actions: [{ label: 'OK', color: 'white' }],
})
}
// Create Subscriptions for recurring items (unless deferred)
if (!needsAcceptance) {
for (const item of recurringItems) {
try {
let planName = null
try {
const plans = await createDoc('Subscription Plan', {
plan_name: item.item_name,
item: item.item_code || item.item_name,
currency: 'CAD',
price_determination: 'Fixed Rate',
cost: item.rate,
billing_interval: item.billing_interval || 'Month',
billing_interval_count: 1,
})
planName = plans.name
} catch {
try {
const existing = await getDoc('Subscription Plan', item.item_code || item.item_name)
planName = existing.name
} catch {}
}
await createDoc('Subscription', {
party_type: 'Customer',
party: customer,
company: 'TARGO',
status: 'Active',
start_date: today,
generate_invoice_at: 'Beginning of the current subscription period',
days_until_due: 30,
follow_calendar_months: 1,
sales_tax_template: 'QC TPS 5% + TVQ 9.975% - T',
custom_description: `${item.item_name}${item.rate}$/mois`,
plans: planName ? [{ plan: planName, qty: item.qty }] : [],
})
} catch (e) {
console.warn('[ProjectWizard] Subscription creation failed for', item.item_name, e.message)
}
}
}
}
// Create Dispatch Jobs (tasks) — ONLY if NOT waiting for acceptance
const createdJobs = []
if (!needsAcceptance) {
for (let i = 0; i < wizardSteps.value.length; i++) {
const step = wizardSteps.value[i]
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
let dependsOn = ''
if (step.depends_on_step != null) {
const depJob = createdJobs[step.depends_on_step]
if (depJob) dependsOn = depJob.name
}
const parentJob = i > 0 && createdJobs[0] ? createdJobs[0].name : ''
const newJob = await createJob({
ticket_id: ticketId,
subject: step.subject,
address,
duration_h: step.duration_h || 1,
priority: step.priority || 'medium',
status: 'open',
job_type: step.job_type || 'Autre',
source_issue: props.issue?.name || '',
customer,
service_location: serviceLocation,
depends_on: dependsOn,
parent_job: parentJob,
step_order: i + 1,
on_open_webhook: step.on_open_webhook || '',
on_close_webhook: step.on_close_webhook || '',
notes: [
step.assigned_group ? `Groupe: ${step.assigned_group}` : '',
orderDocName ? `${orderDocType}: ${orderDocName}` : '',
].filter(Boolean).join(' | '),
scheduled_date: step.scheduled_date || '',
})
createdJobs.push(newJob)
}
}
// Create Service Contract for residential commitments with recurring items
// The contract IS the shopping-cart recap: recurring items drive duration/monthly_rate,
// one-time items with regular_price > rate become "benefits" (promotions étalées).
let contractName = ''
const wantsContract = isQuotation && recurringItems.length > 0
&& recurringItems.some(i => (i.contract_months || 0) > 0)
if (wantsContract) {
try {
const durationMonths = Math.max(...recurringItems.map(i => i.contract_months || 12))
const monthlyRate = recurringItems.reduce((s, i) => s + (i.qty * i.rate), 0)
const benefits = onetimeItems
.filter(i => (i.regular_price || 0) > i.rate)
.map(i => ({
description: i.item_name,
regular_price: i.regular_price,
granted_price: i.rate,
}))
const contractType = acceptanceMethod.value === 'docuseal' ? 'Commercial' : 'Résidentiel'
const createRes = await fetch(`${HUB_URL}/contract/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer,
contract_type: contractType,
duration_months: durationMonths,
monthly_rate: monthlyRate,
service_location: serviceLocation,
quotation: orderDocName,
start_date: today,
benefits,
}),
})
const createData = await createRes.json()
if (createData.ok && createData.contract) {
contractName = createData.contract.name
publishedContractName.value = contractName
}
} catch (e) {
console.warn('[ProjectWizard] Service Contract creation failed:', e.message)
}
}
// Generate acceptance link for quotations
// When a Service Contract was created, prefer /contract/send (promotion-framed récap).
// Otherwise fall back to the Quotation-centric /accept/generate flow.
if (isQuotation && orderDocName && needsAcceptance) {
try {
if (contractName) {
const sendRes = await fetch(`${HUB_URL}/contract/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: contractName,
phone: clientPhone.value || '',
email: clientEmail.value || '',
use_docuseal: acceptanceMethod.value === 'docuseal',
}),
})
const sendData = await sendRes.json()
if (sendData.ok) {
acceptanceLinkUrl.value = sendData.accept_link || sendData.sign_url || ''
const viaParts = []
if (sendData.method === 'sms') viaParts.push('SMS')
if (sendData.method === 'docuseal') viaParts.push('DocuSeal')
acceptanceSentVia.value = viaParts.length ? ` par ${viaParts.join(' et ')}` : ''
acceptanceLinkSent.value = viaParts.length > 0
Notify.create({
type: 'info',
message: sendData.method === 'docuseal'
? 'Lien DocuSeal envoyé — contrat commercial'
: `Récapitulatif envoyé au client${acceptanceSentVia.value}`,
timeout: 6000,
})
}
} else {
const acceptRes = await fetch(`${HUB_URL}/accept/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quotation: orderDocName,
customer,
ttl_hours: 168,
send_sms: hasPhone,
phone: clientPhone.value || '',
send_email: hasEmail,
email: clientEmail.value || '',
use_docuseal: acceptanceMethod.value === 'docuseal',
attach_pdf: true,
}),
})
const acceptData = await acceptRes.json()
if (acceptData.ok) {
acceptanceLinkUrl.value = acceptData.link || acceptData.sign_url || ''
const viaParts = []
if (acceptData.sms_sent) viaParts.push('SMS')
if (acceptData.email_sent) viaParts.push('courriel')
acceptanceSentVia.value = viaParts.length ? ` par ${viaParts.join(' et ')}` : ''
acceptanceLinkSent.value = viaParts.length > 0
Notify.create({
type: 'info',
message: acceptData.method === 'docuseal'
? 'Lien de signature DocuSeal envoyé au client'
: acceptanceLinkSent.value
? `Lien d'acceptation envoyé${acceptanceSentVia.value}`
: 'Lien d\'acceptation généré — copiez-le pour l\'envoyer au client',
timeout: 6000,
})
}
}
} catch (e) {
console.warn('[ProjectWizard] Acceptance link failed:', e.message)
acceptanceLinkUrl.value = `${HUB_URL}/accept/doc-pdf/Quotation/${encodeURIComponent(orderDocName)}`
}
}
// Build notification
const parts = []
if (createdJobs.length) parts.push(`${createdJobs.length} tâches créées`)
if (needsAcceptance) parts.push('en attente d\'acceptation')
if (orderDocName) parts.push(`${orderDocType} ${orderDocName}`)
if (contractName) parts.push(`Contrat ${contractName}`)
if (!needsAcceptance && recurringItems.length) parts.push(`${recurringItems.length} abonnement(s)`)
Notify.create({
type: needsAcceptance ? 'warning' : 'positive',
message: parts.join(' · '),
timeout: 5000,
})
for (const job of createdJobs) {
emit('created', job)
}
// Show success screen. Prefer Quotation as the primary artifact;
// fall back to Service Contract when Quotation failed but Contract
// landed — the rep still needs a way to retrieve the sommaire.
if (orderDocName) {
publishedDocName.value = orderDocName
publishedDocType.value = orderDocType
publishedDone.value = true
pendingAcceptance.value = needsAcceptance
publishedJobCount.value = createdJobs.length
if (clientEmail.value) { sendTo.value = clientEmail.value; sendChannel.value = 'email' }
else if (clientPhone.value) { sendTo.value = clientPhone.value; sendChannel.value = 'sms' }
} else if (contractName) {
publishedDocName.value = contractName
publishedDocType.value = 'Service Contract'
publishedDone.value = true
pendingAcceptance.value = needsAcceptance
publishedJobCount.value = createdJobs.length
} else if (createdJobs.length) {
publishedDocName.value = createdJobs[0]?.name || ''
publishedDocType.value = 'Dispatch Job'
publishedDone.value = true
pendingAcceptance.value = false
publishedJobCount.value = createdJobs.length
} else {
state.cancel()
}
} catch (err) {
console.error('[ProjectWizard] publish error:', err)
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
} finally {
publishing.value = false
}
}
return { publish }
}