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