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 } }