From 41d9b5f31625faa6a1df2ffb23731d6507899a34 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 22 Apr 2026 10:44:17 -0400 Subject: [PATCH] feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 11 + README.md | 18 +- apps/field/src/composables/useScanner.js | 114 +- apps/field/src/pages/ScanPage.vue | 24 +- apps/field/src/stores/offline.js | 105 +- apps/ops/src/api/flow-templates.js | 104 + .../src/components/flow-editor/FieldInput.vue | 76 + .../src/components/flow-editor/FlowEditor.vue | 396 +++ .../flow-editor/FlowEditorDialog.vue | 432 +++ .../src/components/flow-editor/FlowNode.vue | 447 ++++ .../flow-editor/FlowQuickButton.vue | 70 + .../flow-editor/FlowTemplatesSection.vue | 312 +++ .../flow-editor/StepEditorModal.vue | 242 ++ .../components/flow-editor/VariablePicker.vue | 114 + apps/ops/src/components/flow-editor/index.js | 28 + .../components/flow-editor/kind-catalogs.js | 258 ++ .../src/components/flow-editor/variables.js | 110 + .../src/components/shared/ProjectWizard.vue | 2370 ++++++++++++++++- .../detail-sections/EquipmentDetail.vue | 128 +- .../shared/detail-sections/InvoiceDetail.vue | 73 + .../shared/detail-sections/IssueDetail.vue | 4 + apps/ops/src/composables/useAddressPricing.js | 190 ++ apps/ops/src/composables/useClientData.js | 25 +- apps/ops/src/composables/useDeviceStatus.js | 21 +- apps/ops/src/composables/useFlowEditor.js | 205 ++ .../ops/src/composables/useModemDiagnostic.js | 217 ++ apps/ops/src/composables/useWifiDiagnostic.js | 4 +- apps/ops/src/composables/useWizardCatalog.js | 402 ++- apps/ops/src/composables/useWizardPublish.js | 190 +- apps/ops/src/config/project-templates.js | 19 +- apps/ops/src/config/table-columns.js | 10 + apps/ops/src/data/client-constants.js | 2 +- apps/ops/src/data/pricing-mock.js | 123 + apps/ops/src/data/wizard-constants.js | 291 +- apps/ops/src/layouts/MainLayout.vue | 4 + .../components/PublishScheduleModal.vue | 50 +- .../components/SuggestSlotsDialog.vue | 303 +++ apps/ops/src/pages/ClientDetailPage.vue | 132 +- apps/ops/src/pages/SettingsPage.vue | 17 +- apps/ops/src/pages/TicketsPage.vue | 6 + docs/APP_DESIGN_GUIDELINES.md | 83 + docs/ARCHITECTURE.md | 356 +-- docs/BILLING_AND_PAYMENTS.md | 433 +++ docs/CPE_MANAGEMENT.md | 61 + docs/CUSTOMER-360-FLOWS.md | 498 ---- docs/CUSTOMER-FLOW-ARCHITECTURE.md | 567 ---- docs/DATA-STRUCTURE-FOUNDATION.md | 405 --- docs/DATA_AND_FLOWS.md | 81 + docs/DESIGN_GUIDELINES.md | 67 - docs/ERPNEXT_ITEM_DIFF_VS_LEGACY.md | 60 + docs/FIELD-APP-WIZARD-UX.md | 415 --- docs/FLOW_EDITOR_ARCHITECTURE.md | 678 +++++ docs/Gigafibre-Billing-Handoff.pptx | Bin 0 -> 443484 bytes docs/HANDOFF.md | 95 + docs/INFRASTRUCTURE.md | 363 --- docs/PLATFORM-STRATEGY.md | 384 --- docs/ROADMAP.md | 22 + docs/STATUS_2026-04-18.md | 263 ++ docs/STATUS_2026-04-18b.md | 107 + docs/STATUS_2026-04-19.md | 83 + docs/TR069-TO-TR369-MIGRATION.md | 144 - docs/XX230V-DIAGNOSTICS-AND-OKTOPUS-TEST.md | 331 --- .../LEGACY-ACCOUNTING-ANALYSIS.md | 0 docs/{ => archive}/MIGRATION.md | 0 docs/assets/screenshots/invoice-pdf.png | Bin 0 -> 398815 bytes docs/assets/screenshots/pay-public.png | Bin 0 -> 41446 bytes docs/assets/soumission_v1.pdf | Bin 0 -> 22369 bytes docs/build-billing-pptx.js | 364 +++ docs/legacy-wizard/account_wizard.php | 1581 +++++++++++ docs/legacy-wizard/account_wizard_ajax.php | 51 + docs/legacy-wizard/tele_wizard_package.php | 250 ++ docs/legacy-wizard/tele_wizard_subs.php | 130 + erpnext/flow_scheduler.py | 181 ++ erpnext/seed_flow_templates.py | 459 ++++ erpnext/setup_flow_templates.py | 235 ++ erpnext/setup_fsm_doctypes.py | 138 + scripts/migration/clean_reimport.py | 64 +- scripts/migration/import_invoices.py | 27 +- scripts/migration/invoice_preview.jinja | 232 ++ scripts/migration/logo-targo-green.svg | 1 + .../migration/setup_invoice_print_format.py | 940 ++++--- scripts/migration/setup_quote_print_format.py | 480 ++++ scripts/migration/test_jinja_render.py | 103 + .../modem-bridge/lib/diagnostic-normalizer.js | 637 +++++ services/modem-bridge/lib/tplink-session.js | 539 +++- services/modem-bridge/server.js | 33 + services/targo-hub/lib/acceptance.js | 24 +- services/targo-hub/lib/config.js | 4 +- services/targo-hub/lib/contracts.js | 164 +- services/targo-hub/lib/device-extractors.js | 101 +- services/targo-hub/lib/devices.js | 54 + services/targo-hub/lib/dispatch.js | 179 +- services/targo-hub/lib/flow-api.js | 126 + services/targo-hub/lib/flow-runtime.js | 671 +++++ services/targo-hub/lib/flow-templates.js | 278 ++ services/targo-hub/lib/helpers.js | 30 +- services/targo-hub/lib/modem-bridge.js | 144 +- services/targo-hub/lib/oktopus.js | 20 +- services/targo-hub/lib/olt-snmp.js | 64 +- services/targo-hub/lib/payments.js | 8 + services/targo-hub/lib/referral.js | 140 + services/targo-hub/lib/traccar.js | 5 +- services/targo-hub/preview/index.html | 105 + services/targo-hub/server.js | 5 + .../templates/contract-residential.html | 236 ++ 105 files changed, 17742 insertions(+), 4204 deletions(-) create mode 100644 apps/ops/src/api/flow-templates.js create mode 100644 apps/ops/src/components/flow-editor/FieldInput.vue create mode 100644 apps/ops/src/components/flow-editor/FlowEditor.vue create mode 100644 apps/ops/src/components/flow-editor/FlowEditorDialog.vue create mode 100644 apps/ops/src/components/flow-editor/FlowNode.vue create mode 100644 apps/ops/src/components/flow-editor/FlowQuickButton.vue create mode 100644 apps/ops/src/components/flow-editor/FlowTemplatesSection.vue create mode 100644 apps/ops/src/components/flow-editor/StepEditorModal.vue create mode 100644 apps/ops/src/components/flow-editor/VariablePicker.vue create mode 100644 apps/ops/src/components/flow-editor/index.js create mode 100644 apps/ops/src/components/flow-editor/kind-catalogs.js create mode 100644 apps/ops/src/components/flow-editor/variables.js create mode 100644 apps/ops/src/composables/useAddressPricing.js create mode 100644 apps/ops/src/composables/useFlowEditor.js create mode 100644 apps/ops/src/composables/useModemDiagnostic.js create mode 100644 apps/ops/src/data/pricing-mock.js create mode 100644 apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue create mode 100644 docs/APP_DESIGN_GUIDELINES.md create mode 100644 docs/BILLING_AND_PAYMENTS.md create mode 100644 docs/CPE_MANAGEMENT.md delete mode 100644 docs/CUSTOMER-360-FLOWS.md delete mode 100644 docs/CUSTOMER-FLOW-ARCHITECTURE.md delete mode 100644 docs/DATA-STRUCTURE-FOUNDATION.md create mode 100644 docs/DATA_AND_FLOWS.md delete mode 100644 docs/DESIGN_GUIDELINES.md create mode 100644 docs/ERPNEXT_ITEM_DIFF_VS_LEGACY.md delete mode 100644 docs/FIELD-APP-WIZARD-UX.md create mode 100644 docs/FLOW_EDITOR_ARCHITECTURE.md create mode 100644 docs/Gigafibre-Billing-Handoff.pptx create mode 100644 docs/HANDOFF.md delete mode 100644 docs/INFRASTRUCTURE.md delete mode 100644 docs/PLATFORM-STRATEGY.md create mode 100644 docs/STATUS_2026-04-18.md create mode 100644 docs/STATUS_2026-04-18b.md create mode 100644 docs/STATUS_2026-04-19.md delete mode 100644 docs/TR069-TO-TR369-MIGRATION.md delete mode 100644 docs/XX230V-DIAGNOSTICS-AND-OKTOPUS-TEST.md rename docs/{ => archive}/LEGACY-ACCOUNTING-ANALYSIS.md (100%) rename docs/{ => archive}/MIGRATION.md (100%) create mode 100644 docs/assets/screenshots/invoice-pdf.png create mode 100644 docs/assets/screenshots/pay-public.png create mode 100644 docs/assets/soumission_v1.pdf create mode 100644 docs/build-billing-pptx.js create mode 100644 docs/legacy-wizard/account_wizard.php create mode 100644 docs/legacy-wizard/account_wizard_ajax.php create mode 100644 docs/legacy-wizard/tele_wizard_package.php create mode 100644 docs/legacy-wizard/tele_wizard_subs.php create mode 100644 erpnext/flow_scheduler.py create mode 100644 erpnext/seed_flow_templates.py create mode 100644 erpnext/setup_flow_templates.py create mode 100644 scripts/migration/invoice_preview.jinja create mode 100644 scripts/migration/logo-targo-green.svg create mode 100644 scripts/migration/setup_quote_print_format.py create mode 100644 scripts/migration/test_jinja_render.py create mode 100644 services/modem-bridge/lib/diagnostic-normalizer.js create mode 100644 services/targo-hub/lib/flow-api.js create mode 100644 services/targo-hub/lib/flow-runtime.js create mode 100644 services/targo-hub/lib/flow-templates.js create mode 100644 services/targo-hub/lib/referral.js create mode 100644 services/targo-hub/preview/index.html create mode 100644 services/targo-hub/templates/contract-residential.html diff --git a/.gitignore b/.gitignore index 88355f1..2ba42d7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,19 @@ exports/ # OS .DS_Store +**/.DS_Store Thumbs.db +# Generated invoice/quote previews (output of setup_invoice_print_format.py +# + test_jinja_render.py). Keep sources (*.jinja) and final references +# (docs/assets/*.pdf when added intentionally), never ephemeral output. +invoice_preview*.pdf +scripts/migration/invoice_preview*.pdf +scripts/migration/invoice_preview*.html +scripts/migration/rendered_jinja_invoice* +scripts/migration/SINV-*.pdf +scripts/migration/ref_invoice.pdf + # IDE .vscode/ .idea/ diff --git a/README.md b/README.md index 32d46fd..792f544 100644 --- a/README.md +++ b/README.md @@ -130,15 +130,9 @@ Authentik SSO protects staff apps via Traefik `forwardAuth`. The ops app reads ` | Document | Content | |----------|---------| -| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Data model, tech stack, authentication flow, doctype reference | -| [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) | Server, DNS, Traefik, Authentik, Docker, n8n, gotchas | -| [MIGRATION-PLAN.md](docs/MIGRATION-PLAN.md) | Legacy system portrait, field mapping, phases, risks | -| [CHANGELOG.md](docs/CHANGELOG.md) | Detailed migration log with volumes and methods | -| [ROADMAP.md](docs/ROADMAP.md) | 5-phase implementation plan | -| [ECOSYSTEM-OVERVIEW.md](docs/ECOSYSTEM-OVERVIEW.md) | Full platform ecosystem and integration map | -| [PLATFORM-STRATEGY.md](docs/PLATFORM-STRATEGY.md) | Platform strategy and product direction | -| [CUSTOMER-360-FLOWS.md](docs/CUSTOMER-360-FLOWS.md) | Customer lifecycle flows and 360 view design | -| [DESIGN_GUIDELINES.md](docs/DESIGN_GUIDELINES.md) | UI/UX design guidelines for ops apps | -| [COMPETITIVE-ANALYSIS.md](docs/COMPETITIVE-ANALYSIS.md) | Comparison with Gaiia, Odoo, Zuper, Salesforce, ServiceTitan | -| [TR069-TO-TR369-MIGRATION.md](docs/TR069-TO-TR369-MIGRATION.md) | CPE management protocol migration plan | -| [scripts/migration/MIGRATION_MAP.md](scripts/migration/MIGRATION_MAP.md) | Field-level mapping from legacy tables to ERPNext | +| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Ecosystem overview, remote Docker infrastructure, platform strategy | +| [DATA_AND_FLOWS.md](docs/DATA_AND_FLOWS.md) | ERPNext data models, atomic order creation, customer flows | +| [CPE_MANAGEMENT.md](docs/CPE_MANAGEMENT.md) | Hardware management, XX230v diagnostics, TR-069/TR-369 | +| [APP_DESIGN_GUIDELINES.md](docs/APP_DESIGN_GUIDELINES.md) | Frontend framework architecture rules, UI/UX Wizard guidelines | +| [ROADMAP.md](docs/ROADMAP.md) | Implementation phases and current remote transition tasks | +| [archive/](docs/archive/) | Completed legacy migration analyses and accounting audits | diff --git a/apps/field/src/composables/useScanner.js b/apps/field/src/composables/useScanner.js index a3e3872..d2c1ab8 100644 --- a/apps/field/src/composables/useScanner.js +++ b/apps/field/src/composables/useScanner.js @@ -1,5 +1,8 @@ -import { ref } from 'vue' +import { ref, watch } from 'vue' import { scanBarcodes } from 'src/api/ocr' +import { useOfflineStore } from 'src/stores/offline' + +const SCAN_TIMEOUT_MS = 8000 /** * Barcode scanner using device camera photo capture + Gemini Vision AI. @@ -8,59 +11,99 @@ import { scanBarcodes } from 'src/api/ocr' * the native camera app — this gives proper autofocus, tap-to-focus, * and high-res photos. Then send to Gemini Vision for barcode extraction. * - * Also keeps a thumbnail of each captured photo for reference. + * Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE), + * the photo is queued in IndexedDB via the offline store and retried when + * the signal comes back. The tech gets a "scan en attente" indicator and + * can keep working; late results are delivered via onNewCode(). + * + * @param {object} options + * @param {(code: string) => void} [options.onNewCode] — called for each + * newly detected code, whether the scan was synchronous or delivered + * later from the offline queue. Typically used to trigger lookup + notify. */ -export function useScanner () { +export function useScanner (options = {}) { + const onNewCode = options.onNewCode || (() => {}) const barcodes = ref([]) // Array of { value, region } — max 3 const scanning = ref(false) // true while Gemini is processing const error = ref(null) const lastPhoto = ref(null) // data URI of last captured photo (thumbnail) const photos = ref([]) // all captured photo thumbnails + const offline = useOfflineStore() + + // Pick up any scans that completed while the page was unmounted (e.g. tech + // queued a photo, locked phone, walked out of the basement, signal returns). + for (const result of offline.scanResults) { + mergeCodes(result.barcodes || [], 'queued') + offline.consumeScanResult(result.id) + } + + // Watch for sync completions during the lifetime of this scanner. + // Vue auto-disposes the watcher when the host component unmounts. + watch( + () => offline.scanResults.length, + () => { + for (const result of [...offline.scanResults]) { + mergeCodes(result.barcodes || [], 'queued') + offline.consumeScanResult(result.id) + } + } + ) + + function addCode (code, region) { + if (barcodes.value.length >= 3) return false + if (barcodes.value.find(b => b.value === code)) return false + barcodes.value.push({ value: code, region }) + onNewCode(code) + return true + } + + function mergeCodes (codes, region) { + const added = [] + for (const code of codes) { + if (addCode(code, region)) added.push(code) + } + return added + } + /** * Process a photo file from camera input. - * Resizes for AI, keeps thumbnail, sends to Gemini. - * @param {File} file - image file from camera - * @returns {string[]} newly found barcode values + * Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout. + * On timeout/failure, the photo is queued for background retry. */ async function processPhoto (file) { if (!file) return [] error.value = null scanning.value = true - const found = [] + let aiImage = null + const photoIdx = photos.value.length + let found = [] try { // Create thumbnail for display (small) const thumbUrl = await resizeImage(file, 400) lastPhoto.value = thumbUrl - photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [] }) + photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) // Create optimized image for AI — keep high res for text readability - const aiImage = await resizeImage(file, 1600, 0.92) + aiImage = await resizeImage(file, 1600, 0.92) - // Send to Gemini Vision - const result = await scanBarcodes(aiImage) - const existing = new Set(barcodes.value.map(b => b.value)) - - for (const code of (result.barcodes || [])) { - if (barcodes.value.length >= 3) break - if (!existing.has(code)) { - existing.add(code) - barcodes.value.push({ value: code, region: 'photo' }) - found.push(code) - } - } - - // Tag the photo with found codes - const lastIdx = photos.value.length - 1 - if (lastIdx >= 0) photos.value[lastIdx].codes = found + const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS) + found = mergeCodes(result.barcodes || [], 'photo') + photos.value[photoIdx].codes = found if (found.length === 0) { error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point' } } catch (e) { - error.value = e.message || 'Erreur' + if (aiImage && isRetryable(e)) { + await offline.enqueueVisionScan({ image: aiImage }) + if (photos.value[photoIdx]) photos.value[photoIdx].queued = true + error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.' + } else { + error.value = e.message || 'Erreur' + } } finally { scanning.value = false } @@ -68,6 +111,25 @@ export function useScanner () { return found } + async function scanBarcodesWithTimeout (image, ms) { + return await Promise.race([ + scanBarcodes(image), + new Promise((_, reject) => setTimeout( + () => reject(new Error('ScanTimeout')), + ms, + )), + ]) + } + + function isRetryable (e) { + const msg = (e?.message || '').toLowerCase() + return msg.includes('scantimeout') + || msg.includes('failed to fetch') + || msg.includes('networkerror') + || msg.includes('load failed') + || e?.name === 'TypeError' // fetch throws TypeError on network error + } + /** * Resize an image file to a max dimension, return as base64 data URI. */ diff --git a/apps/field/src/pages/ScanPage.vue b/apps/field/src/pages/ScanPage.vue index 08932f8..107ba65 100644 --- a/apps/field/src/pages/ScanPage.vue +++ b/apps/field/src/pages/ScanPage.vue @@ -29,6 +29,13 @@ + +
+ + {{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer + +
+
@@ -203,8 +210,13 @@ import { useOfflineStore } from 'src/stores/offline' import { Notify } from 'quasar' const route = useRoute() -const scanner = useScanner() const offline = useOfflineStore() +const scanner = useScanner({ + onNewCode: (code) => { + Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' }) + lookupDevice(code) + }, +}) const cameraInput = ref(null) const manualCode = ref('') @@ -257,12 +269,10 @@ function takePhoto () { async function onPhoto (e) { const file = e.target.files?.[0] if (!file) return - - const found = await scanner.processPhoto(file) - for (const code of found) { - Notify.create({ type: 'positive', message: code, timeout: 2500, icon: 'qr_code', color: 'deep-purple' }) - lookupDevice(code) - } + // Codes are delivered through the onNewCode callback registered on the + // scanner — fires both for sync scans and for queued scans that complete + // later when the signal comes back. + await scanner.processPhoto(file) } function viewPhoto (photo) { diff --git a/apps/field/src/stores/offline.js b/apps/field/src/stores/offline.js index 5196162..337d1db 100644 --- a/apps/field/src/stores/offline.js +++ b/apps/field/src/stores/offline.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { get, set, del, keys } from 'idb-keyval' import { createDoc, updateDoc } from 'src/api/erp' +import { scanBarcodes } from 'src/api/ocr' export const useOfflineStore = defineStore('offline', () => { const queue = ref([]) @@ -9,8 +10,21 @@ export const useOfflineStore = defineStore('offline', () => { const online = ref(navigator.onLine) const pendingCount = computed(() => queue.value.length) + // Vision scan queue — photos whose Gemini call timed out / failed, + // waiting to be retried when the signal is back. + const visionQueue = ref([]) // { id, image (base64), ts, status } + const scanResults = ref([]) // completed scans not yet consumed by a page + // { id, barcodes: string[], ts } + const pendingVisionCount = computed(() => visionQueue.value.length) + let retryTimer = null + let visionSyncing = false + // Listen to connectivity changes - window.addEventListener('online', () => { online.value = true; syncQueue() }) + window.addEventListener('online', () => { + online.value = true + syncQueue() + syncVisionQueue() + }) window.addEventListener('offline', () => { online.value = false }) async function loadQueue () { @@ -24,6 +38,25 @@ export const useOfflineStore = defineStore('offline', () => { await set('offline-queue', JSON.parse(JSON.stringify(queue.value))) } + async function loadVisionQueue () { + try { + visionQueue.value = (await get('vision-queue')) || [] + scanResults.value = (await get('vision-results')) || [] + } catch { + visionQueue.value = [] + scanResults.value = [] + } + if (visionQueue.value.length) scheduleVisionRetry(5000) + } + + async function saveVisionQueue () { + await set('vision-queue', JSON.parse(JSON.stringify(visionQueue.value))) + } + + async function saveScanResults () { + await set('vision-results', JSON.parse(JSON.stringify(scanResults.value))) + } + // Enqueue a mutation to be synced later async function enqueue (action) { // action = { type: 'create'|'update', doctype, name?, data, ts } @@ -55,6 +88,68 @@ export const useOfflineStore = defineStore('offline', () => { syncing.value = false } + // Enqueue a photo whose Gemini scan couldn't complete (timeout / offline). + // Returns the queued entry so the caller can display a pending indicator. + async function enqueueVisionScan ({ image }) { + const entry = { + id: Date.now() + '-' + Math.random().toString(36).slice(2, 8), + image, + ts: Date.now(), + status: 'queued', + } + visionQueue.value.push(entry) + await saveVisionQueue() + scheduleVisionRetry(5000) + return entry + } + + // Retry each queued photo. Success → move to scanResults, fail → stay queued + // with a bumped retry schedule. navigator.onLine can lie in weak-signal + // zones, so we drive retries off the queue itself, not off the online flag. + async function syncVisionQueue () { + if (visionSyncing) return + if (retryTimer) { clearTimeout(retryTimer); retryTimer = null } + if (visionQueue.value.length === 0) return + visionSyncing = true + const remaining = [] + try { + for (const entry of [...visionQueue.value]) { + try { + entry.status = 'syncing' + const result = await scanBarcodes(entry.image) + scanResults.value.push({ + id: entry.id, + barcodes: result.barcodes || [], + ts: Date.now(), + }) + } catch { + entry.status = 'queued' + remaining.push(entry) + } + } + visionQueue.value = remaining + await Promise.all([saveVisionQueue(), saveScanResults()]) + if (remaining.length) scheduleVisionRetry(30000) + } finally { + visionSyncing = false + } + } + + function scheduleVisionRetry (delay) { + if (retryTimer) return + retryTimer = setTimeout(() => { + retryTimer = null + syncVisionQueue() + }, delay) + } + + // Consumer (ScanPage) calls this after merging a result into the UI so the + // same serial doesn't reappear next time the page mounts. + async function consumeScanResult (id) { + scanResults.value = scanResults.value.filter(r => r.id !== id) + await saveScanResults() + } + // Cache data for offline reading async function cacheData (key, data) { await set('cache-' + key, { data, ts: Date.now() }) @@ -68,6 +163,12 @@ export const useOfflineStore = defineStore('offline', () => { } loadQueue() + loadVisionQueue() - return { queue, syncing, online, pendingCount, enqueue, syncQueue, cacheData, getCached, loadQueue } + return { + queue, syncing, online, pendingCount, enqueue, syncQueue, + visionQueue, scanResults, pendingVisionCount, + enqueueVisionScan, syncVisionQueue, consumeScanResult, + cacheData, getCached, loadQueue, + } }) diff --git a/apps/ops/src/api/flow-templates.js b/apps/ops/src/api/flow-templates.js new file mode 100644 index 0000000..5184e84 --- /dev/null +++ b/apps/ops/src/api/flow-templates.js @@ -0,0 +1,104 @@ +/** + * api/flow-templates.js — Client for the Hub /flow/templates CRUD endpoints. + * + * Mirrors services/targo-hub/lib/flow-templates.js. All requests go through + * the Hub (which handles ERPNext auth server-side). + * + * Functions: + * listFlowTemplates({ category?, applies_to?, trigger_event?, is_active?, q? }) + * getFlowTemplate(name) + * createFlowTemplate(body) + * updateFlowTemplate(name, patch) + * deleteFlowTemplate(name) + * duplicateFlowTemplate(name, newName?) + * + * All functions return the parsed JSON body or throw on network / 4xx / 5xx. + */ + +import { HUB_URL } from 'src/config/hub' + +/** Fetch helper with error normalization. */ +async function hubFetch (path, { method = 'GET', body } = {}) { + const opts = { method, headers: { 'Content-Type': 'application/json' } } + if (body) opts.body = JSON.stringify(body) + const res = await fetch(`${HUB_URL}${path}`, opts) + const text = await res.text() + let data + try { data = text ? JSON.parse(text) : {} } + catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) } + if (!res.ok) { + const msg = data.error || `HTTP ${res.status}` + const err = new Error(msg) + err.status = res.status + err.detail = data.detail + throw err + } + return data +} + +/** + * List flow templates with optional filters. + * @param {Object} filters { category, applies_to, trigger_event, is_active, q, limit } + * @returns {Promise} list of templates (without flow_definition body) + */ +export async function listFlowTemplates (filters = {}) { + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(filters)) { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)) + } + const path = `/flow/templates${qs.toString() ? '?' + qs.toString() : ''}` + const data = await hubFetch(path) + return data.templates || [] +} + +/** + * Fetch a single template with its parsed flow_definition. + * @param {string} name FT-00001 etc. + */ +export async function getFlowTemplate (name) { + const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}`) + return data.template +} + +/** + * Create a new (user) template. is_system is forced to 0 by the API. + * @param {Object} body { template_name, category, applies_to, flow_definition, ... } + */ +export async function createFlowTemplate (body) { + const data = await hubFetch('/flow/templates', { method: 'POST', body }) + return data.template +} + +/** + * Patch an existing template. Version is auto-bumped by the API. + * @param {string} name + * @param {Object} patch subset of fields + */ +export async function updateFlowTemplate (name, patch) { + const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}`, { + method: 'PUT', body: patch, + }) + return data.template +} + +/** + * Delete a template. Blocked server-side if is_system=1. + * @param {string} name + */ +export async function deleteFlowTemplate (name) { + await hubFetch(`/flow/templates/${encodeURIComponent(name)}`, { method: 'DELETE' }) +} + +/** + * Duplicate a template (e.g. to customize a system template). + * Creates an inactive copy (is_active=0) for user to review before enabling. + * @param {string} name + * @param {string} [newName] optional override (defaults to " (copie)") + */ +export async function duplicateFlowTemplate (name, newName) { + const body = newName ? { template_name: newName } : {} + const data = await hubFetch(`/flow/templates/${encodeURIComponent(name)}/duplicate`, { + method: 'POST', body, + }) + return data.template +} diff --git a/apps/ops/src/components/flow-editor/FieldInput.vue b/apps/ops/src/components/flow-editor/FieldInput.vue new file mode 100644 index 0000000..68eb0dc --- /dev/null +++ b/apps/ops/src/components/flow-editor/FieldInput.vue @@ -0,0 +1,76 @@ + + + + diff --git a/apps/ops/src/components/flow-editor/FlowEditor.vue b/apps/ops/src/components/flow-editor/FlowEditor.vue new file mode 100644 index 0000000..2e80305 --- /dev/null +++ b/apps/ops/src/components/flow-editor/FlowEditor.vue @@ -0,0 +1,396 @@ + + + + + + + + + diff --git a/apps/ops/src/components/flow-editor/FlowEditorDialog.vue b/apps/ops/src/components/flow-editor/FlowEditorDialog.vue new file mode 100644 index 0000000..92239e8 --- /dev/null +++ b/apps/ops/src/components/flow-editor/FlowEditorDialog.vue @@ -0,0 +1,432 @@ + + + + + + diff --git a/apps/ops/src/components/flow-editor/FlowNode.vue b/apps/ops/src/components/flow-editor/FlowNode.vue new file mode 100644 index 0000000..af381a5 --- /dev/null +++ b/apps/ops/src/components/flow-editor/FlowNode.vue @@ -0,0 +1,447 @@ + + + + + + diff --git a/apps/ops/src/components/flow-editor/FlowQuickButton.vue b/apps/ops/src/components/flow-editor/FlowQuickButton.vue new file mode 100644 index 0000000..6f1eda0 --- /dev/null +++ b/apps/ops/src/components/flow-editor/FlowQuickButton.vue @@ -0,0 +1,70 @@ + + + + diff --git a/apps/ops/src/components/flow-editor/FlowTemplatesSection.vue b/apps/ops/src/components/flow-editor/FlowTemplatesSection.vue new file mode 100644 index 0000000..9ac12ac --- /dev/null +++ b/apps/ops/src/components/flow-editor/FlowTemplatesSection.vue @@ -0,0 +1,312 @@ + + + + + + diff --git a/apps/ops/src/components/flow-editor/StepEditorModal.vue b/apps/ops/src/components/flow-editor/StepEditorModal.vue new file mode 100644 index 0000000..b46e717 --- /dev/null +++ b/apps/ops/src/components/flow-editor/StepEditorModal.vue @@ -0,0 +1,242 @@ + + + + + + diff --git a/apps/ops/src/components/flow-editor/VariablePicker.vue b/apps/ops/src/components/flow-editor/VariablePicker.vue new file mode 100644 index 0000000..fb88490 --- /dev/null +++ b/apps/ops/src/components/flow-editor/VariablePicker.vue @@ -0,0 +1,114 @@ + + + + + + diff --git a/apps/ops/src/components/flow-editor/index.js b/apps/ops/src/components/flow-editor/index.js new file mode 100644 index 0000000..1732aa6 --- /dev/null +++ b/apps/ops/src/components/flow-editor/index.js @@ -0,0 +1,28 @@ +/** + * flow-editor/index.js — Public exports for the Flow Editor module. + * + * Usage: + * import { FlowEditor, PROJECT_KINDS } from 'src/components/flow-editor' + * + * + * + * See FLOW_EDITOR_ARCHITECTURE.md in /docs/ for the full data model and + * runtime contract. + */ + +export { default as FlowEditor } from './FlowEditor.vue' +export { default as FlowNode } from './FlowNode.vue' +export { default as StepEditorModal } from './StepEditorModal.vue' +export { default as FieldInput } from './FieldInput.vue' +export { default as FlowEditorDialog } from './FlowEditorDialog.vue' +export { default as FlowTemplatesSection } from './FlowTemplatesSection.vue' +export { default as FlowQuickButton } from './FlowQuickButton.vue' + +export { + PROJECT_KINDS, + AGENT_KINDS, + TRIGGER_TYPES, + getKind, + getTrigger, + buildEmptyStep, +} from './kind-catalogs' diff --git a/apps/ops/src/components/flow-editor/kind-catalogs.js b/apps/ops/src/components/flow-editor/kind-catalogs.js new file mode 100644 index 0000000..6f2331c --- /dev/null +++ b/apps/ops/src/components/flow-editor/kind-catalogs.js @@ -0,0 +1,258 @@ +/** + * kind-catalogs.js — Pluggable definitions for flow step kinds. + * + * A "catalog" tells the FlowEditor which step kinds are allowed and how + * to render their editor form. One editor, many domains (project, agent, + * customer onboarding, etc.) — same UI, different catalogs. + * + * Field descriptor shape: + * { + * name: 'subject', // flattens into step.payload[name] + * type: 'text'|'textarea'|'number'|'select'|'datetime'|'webhook', + * label: 'Sujet', + * required: true|false, + * options: [...], // for select + * placeholder: '...', + * default: 'value', + * help: 'tooltip text', + * } + */ + +// ----------------------------------------------------------------------------- +// Trigger types (when a step executes) — shared across all catalogs +// ----------------------------------------------------------------------------- + +export const TRIGGER_TYPES = { + on_flow_start: { + label: 'Au démarrage du flow', + help: 'Cette étape s\'exécute dès que le flow démarre', + fields: [], + }, + on_prev_complete: { + label: 'Après les dépendances (depends_on)', + help: 'Cette étape attend que les étapes listées dans depends_on soient complétées', + fields: [], + }, + after_delay: { + label: 'Après un délai', + help: 'Cette étape attend X heures/jours après que ses dépendances soient complétées', + fields: [ + { name: 'delay_hours', type: 'number', label: 'Heures', placeholder: '24' }, + { name: 'delay_days', type: 'number', label: 'Jours', placeholder: '7' }, + ], + }, + on_date: { + label: 'À une date précise', + help: 'Déclenchement à une date/heure fixe', + fields: [ + { name: 'at', type: 'datetime', label: 'Date/heure' }, + ], + }, + on_webhook: { + label: 'Webhook externe reçu', + help: 'POST sur /flow/trigger/:run_id/:step_id depuis n8n/autre', + fields: [], + }, + manual: { + label: 'Déclenchement manuel (bouton)', + help: 'L\'utilisateur clique un bouton pour déclencher', + fields: [], + }, +} + +// ----------------------------------------------------------------------------- +// Project kinds — for the project wizard + service orchestration flows +// ----------------------------------------------------------------------------- + +const JOB_TYPES = ['Installation', 'Réparation', 'Maintenance', 'Retrait', 'Dépannage', 'Autre'] +const PRIORITIES = ['low', 'medium', 'high'] +const GROUPS = ['Admin', 'Tech Targo', 'Support', 'NOC', 'Facturation'] + +export const PROJECT_KINDS = { + dispatch_job: { + label: 'Tâche dispatch', + icon: 'build', + color: '#6366f1', + fields: [ + { name: 'subject', type: 'text', label: 'Sujet', required: true }, + { name: 'job_type', type: 'select', label: 'Type', options: JOB_TYPES, default: 'Autre' }, + { name: 'priority', type: 'select', label: 'Priorité', options: PRIORITIES, default: 'medium' }, + { name: 'duration_h', type: 'number', label: 'Durée (h)', default: 1 }, + { name: 'assigned_group', type: 'select', label: 'Groupe', options: GROUPS, default: 'Tech Targo' }, + { name: 'on_open_webhook', type: 'text', label: 'Webhook à l\'ouverture (n8n)', placeholder: 'https://n8n.gigafibre.ca/webhook/...' }, + { name: 'on_close_webhook',type: 'text', label: 'Webhook à la fermeture (n8n)' }, + { name: 'merge_key', type: 'text', label: 'Merge key (optionnel)', help: 'Étapes avec le même merge_key fusionnent en une seule visite' }, + ], + }, + issue: { + label: 'Ticket', + icon: 'confirmation_number', + color: '#f59e0b', + fields: [ + { name: 'subject', type: 'text', label: 'Sujet', required: true }, + { name: 'description', type: 'textarea', label: 'Description' }, + { name: 'priority', type: 'select', label: 'Priorité', options: ['Low', 'Medium', 'High', 'Urgent'], default: 'Medium' }, + { name: 'issue_type', type: 'text', label: 'Type (texte libre)', placeholder: 'Suivi' }, + ], + }, + notify: { + label: 'Notification (SMS/email)', + icon: 'send', + color: '#3b82f6', + fields: [ + { name: 'channel', type: 'select', label: 'Canal', options: ['sms', 'email'], default: 'sms' }, + { name: 'to', type: 'text', label: 'Destinataire (template)', placeholder: '{{customer.primary_phone}}', help: 'Supporte les templates {{customer.field}}' }, + { name: 'template_id', type: 'text', label: 'Template ID (depuis email-templates.js)', placeholder: 'welcome_residential' }, + { name: 'subject', type: 'text', label: 'Sujet (email uniquement)' }, + { name: 'body', type: 'textarea', label: 'Corps (si pas de template_id)' }, + ], + }, + webhook: { + label: 'Webhook externe', + icon: 'webhook', + color: '#8b5cf6', + fields: [ + { name: 'url', type: 'text', label: 'URL', required: true, placeholder: 'https://n8n.gigafibre.ca/webhook/xxx' }, + { name: 'method', type: 'select', label: 'Méthode', options: ['POST', 'GET', 'PUT', 'DELETE'], default: 'POST' }, + { name: 'body_template', type: 'textarea', label: 'Body (JSON template)', placeholder: '{"customer": "{{customer.name}}", "contract": "{{contract.name}}"}' }, + ], + }, + erp_update: { + label: 'Mise à jour ERPNext', + icon: 'edit_note', + color: '#10b981', + fields: [ + { name: 'doctype', type: 'text', label: 'DocType', required: true, placeholder: 'Customer' }, + { name: 'docname_ref', type: 'text', label: 'Nom du doc (template)', placeholder: '{{customer.name}}' }, + { name: 'fields_json', type: 'textarea', label: 'Champs à mettre à jour (JSON)', placeholder: '{"customer_group": "Active"}' }, + ], + }, + wait: { + label: 'Attendre', + icon: 'hourglass_empty', + color: '#94a3b8', + help: 'Utilise le trigger « Après un délai » pour contrôler la durée', + fields: [], + }, + condition: { + label: 'Condition (si / sinon)', + icon: 'fork_right', + color: '#eab308', + hasBranches: true, + branchLabels: { yes: 'Oui', no: 'Non' }, + fields: [ + { name: 'field', type: 'text', label: 'Champ (chemin JSON)', placeholder: 'customer.primary_phone', required: true }, + { name: 'op', type: 'select', label: 'Opérateur', + options: ['==', '!=', '<', '>', '<=', '>=', 'in', 'not_in', 'empty', 'not_empty'], default: '==' }, + { name: 'value', type: 'text', label: 'Valeur', placeholder: 'Actif' }, + ], + }, + subscription_activate: { + label: 'Activer l\'abonnement', + icon: 'autorenew', + color: '#ec4899', + fields: [ + { name: 'subscription_ref', type: 'text', label: 'Référence abonnement (template)', placeholder: '{{contract.subscription}}' }, + ], + }, +} + +// ----------------------------------------------------------------------------- +// Agent kinds — for the conversational agent flows (AgentFlowsPage) +// ----------------------------------------------------------------------------- +// Preserved from the current AgentFlowsPage stepTypeLabels structure so the +// extracted FlowEditor can replace it as-is in a later refactor. + +export const AGENT_KINDS = { + tool: { + label: 'Appel outil', + icon: 'settings', + color: '#6366f1', + fields: [ + { name: 'tool', type: 'text', label: 'Outil', placeholder: 'get_equipment' }, + { name: 'note', type: 'text', label: 'Note' }, + ], + }, + condition: { + label: 'Condition', + icon: 'help', + color: '#eab308', + hasBranches: true, + branchLabels: { yes: 'Oui', no: 'Non' }, + fields: [ + { name: 'field', type: 'text', label: 'Champ', placeholder: 'device.online' }, + { name: 'op', type: 'select', label: 'Opérateur', options: ['==', '!=', '<', '>', '<=', '>='] }, + { name: 'value', type: 'text', label: 'Valeur' }, + ], + }, + switch: { + label: 'Switch', + icon: 'call_split', + color: '#f59e0b', + hasBranches: 'dynamic', + fields: [ + { name: 'field', type: 'text', label: 'Champ switch', placeholder: 'onu.alarm_type' }, + ], + }, + respond: { + label: 'Réponse', + icon: 'chat', + color: '#10b981', + fields: [ + { name: 'message', type: 'textarea', label: 'Message', rows: 4 }, + { name: 'note', type: 'text', label: 'Note interne' }, + ], + }, + action: { + label: 'Action', + icon: 'bolt', + color: '#8b5cf6', + fields: [ + { name: 'action', type: 'text', label: 'Action', placeholder: 'create_dispatch_job' }, + { name: 'params', type: 'json', label: 'Paramètres (JSON)' }, + { name: 'message', type: 'textarea', label: 'Message au client', rows: 2 }, + ], + }, + goto: { + label: 'Aller à', + icon: 'arrow_forward', + color: '#64748b', + fields: [ + { name: 'target', type: 'text', label: 'Cible (intent ID)' }, + ], + }, +} + +// ----------------------------------------------------------------------------- +// Utilities +// ----------------------------------------------------------------------------- + +export function getKind (catalog, kindName) { + return catalog[kindName] || { label: kindName, icon: 'circle', color: '#94a3b8', fields: [] } +} + +export function getTrigger (typeName) { + return TRIGGER_TYPES[typeName] || { label: typeName || 'Inconnu', fields: [] } +} + +/** + * Build an empty step skeleton for a given kind. + * Applies defaults from field descriptors. + */ +export function buildEmptyStep (kindName, catalog) { + const kind = getKind(catalog, kindName) + const payload = {} + for (const f of kind.fields || []) { + if (f.default !== undefined) payload[f.name] = f.default + } + return { + id: 'step_' + Math.random().toString(36).slice(2, 9), + kind: kindName, + label: kind.label, + parent_id: null, + branch: null, + depends_on: [], + trigger: { type: 'on_prev_complete' }, + payload, + } +} diff --git a/apps/ops/src/components/flow-editor/variables.js b/apps/ops/src/components/flow-editor/variables.js new file mode 100644 index 0000000..206f234 --- /dev/null +++ b/apps/ops/src/components/flow-editor/variables.js @@ -0,0 +1,110 @@ +/** + * variables.js — Catalog of variables available to flow templates. + * + * Lists the `{{path.to.value}}` expressions that the runtime can resolve when + * it interpolates a step's payload (SMS body, email subject, webhook URL, etc.) + * or evaluates a JSONLogic/expression condition. + * + * The paths are keyed by the template's `applies_to` target (Customer, + * Quotation, Service Contract, …) because the runtime builds the context + * object differently per doctype: + * + * - A Customer-scoped flow has `context.customer = ` + * - A Service Contract-scoped flow has `context.contract = ` + * plus `context.customer` auto-joined from `contract.customer` + * - And so on + * + * ⚠️ If you extend this catalog, mirror the change server-side in + * `services/targo-hub/lib/flow-context.js` so the runtime actually + * populates the new path. + * + * Performance: constant-time lookup, tables are < 20 entries. Safe to import + * anywhere in the editor. + */ + +// Always-available regardless of applies_to. +export const COMMON_VARIABLES = [ + { label: 'Maintenant (ISO 8601)', path: 'now', hint: 'Timestamp au démarrage du flow' }, + { label: 'ID du run', path: 'flow.run_id', hint: 'Identifiant unique du flow run' }, + { label: 'Nom du template', path: 'flow.template', hint: 'Flow Template de référence' }, +] + +// Per-applies_to domain variables. Keep the strings exactly as the runtime +// exposes them (context..) to avoid silent mismatches. +export const VARIABLE_CATALOGS = { + Customer: [ + { label: 'ID client (doc)', path: 'customer.name', hint: 'ex: CUST-00042' }, + { label: 'Nom commercial', path: 'customer.customer_name', hint: 'Jean Tremblay / Acme Inc.' }, + { label: 'Type', path: 'customer.customer_type', hint: 'Individual | Company' }, + { label: 'Groupe', path: 'customer.customer_group' }, + { label: 'Email', path: 'customer.email_id' }, + { label: 'Téléphone principal', path: 'customer.primary_phone' }, + { label: 'Mobile', path: 'customer.mobile_no' }, + { label: 'Adresse principale', path: 'customer.primary_address' }, + { label: 'Langue', path: 'customer.language', hint: 'fr | en' }, + { label: 'Territoire', path: 'customer.territory' }, + ], + Quotation: [ + { label: 'ID devis', path: 'quotation.name' }, + { label: 'Client (ID)', path: 'quotation.customer' }, + { label: 'Nom du client', path: 'quotation.customer_name' }, + { label: 'Total HT', path: 'quotation.total' }, + { label: 'Total TTC', path: 'quotation.grand_total' }, + { label: 'Statut', path: 'quotation.status' }, + { label: 'Date transaction', path: 'quotation.transaction_date' }, + { label: 'Valide jusqu\'au', path: 'quotation.valid_till' }, + { label: 'Email du client', path: 'customer.email_id' }, + { label: 'Téléphone du client', path: 'customer.primary_phone' }, + ], + 'Service Contract': [ + { label: 'ID contrat', path: 'contract.name' }, + { label: 'Client (ID)', path: 'contract.customer' }, + { label: 'Nom du client', path: 'contract.customer_name' }, + { label: 'Plan', path: 'contract.plan' }, + { label: 'Abonnement (ID)', path: 'contract.subscription' }, + { label: 'Date de début', path: 'contract.start_date' }, + { label: 'Date de fin', path: 'contract.end_date' }, + { label: 'Mensualité', path: 'contract.monthly_price' }, + { label: 'Statut', path: 'contract.status' }, + { label: 'Email du client', path: 'customer.email_id' }, + { label: 'Téléphone du client', path: 'customer.primary_phone' }, + { label: 'Adresse d\'installation', path: 'contract.service_address' }, + ], + Issue: [ + { label: 'ID ticket', path: 'issue.name' }, + { label: 'Sujet', path: 'issue.subject' }, + { label: 'Description', path: 'issue.description' }, + { label: 'Priorité', path: 'issue.priority' }, + { label: 'Statut', path: 'issue.status' }, + { label: 'Type', path: 'issue.issue_type' }, + { label: 'Client (ID)', path: 'issue.customer' }, + { label: 'Nom du client', path: 'issue.customer_name' }, + { label: 'Date d\'ouverture', path: 'issue.opening_date' }, + { label: 'Email du client', path: 'customer.email_id' }, + ], + Subscription: [ + { label: 'ID abonnement', path: 'subscription.name' }, + { label: 'Client (ID)', path: 'subscription.customer' }, + { label: 'Date de début', path: 'subscription.start_date' }, + { label: 'Date de fin', path: 'subscription.end_date' }, + { label: 'Plan', path: 'subscription.plan' }, + { label: 'Statut', path: 'subscription.status' }, + ], +} + +/** + * Return the flat list of variables available for the given applies_to target. + * If applies_to is null/unknown, returns just the COMMON_VARIABLES. + */ +export function getVariables (appliesTo) { + const specific = VARIABLE_CATALOGS[appliesTo] || [] + return [...specific, ...COMMON_VARIABLES] +} + +/** + * List of applies_to keys we know about — used by the editor to warn users + * when they haven't picked one yet. + */ +export function hasVariables (appliesTo) { + return Boolean(VARIABLE_CATALOGS[appliesTo]) +} diff --git a/apps/ops/src/components/shared/ProjectWizard.vue b/apps/ops/src/components/shared/ProjectWizard.vue index d02c337..04f23a5 100644 --- a/apps/ops/src/components/shared/ProjectWizard.vue +++ b/apps/ops/src/components/shared/ProjectWizard.vue @@ -5,24 +5,44 @@
- Projet — {{ STEP_LABELS[currentStep] }} + {{ issue?.name ? 'Projet' : 'Nouvelle soumission' }} — {{ STEP_LABELS[currentStep] }} +
+
+ +
-
{{ issue?.name }} · {{ issue?.subject }}
-
{{ i < currentStep ? '✓' : i + 1 }}
+ class="wizard-step-dot" :class="{ active: i === currentStep, done: i < currentStep, skipped: isQuickSale && i === 1 }" + @click="handleStepDotClick(i)"> +
{{ isQuickSale && i === 1 ? '–' : (i < currentStep ? '✓' : i + 1) }}
{{ label }}
-
Choisissez un modèle de projet ou créez un projet vide
+
Que voulez-vous vendre ?
+ +
+
+
+
Vente rapide — sans installation
+
Ajout d'équipement (ex : routeur, amplificateur WiFi) ou d'un service mensuel sur une adresse existante. Aucune étape d'installation.
+
+ +
+ +
+
Ou choisir un modèle d'installation :
+ + +
@@ -34,14 +54,30 @@
-
Projet vide
+
Projet sur mesure
Créer les étapes manuellement
-
Modifiez les étapes, dates et assignations
+
+ +
+
Mode Vente rapide — aucune étape requise
+
Appuyez sur « Suivant » pour aller directement au catalogue.
+
+
+
+ +
+
+ {{ lastMergeCount }} étape{{ lastMergeCount > 1 ? 's' : '' }} fusionnée{{ lastMergeCount > 1 ? 's' : '' }} — un seul déplacement +
+
Les services sélectionnés partagent la même visite d'installation.
+
+
+
Modifiez les étapes, dates et assignations
@@ -102,102 +138,364 @@ @click="addStep" /> - +
- +
-
-
- + +
+
+ + {{ orderItems.length }}
-
-
-
- -
-
{{ p.item_name }}
-
{{ p.item_code }}
+
+
+ + +
+
+ + +
+
+
+
+ {{ recurringTotal.toFixed(2) }}$/mois +
+
+ {{ onetimeTotal.toFixed(2) }}$unique +
+
+ +{{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }}$ tx +
+
+ +
+ + +
+
+
1
+
+
Internet
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ {{ t.label }} + + {{ t.badge }} + + · {{ t.speed }}
-
-
{{ p.rate.toFixed(2) }}$
-
- {{ p.billing_type === 'Mensuel' ? '/mois' : p.billing_type === 'Annuel' ? '/an' : 'unique' }} -
+
{{ t.desc }}
+
+
+
+ {{ t.price_effective.toFixed(2) }}$/mois
- +
+ {{ t.price_base.toFixed(2) }}$ +
+
+
+
-
Chargement...
+
+ + +
+
+
2
+
+
Services additionnels
+
+ Optionnels — Télé dès 25$/mois · Téléphonie +10$/mois · WiFi booster, etc. · rabais combo auto dès 2 services +
+
+
+ + +
+
+
+ +
+
+
+ Télé {{ tvAnchor.label }} + + {{ tvAnchor.badge }} + +
+
{{ tvAnchor.desc }}
+
+
+
à partir de
+
+ {{ (selectedTvTier || tvHeroTiers[0]).price_effective.toFixed(2) }}$/mois +
+
+
+ + +
+
+ +
+
+
+ +
{{ t.label }}
+ + {{ t.badge }} + + + À venir + +
+
{{ t.desc }}
+
+ + {{ t.picks_allowed }} chaînes au choix +
+
+ + selon les chaînes choisies +
+ +
+
+
+ + +
+
+
+ +
+
+
+ Téléphonie + + · {{ phoneMode === 'keep' ? 'Conservation' : 'Nouveau numéro' }} + + +
+
+ + +
+
+
+
à partir de
+
+ +10.00$/mois +
+
+
+ + +
+
+ +
+
+
+ +
Nouveau numéro
+
+
Attribution automatique à l'installation — indicatif local.
+
+ +10.00$ + /mois +
+ +
+ +
+
+ +
Conservation (port-in)
+ + Preuve requise + +
+
+ Le client garde son numéro actuel. Le tech doit photographier la facture du fournisseur actuel comme preuve de propriété. +
+
+ +
+
+ +10.00$ + /mois +
+ +
+
+
+ + +
+
+ +
+
+ +{{ p.price_delta.toFixed(2) }}$/mois +
+
{{ p.label }}
+
+ +
+
+
+ + +
+ +
+
Code de référence
+
50$ de crédit pour le nouvel abonné · 50$ pour le parrain à l'installation
+
+ + + +
+
+ {{ referralError }}
-
-
-
- - - -
-
- -
-
- - - -
-
- - -
-
-
- - -
-
- Template: {{ item.project_template_id }} - - Étapes chargées -
-
- - - -
-
- Sous-total unique - {{ onetimeTotal.toFixed(2) }} $ -
-
- Récurrent mensuel - {{ recurringTotal.toFixed(2) }} $/mois -
- -
- Taxes (TPS+TVQ ~14.975%) - {{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }} $ -
+ +
+ +
+ +
+ +
+ Sommaire — {{ orderItems.length }} item{{ orderItems.length > 1 ? 's' : '' }} +
+ + +
+ +
+ +
Aucun item dans le panier
+ +
+ +
+
+ +
+
+ +
+
{{ i + 1 }}
+ +
+
{{ item.item_name || 'Item sans nom' }}
+
+ Qté {{ item.qty }} + · + {{ (item.qty * item.rate).toFixed(2) }}$ + /mois + + +
+
+ + +
+ + +
+
+
+ + + + +
+
+
+
+ +
+
+ + + +
+
+ + + + +
+
+ + +
+ + +
+
+
+
+ + +
+
+ Sous-total unique + {{ onetimeTotal.toFixed(2) }} $ +
+
+ dont extras sur étapes + {{ stepsExtraTotal.toFixed(2) }} $ +
+
+ Récurrent mensuel + {{ recurringTotal.toFixed(2) }} $/mois +
+
+ Valeur des promotions étalées + {{ promoTotal.toFixed(2) }} $ +
+ +
+ Taxes (TPS+TVQ ~14.975%) + {{ ((onetimeTotal + recurringTotal) * 0.14975).toFixed(2) }} $ +
+
+
+ +
Vérifiez le projet avant publication
@@ -234,10 +712,14 @@ {{ orderMode === 'quotation' ? 'Devis' : orderMode === 'prepaid' ? 'Facture' : 'Bon de commande' }} — {{ orderItems.length }} items
-
+
{{ item.qty }}x {{ item.item_name }} + + {{ (item.qty * item.regular_price).toFixed(2) }}$ + {{ (item.qty * item.rate).toFixed(2) }}${{ item.billing === 'recurring' ? '/mois' : '' }}
@@ -245,6 +727,24 @@ Total {{ onetimeTotal.toFixed(2) }}$ + {{ recurringTotal.toFixed(2) }}$/mois
+
+  Valeur promotions étalées sur {{ maxContractMonths }} mois + {{ promoTotal.toFixed(2) }}$ +
+
+ +
+
+ + Contrat de service — {{ acceptanceMethod === 'docuseal' ? 'Commercial' : 'Résidentiel' }} +
+
+ {{ recurringTotal.toFixed(2) }}$/mois · {{ maxContractMonths }} mois + +
+
+ Récapitulatif envoyé au client — click-to-accept avec horodatage + IP. Pas de "pénalité"; changement avant {{ maxContractMonths }} mois = portion non étalée au prorata. +
@@ -294,6 +794,9 @@ {{ pendingAcceptance ? 'En attente d\'acceptation client' : 'Projet créé avec succès' }}
{{ publishedDocType }} {{ publishedDocName }}
+
+ Contrat {{ publishedContractName }} +
{{ publishedJobCount }} tâches créées
@@ -356,33 +859,242 @@ + + + +
+
+ +
+
Catalogue
+
Ajoutez des produits ou services au devis
+
+ +
+ + + +
+
+ + {{ cat }} +
+
+
+ + +
+ +
Chargement du catalogue...
+
+
+ +
Aucun produit ne correspond à votre recherche
+
+
+
+
+ +
+
+
{{ p.item_name }}
+
+ {{ p.item_code }} + Au panier +
+
+
+
{{ p.rate.toFixed(2) }} $
+
+ {{ p.billing_type === 'Mensuel' ? '/ mois' : p.billing_type === 'Annuel' ? '/ an' : 'unique' }} +
+
+ +
+
+
+ + +
+
+ + + + + +
Ajouter une étape
+ + +
+ + +
+
+ +
+
+ +
+
+ +
+ + + + +
+
+ + + + + + +
+
Configurateur chaînes
+
+ {{ pickerPicksUsed }} / {{ pickerPicksAllowed }} choix utilisés + + · +{{ pickerOverage }} à la carte (tarification à venir) + +
+
+ +
+ + + +
+
{{ t.label }}
+
{{ t.price_effective.toFixed(2) }}$/mois
+
+ {{ t.picks_allowed }} choix · {{ t.pick_unit_cost?.toFixed(2) }}$/pick régulier +
+
+
+ + + + + + + + + + {{ ch.name }} + + 2 + + + + + +
+ +
+
+ {{ ch.name }} + 2 choix +
+
+ incl. {{ ch.sub.join(', ') }} +
+
+
+3$
+
+
+ Aucune chaîne ne correspond. +
+
+ + + + + +
+
+ @@ -629,7 +2091,119 @@ watch(() => props.modelValue, (v) => { } .wizard-step-dot.active .wizard-step-num { background: #6366f1; color: #fff; } .wizard-step-dot.done .wizard-step-num { background: #10b981; color: #fff; } +.wizard-step-dot.skipped { opacity: 0.3; } +.wizard-step-dot.skipped .wizard-step-num { background: #f1f5f9; color: #94a3b8; } .wizard-step-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; } +.quick-sale-card { + display: flex; align-items: center; gap: 12px; + padding: 14px 16px; border: 2px solid #c7d2fe; border-radius: 12px; + background: linear-gradient(135deg, #eef2ff 0%, #ffffff 100%); + cursor: pointer; transition: all 0.15s; +} +.quick-sale-card:hover { border-color: #6366f1; box-shadow: 0 4px 12px rgba(99,102,241,0.18); transform: translateY(-1px); } +.quick-sale-card.selected { border-color: #6366f1; background: #eef2ff; } +.quick-sale-icon { + width: 46px; height: 46px; border-radius: 12px; + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); + display: flex; align-items: center; justify-content: center; flex-shrink: 0; + box-shadow: 0 4px 10px rgba(99,102,241,0.3); +} +.quick-sale-title { font-weight: 700; font-size: 0.92rem; color: #1e293b; } +.quick-sale-desc { font-size: 0.76rem; color: #64748b; margin-top: 2px; line-height: 1.35; } +.quick-sale-notice { + display: flex; align-items: flex-start; padding: 12px 14px; + background: #eef2ff; border: 1px solid #c7d2fe; border-radius: 10px; + margin-bottom: 8px; +} +.merge-notice { + display: flex; align-items: flex-start; padding: 12px 14px; + background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; + margin-bottom: 8px; +} +.bundles-summary { + border: 1px solid #c7d2fe; background: #eef2ff; border-radius: 10px; + padding: 10px 12px; margin-bottom: 10px; +} +.bundles-summary-header { + display: flex; align-items: center; gap: 8px; margin-bottom: 8px; +} +.bundles-summary-title { + font-weight: 700; font-size: 0.85rem; color: #3730a3; + display: flex; align-items: center; gap: 8px; +} +.bundles-summary-count { + background: #c7d2fe; color: #3730a3; font-weight: 600; + padding: 2px 8px; border-radius: 10px; font-size: 0.72rem; +} +.bundles-summary-list { display: flex; flex-direction: column; gap: 6px; } +.bundle-item { + background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; + overflow: hidden; +} +.bundle-item-header { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; + cursor: pointer; transition: background 0.15s; +} +.bundle-item-header:hover { background: #f8fafc; } +.bundle-item-name { flex: 1; min-width: 0; } +.bundle-item-steps { + border-top: 1px solid #e2e8f0; padding: 6px 10px 8px 34px; + background: #fafbfc; display: flex; flex-direction: column; gap: 4px; +} +.bundle-step-row { + display: flex; align-items: center; gap: 8px; + font-size: 0.78rem; color: #334155; +} +.bundle-step-subject { flex: 1; } +.bundle-add-step { + display: flex; align-items: center; gap: 6px; cursor: pointer; + padding: 6px 8px; margin-top: 2px; border-radius: 6px; + color: #4f46e5; font-size: 0.76rem; font-weight: 600; + border: 1px dashed #c7d2fe; background: #ffffff; + transition: background 0.15s; +} +.bundle-add-step:hover { background: #eef2ff; } + +.pricing-compact { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + background: #f0fdfa; border: 1px solid #99f6e4; border-radius: 8px; + padding: 6px 10px; margin-bottom: 8px; +} +.pricing-compact-label { font-weight: 600; color: #115e59; font-size: 0.78rem; } +.pricing-area-badge { + background: #ccfbf1; color: #115e59; font-weight: 600; + padding: 2px 8px; border-radius: 10px; font-size: 0.7rem; +} +.pricing-area-default { background: #fef3c7; color: #92400e; } + +.bundle-step-install { background: #f0fdfa; border-radius: 6px; padding: 4px 6px; } +.install-step-panel { + background: #f0fdfa; border: 1px solid #99f6e4; border-radius: 8px; + margin: 4px 0 4px 26px; padding: 10px 12px; + display: flex; flex-direction: column; gap: 8px; +} +.install-step-area-header { + display: flex; align-items: center; gap: 6px; + padding-bottom: 6px; border-bottom: 1px solid #ccfbf1; +} +.install-step-area-name { font-weight: 700; color: #115e59; font-size: 0.8rem; } +.install-step-area-meta { font-size: 0.72rem; color: #475569; } +.install-step-row { display: flex; align-items: center; gap: 10px; } +.install-step-row-label { font-size: 0.8rem; color: #0f172a; font-weight: 600; flex: 1; } +.install-step-extras { background: #ffffff; border: 1px solid #ccfbf1; border-radius: 6px; padding: 6px 8px; } +.install-step-extras-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } +.install-step-extras-empty { font-size: 0.74rem; color: #64748b; font-style: italic; padding: 3px 0; } +.install-step-extra-row { + display: flex; align-items: center; gap: 8px; padding: 5px 0; + border-top: 1px solid #f1f5f9; +} +.install-step-extra-row:first-of-type { border-top: 0; } +.install-step-extra-text { flex: 1; min-width: 0; } +.install-step-extra-amount { font-weight: 700; color: #0f766e; font-size: 0.8rem; } +.install-step-total { + display: flex; align-items: center; gap: 8px; + background: #ccfbf1; border-radius: 6px; padding: 5px 8px; +} .template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; } .template-card { border: 2px solid #e2e8f0; border-radius: 10px; padding: 14px; @@ -659,6 +2233,172 @@ watch(() => props.modelValue, (v) => { .review-step-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; background: #fff; } .review-step-card.has-dep { border-left: 3px solid #f59e0b; } .catalog-picker { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 12px; } +.catalog-open-btn { border-radius: 8px; padding: 4px 14px; font-weight: 600; letter-spacing: 0.2px; } +.catalog-modal { + width: 560px; max-width: 95vw; height: 80vh; max-height: 720px; + display: flex; flex-direction: column; border-radius: 14px; overflow: hidden; +} +.catalog-modal-header { + padding: 14px 16px 10px; border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #ffffff, #f8fafc); +} +.catalog-search :deep(.q-field__control) { border-radius: 10px; } +.catalog-cat-row { + display: flex; gap: 6px; overflow-x: auto; margin-top: 10px; + padding-bottom: 4px; scrollbar-width: none; +} +.catalog-cat-row::-webkit-scrollbar { display: none; } +.catalog-cat-chip { + display: inline-flex; align-items: center; flex-shrink: 0; + padding: 5px 12px; border-radius: 999px; background: #f1f5f9; color: #475569; + font-size: 0.78rem; font-weight: 500; cursor: pointer; transition: all 0.15s; + white-space: nowrap; +} +.catalog-cat-chip:hover { background: #e2e8f0; } +.catalog-cat-chip.active { background: #6366f1; color: #fff; box-shadow: 0 2px 6px rgba(99,102,241,0.3); } +.catalog-modal-body { padding: 10px 12px; background: #fafbfc; } +.catalog-list { display: flex; flex-direction: column; gap: 8px; padding-bottom: 8px; } +.catalog-card { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; background: #fff; border: 1px solid #e2e8f0; + border-radius: 12px; cursor: pointer; transition: all 0.15s; + position: relative; +} +.catalog-card:hover { border-color: #6366f1; box-shadow: 0 2px 8px rgba(99,102,241,0.12); transform: translateY(-1px); } +.catalog-card:active { transform: translateY(0); } +.catalog-card.just-added { + animation: catalogPulse 0.9s ease-out; +} +@keyframes catalogPulse { + 0% { background: #fff; } + 30% { background: #dcfce7; border-color: #10b981; box-shadow: 0 0 0 4px rgba(16,185,129,0.2); } + 100% { background: #fff; } +} +.catalog-card-icon { + width: 42px; height: 42px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.catalog-card-body { flex: 1; min-width: 0; } +.catalog-card-name { + font-size: 0.88rem; font-weight: 600; color: #1e293b; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.catalog-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; } +.catalog-card-code { font-size: 0.7rem; color: #94a3b8; font-family: ui-monospace, monospace; } +.catalog-card-chip { height: 18px; font-size: 0.65rem !important; } +.catalog-card-price { text-align: right; flex-shrink: 0; } +.catalog-card-rate { font-size: 0.95rem; font-weight: 700; color: #1e293b; } +.catalog-card-billing { font-size: 0.68rem; font-weight: 500; margin-top: -2px; } +.catalog-card-billing.recurring { color: #ea580c; } +.catalog-card-billing.onetime { color: #64748b; } +.catalog-card-add { flex-shrink: 0; } +.catalog-modal-footer { + padding: 10px 14px; border-top: 1px solid #e2e8f0; background: #fff; + display: flex; align-items: center; justify-content: space-between; gap: 10px; +} +.catalog-footer-counter { font-size: 0.8rem; color: #475569; display: flex; align-items: center; } +.catalog-footer-added { margin-left: 4px; } +.catalog-loading, .catalog-empty { + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 60px 20px; text-align: center; +} +@media (max-width: 599px) { + .catalog-modal { border-radius: 0; height: 100vh; max-height: 100vh; width: 100vw; max-width: 100vw; } +} +.presets-row { background: #fefce8; border: 1px solid #fde68a; border-radius: 10px; padding: 8px 10px; } +.preset-hero { + display: flex; align-items: center; gap: 12px; + padding: 12px 14px; border: 1.5px solid #c7d2fe; border-radius: 10px; + background: linear-gradient(135deg, #ffffff 0%, #eef2ff 100%); + cursor: pointer; transition: all 0.15s; +} +.preset-hero:hover { + border-color: #6366f1; box-shadow: 0 2px 10px rgba(99, 102, 241, 0.18); + transform: translateY(-1px); +} +.preset-hero.preset-hero-added { + border-color: #10b981; + background: linear-gradient(135deg, #ffffff 0%, #ecfdf5 100%); +} +.preset-hero-icon { + width: 44px; height: 44px; border-radius: 10px; + background: #fff; border: 1px solid #e2e8f0; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.preset-hero-text { flex: 1; min-width: 0; } +.preset-hero-label { font-size: 0.95rem; font-weight: 700; color: #1e293b; } +.preset-hero-desc { font-size: 0.75rem; color: #64748b; margin-top: 2px; } +.preset-hero-price { text-align: right; line-height: 1.1; } +.preset-hero-price-tag { font-size: 0.66rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.4px; } +.preset-hero-price-big { font-size: 1.35rem; font-weight: 800; color: #3730a3; } +.preset-hero-price-unit { font-size: 0.7rem; font-weight: 600; color: #64748b; margin-left: 2px; } +.preset-hero-price-strike { + font-size: 0.7rem; color: #94a3b8; text-decoration: line-through; margin-top: 1px; +} +.preset-hero-group { position: relative; } +.preset-hero-label-tier { color: #3730a3; } +.preset-hero-action { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } +.preset-hero-chevron { transition: transform 0.18s; } +.preset-hero-chevron.rotated { transform: rotate(180deg); } + +.tier-expand { + display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 8px; margin-top: 8px; + padding: 10px; border: 1px solid #e0e7ff; border-radius: 10px; + background: #fafbff; + animation: tier-fade-in 0.18s ease-out; +} +@keyframes tier-fade-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +.tier-card { + position: relative; padding: 10px 12px; border: 1.5px solid #e2e8f0; + border-radius: 8px; background: #fff; cursor: pointer; transition: all 0.15s; + display: flex; flex-direction: column; gap: 4px; +} +.tier-card:hover { + border-color: #6366f1; box-shadow: 0 2px 8px rgba(99, 102, 241, 0.18); + transform: translateY(-1px); +} +.tier-card.tier-card-active { + border-color: #10b981; background: linear-gradient(135deg, #ffffff 0%, #ecfdf5 100%); +} +.tier-card-head { display: flex; align-items: center; gap: 6px; } +.tier-card-title { font-size: 0.88rem; font-weight: 700; color: #1e293b; flex: 1; } +.tier-card-badge { font-size: 0.6rem; padding: 2px 6px; } +.tier-card-speed { font-size: 0.72rem; color: #475569; font-weight: 600; } +.tier-card-desc { font-size: 0.7rem; color: #64748b; line-height: 1.25; } +.tier-card-price { display: flex; align-items: baseline; gap: 4px; margin-top: 4px; } +.tier-card-price-big { font-size: 1.05rem; font-weight: 800; color: #3730a3; } +.tier-card-price-unit { font-size: 0.65rem; color: #64748b; font-weight: 600; } +.tier-card-price-strike { + font-size: 0.65rem; color: #94a3b8; text-decoration: line-through; margin-left: 4px; +} +.tier-card-check { position: absolute; top: 8px; right: 8px; } +.preset-upsell { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 8px; + background: #fff; cursor: pointer; transition: all 0.15s; + flex: 1; min-width: 150px; +} +.preset-upsell:hover { border-color: #6366f1; background: #eef2ff; box-shadow: 0 1px 4px rgba(99, 102, 241, 0.15); } +.preset-upsell.preset-added { border-color: #10b981; background: #f0fdf4; } +.preset-upsell.tier-addon { border-style: dashed; } +.preset-upsell-body { min-width: 0; flex: 1; line-height: 1.15; } +.preset-upsell-delta { font-size: 0.82rem; font-weight: 700; color: #3730a3; } +.preset-upsell-mo { font-size: 0.65rem; font-weight: 600; color: #64748b; margin-left: 2px; } +.preset-upsell-label { font-size: 0.72rem; color: #475569; } +.preset-upsell.preset-added .preset-upsell-delta { color: #047857; } + +.referral-row { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border: 1px dashed #c4b5fd; border-radius: 8px; + background: #faf5ff; +} +.referral-title { font-size: 0.8rem; font-weight: 600; color: #4c1d95; } +.referral-desc { font-size: 0.7rem; color: #6b7280; } +.referral-error { font-size: 0.72rem; color: #b91c1c; margin-top: 4px; padding-left: 4px; } .catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 6px; max-height: 220px; overflow-y: auto; @@ -669,5 +2409,421 @@ watch(() => props.modelValue, (v) => { } .catalog-item:hover { border-color: #6366f1; background: #f5f3ff; box-shadow: 0 1px 4px rgba(99, 102, 241, 0.12); } .item-line { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 10px; margin-bottom: 6px; background: #fafbfc; } + +.billing-pill { + display: inline-flex; align-items: center; + border: 1px solid #e2e8f0; border-radius: 999px; + background: #fff; padding: 2px 8px; font-size: 0.7rem; font-weight: 700; + color: #475569; cursor: pointer; transition: all 0.15s; + font-family: inherit; line-height: 1.2; +} +.billing-pill:hover { border-color: #6366f1; background: #eef2ff; } +.billing-pill.recurring { background: #fff7ed; border-color: #fed7aa; color: #c2410c; } +.billing-pill.recurring:hover { background: #ffedd5; border-color: #fb923c; } +.billing-pill.yearly { background: #ecfdf5; border-color: #a7f3d0; color: #047857; } +.billing-pill.yearly:hover { background: #d1fae5; border-color: #34d399; } +.billing-pill.onetime { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; } +.billing-pill.onetime:hover { background: #e0e7ff; border-color: #818cf8; } + +.item-steps-wrap { display: flex; flex-direction: column; gap: 4px; } +.item-steps-pill { + align-self: flex-start; + display: inline-flex; align-items: center; + padding: 3px 10px; border-radius: 999px; + background: #eef2ff; border: 1px solid #c7d2fe; + color: #3730a3; font-size: 0.72rem; font-weight: 600; + cursor: pointer; transition: all 0.15s; font-family: inherit; +} +.item-steps-pill:hover { background: #e0e7ff; border-color: #6366f1; } +.item-steps-pill.expanded { background: #6366f1; border-color: #4f46e5; color: #fff; } +.item-steps-panel { + border: 1px solid #e2e8f0; border-radius: 8px; + background: #ffffff; padding: 6px 10px; + display: flex; flex-direction: column; gap: 4px; + margin-top: 2px; +} +.item-steps-panel .bundle-step-row { + display: flex; align-items: center; gap: 8px; + font-size: 0.78rem; color: #334155; padding: 2px 0; +} +.item-steps-panel .bundle-step-subject { flex: 1; min-width: 0; } +.step-extra-chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 8px; border-radius: 999px; + background: #fff7ed; border: 1px dashed #fed7aa; + color: #9a3412; font-size: 0.72rem; font-weight: 600; + cursor: pointer; font-family: inherit; + transition: background 0.15s, border-color 0.15s; + max-width: 240px; +} +.step-extra-chip:hover { background: #ffedd5; border-color: #fb923c; } +.step-extra-chip-set { + background: #ffedd5; border-style: solid; border-color: #fb923c; +} +.step-extra-chip-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.step-extra-chip-amount { font-weight: 700; color: #c2410c; margin-left: 2px; } +.step-extra-chip-placeholder { color: #b45309; } +.step-extra-menu { min-width: 300px; padding: 8px 10px; } +.step-extra-menu-head { + font-size: 0.72rem; font-weight: 700; color: #9a3412; + text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 4px 6px; +} +.step-extra-menu-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 4px; +} +.step-extra-preset { + display: flex; align-items: center; justify-content: space-between; + padding: 5px 8px; border-radius: 6px; cursor: pointer; font-family: inherit; + background: #fff7ed; border: 1px solid #fed7aa; color: #7c2d12; + font-size: 0.75rem; text-align: left; gap: 6px; + transition: background 0.15s, border-color 0.15s; +} +.step-extra-preset:hover { background: #ffedd5; border-color: #fb923c; } +.step-extra-preset.active { + background: #fb923c; border-color: #ea580c; color: #fff; +} +.step-extra-preset.active .step-extra-preset-amount { color: #fff; } +.step-extra-preset-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.step-extra-preset-amount { font-weight: 700; color: #c2410c; font-size: 0.72rem; } +.step-extra-custom { padding: 4px; } +.step-extra-custom-title { + font-size: 0.72rem; font-weight: 700; color: #64748b; + text-transform: uppercase; letter-spacing: 0.05em; +} +.step-extra-remove { + display: flex; align-items: center; gap: 4px; width: 100%; + padding: 5px 8px; border-radius: 6px; cursor: pointer; font-family: inherit; + background: transparent; border: none; color: #dc2626; + font-size: 0.75rem; font-weight: 600; +} +.step-extra-remove:hover { background: #fee2e2; } +.item-steps-panel .bundle-add-step { + display: flex; align-items: center; gap: 6px; cursor: pointer; + padding: 4px 8px; margin-top: 2px; border-radius: 6px; + color: #4f46e5; font-size: 0.74rem; font-weight: 600; + border: 1px dashed #c7d2fe; background: #ffffff; + transition: background 0.15s; font-family: inherit; +} +.item-steps-panel .bundle-add-step:hover { background: #eef2ff; } +.catalog-bound :deep(.q-field__control) { cursor: pointer; } +.catalog-bound :deep(.q-field__control):hover { border-color: #6366f1; background: #f5f3ff; } +.catalog-bound :deep(input) { cursor: pointer !important; color: #1e293b; font-weight: 600; } .totals-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; } + +/* TV channel picker dialog */ +.channel-picker-card { + width: 560px; max-width: 96vw; + display: flex; flex-direction: column; max-height: 90vh; +} +.channel-picker-head { border-bottom: 1px solid #f1f5f9; } +.channel-picker-sub { + font-size: 0.78rem; color: #64748b; margin-top: 2px; +} +.channel-picker-overage { + color: #ea580c; font-weight: 600; margin-left: 4px; +} +.channel-picker-chips { + display: flex; flex-wrap: wrap; gap: 6px; + border-bottom: 1px solid #f1f5f9; padding-top: 6px; padding-bottom: 10px; +} +.channel-chip { + position: relative; padding-right: 32px !important; + font-size: 0.78rem; font-weight: 600; +} +.channel-chip-pastille { + position: absolute; top: -4px; right: 4px; + min-width: 16px; height: 16px; font-size: 0.62rem; + padding: 0 4px; font-weight: 700; +} +.channel-chip-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.channel-picker-list { + flex: 1; overflow-y: auto; padding: 4px 0; + display: flex; flex-direction: column; gap: 2px; +} +.channel-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 16px; border-radius: 0; cursor: pointer; + transition: background 0.12s; +} +.channel-row:hover { background: #f8fafc; } +.channel-row-selected { background: #ecfdf5; } +.channel-row-selected:hover { background: #d1fae5; } +.channel-row-premium .channel-row-name { color: #9d174d; } +.channel-row-name { + font-size: 0.85rem; font-weight: 600; color: #1e293b; + display: flex; align-items: center; gap: 4px; +} +.channel-row-sub { + font-size: 0.72rem; color: #94a3b8; margin-top: 1px; +} +.channel-row-surcharge { + font-size: 0.78rem; font-weight: 700; color: #be185d; +} +.channel-row-empty { + padding: 24px; text-align: center; color: #94a3b8; font-size: 0.85rem; +} +.channel-picker-foot { border-top: 1px solid #f1f5f9; } + +/* Tier segmented toggle inside picker */ +.channel-picker-tiers { + display: flex; gap: 8px; + border-bottom: 1px solid #f1f5f9; +} +.channel-picker-tier { + flex: 1; padding: 8px 10px; border-radius: 8px; + border: 1px solid #e5e7eb; background: #fafafa; + cursor: pointer; transition: all 0.15s; + display: flex; flex-direction: column; gap: 2px; +} +.channel-picker-tier:hover { border-color: #f472b6; background: #fdf2f8; } +.channel-picker-tier-active { + border-color: #ec4899; background: #fdf2f8; + box-shadow: 0 0 0 2px #fbcfe8; +} +.channel-picker-tier-label { font-size: 0.84rem; font-weight: 700; color: #1e293b; } +.channel-picker-tier-price { font-size: 0.82rem; font-weight: 700; color: #be185d; } +.channel-picker-tier-meta { font-size: 0.7rem; color: #64748b; } + +/* TV group gated behind Internet */ +.preset-hero-group-disabled .preset-hero { + background: #f8fafc; border-color: #e5e7eb; opacity: 0.65; +} +.preset-hero-group-disabled .preset-hero:hover { background: #f1f5f9; } +.preset-hero-group-disabled .preset-hero-label::after { + content: ' — Internet requis'; color: #9ca3af; font-weight: 400; font-size: 0.78rem; +} + +/* Telephony hero group + port-in input */ +.preset-hero-phone { border-color: #14b8a6; } +.preset-hero-phone:hover { background: #f0fdfa; } +.preset-hero-label-phone { color: #0f766e; } +.preset-hero-price-phone { color: #0f766e; } +.tier-expand-phone .tier-card:hover { border-color: #14b8a6; background: #f0fdfa; } +.tier-card-keep-input { + margin-top: 8px; padding-top: 8px; + border-top: 1px dashed #e5e7eb; +} + +/* ── Wizard step blocks (Étape 1 Internet, Étape 2 Services additionnels) ── */ +.wizard-step-block { + border: 1px solid #e2e8f0; border-radius: 10px; + background: #fefefe; padding: 10px 12px; +} +.wizard-step-block-required { + background: linear-gradient(180deg, #fefce8 0%, #ffffff 50%); + border-color: #fde68a; +} +.wizard-step-header { + display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; +} +.wizard-step-header-clickable { + cursor: pointer; + padding: 2px; + border-radius: 8px; + margin: -2px -2px 6px -2px; + transition: background 0.15s; +} +.wizard-step-header-clickable:hover { + background: rgba(99, 102, 241, 0.05); +} +.wizard-step-action { + display: flex; align-items: center; gap: 6px; + flex-shrink: 0; padding-top: 2px; +} +.wizard-step-block-expanded { box-shadow: 0 1px 4px rgba(99, 102, 241, 0.08); } +.wizard-step-number { + flex-shrink: 0; + width: 26px; height: 26px; border-radius: 50%; + background: #6366f1; color: #fff; + display: flex; align-items: center; justify-content: center; + font-size: 0.85rem; font-weight: 800; + box-shadow: 0 1px 3px rgba(99, 102, 241, 0.35); +} +.wizard-step-number-optional { + background: #94a3b8; + box-shadow: 0 1px 3px rgba(148, 163, 184, 0.35); +} +.wizard-step-headline { flex: 1; min-width: 0; } +.wizard-step-title { + font-size: 0.92rem; font-weight: 800; color: #1e293b; line-height: 1.3; +} +.wizard-step-title-note { + font-weight: 500; color: #64748b; font-size: 0.8rem; +} +.wizard-step-summary { + font-size: 0.76rem; color: #475569; margin-top: 2px; + line-height: 1.35; +} +.wizard-step-summary-chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } +.wizard-step-chip { + display: inline-flex; align-items: center; + padding: 2px 8px; border-radius: 999px; + border: 1px solid transparent; + font-size: 0.72rem; font-weight: 600; + background: #eef2ff; color: #3730a3; border-color: #c7d2fe; +} +.wizard-step-chip-tv { background: #fdf2f8; color: #9d174d; border-color: #fbcfe8; } +.wizard-step-chip-phone { background: #f0fdfa; color: #0f766e; border-color: #99f6e4; } +.wizard-step-chip-upsell { background: #ecfeff; color: #155e75; border-color: #a5f3fc; } + +/* ── Internet tier list (accordion content) ── */ +.tier-list { + display: flex; flex-direction: column; gap: 6px; + animation: tier-fade-in 0.18s ease-out; +} +.tier-row { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; + border: 1.5px solid #e2e8f0; border-radius: 10px; + background: #fff; cursor: pointer; transition: all 0.15s; +} +.tier-row:hover { + border-color: #6366f1; background: #f5f3ff; + box-shadow: 0 1px 4px rgba(99, 102, 241, 0.18); +} +.tier-row-active { + border-color: #10b981; + background: linear-gradient(90deg, #ecfdf5 0%, #ffffff 60%); + box-shadow: 0 1px 4px rgba(16, 185, 129, 0.15); +} +.tier-row-icon { + flex-shrink: 0; + width: 40px; height: 40px; border-radius: 8px; + background: #fff; border: 1px solid #e2e8f0; + display: flex; align-items: center; justify-content: center; +} +.tier-row-active .tier-row-icon { border-color: #86efac; background: #ecfdf5; } +.tier-row-body { flex: 1; min-width: 0; } +.tier-row-head { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; +} +.tier-row-title { font-size: 0.92rem; font-weight: 700; color: #1e293b; } +.tier-row-badge { font-size: 0.6rem; padding: 2px 6px; } +.tier-row-speed { font-size: 0.78rem; color: #475569; font-weight: 600; } +.tier-row-desc { font-size: 0.72rem; color: #64748b; line-height: 1.3; margin-top: 2px; } +.tier-row-price { text-align: right; flex-shrink: 0; line-height: 1.1; } +.tier-row-price-big { font-size: 1.05rem; font-weight: 800; color: #3730a3; } +.tier-row-active .tier-row-price-big { color: #047857; } +.tier-row-price-unit { font-size: 0.68rem; font-weight: 600; color: #64748b; margin-left: 2px; } +.tier-row-price-strike { + font-size: 0.68rem; color: #94a3b8; text-decoration: line-through; margin-top: 1px; +} +.tier-row-select { flex-shrink: 0; } +@media (max-width: 480px) { + .tier-row { padding: 8px 10px; gap: 8px; } + .tier-row-icon { width: 32px; height: 32px; } + .tier-row-title { font-size: 0.86rem; } + .tier-row-desc { display: none; } + .tier-row-price-big { font-size: 0.95rem; } +} + +/* ── Cart pill (Step 2 → Sommaire handoff) ───────────────────────────── + Sticky summary at the top of the Items/Devis scroll area. Combines + item count + running totals + taxes into a single strip; details live + on the Sommaire step (click → navigate). */ +.cart-pill { + display: flex; align-items: center; gap: 12px; + padding: 10px 14px; + border: 1.5px solid #c7d2fe; border-radius: 12px; + background: linear-gradient(90deg, #eef2ff 0%, #ffffff 70%); + cursor: pointer; transition: all 0.15s; + position: relative; +} +.cart-pill:hover { border-color: #6366f1; box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25); } +.cart-pill-empty { + border-color: #e2e8f0; background: #f8fafc; cursor: default; +} +.cart-pill-empty:hover { border-color: #e2e8f0; box-shadow: none; } +/* Sticky variant — stays pinned at the top of the card section so the + running tally is always visible while scrolling through pickers. */ +.cart-pill-sticky { + position: sticky; top: 0; z-index: 5; + /* Slightly stronger shadow to float above pickers below. */ + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); + background: linear-gradient(90deg, #eef2ff 0%, #f5f3ff 100%); + backdrop-filter: saturate(1.1); + margin-bottom: 10px; +} +.cart-pill-sticky.cart-pill-empty { + background: #f8fafc; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); +} +.cart-pill-left { position: relative; flex-shrink: 0; padding: 2px; } +.cart-pill-badge { font-size: 0.65rem; padding: 1px 5px; min-height: 16px; } +.cart-pill-body { flex: 1; min-width: 0; } +.cart-pill-title { font-size: 0.88rem; font-weight: 800; color: #1e293b; line-height: 1.2; } +.cart-pill-empty .cart-pill-title { color: #64748b; font-weight: 700; } +.cart-pill-sub { font-size: 0.74rem; line-height: 1.3; margin-top: 2px; } +.cart-pill-totals { + text-align: right; line-height: 1.15; flex-shrink: 0; + border-left: 1px solid #e0e7ff; padding-left: 10px; +} +.cart-pill-total-line { font-size: 0.88rem; font-weight: 800; color: #3730a3; } +.cart-pill-total-recurring { color: #c2410c; } +.cart-pill-mo { font-size: 0.62rem; font-weight: 600; margin-left: 1px; } +.cart-pill-tax { font-size: 0.66rem; color: #64748b; margin-top: 1px; } + +/* ── Sommaire step (cart detail) ─────────────────────────────────────── + Each row collapses to a compact summary; clicking expands an inline + editor. Drag handle on the left reorders the list. */ +.sommaire-empty { + text-align: center; padding: 30px 16px; + border: 2px dashed #e2e8f0; border-radius: 10px; background: #f8fafc; +} +.sommaire-list { display: flex; flex-direction: column; gap: 6px; } +.sommaire-row { + border: 1.5px solid #e2e8f0; border-radius: 10px; + background: #fff; transition: all 0.15s; + overflow: hidden; +} +.sommaire-row:hover { border-color: #c7d2fe; } +.sommaire-row-expanded { border-color: #6366f1; box-shadow: 0 1px 6px rgba(99, 102, 241, 0.18); } +.sommaire-row-dragging { opacity: 0.4; } +.sommaire-row-drop-target { + border-color: #10b981; border-style: dashed; + background: linear-gradient(90deg, #ecfdf5 0%, #ffffff 60%); +} +.sommaire-head { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; cursor: pointer; +} +.sommaire-handle { + cursor: grab; padding: 2px; touch-action: none; + display: flex; align-items: center; +} +.sommaire-handle:active { cursor: grabbing; } +.sommaire-badge { + flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%; + background: #6366f1; color: #fff; font-size: 0.72rem; font-weight: 800; + display: flex; align-items: center; justify-content: center; +} +.sommaire-type-icon { flex-shrink: 0; } +.sommaire-main { flex: 1; min-width: 0; } +.sommaire-name { + font-size: 0.88rem; font-weight: 700; color: #1e293b; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sommaire-meta { + display: flex; align-items: center; gap: 4px; flex-wrap: wrap; + font-size: 0.74rem; color: #475569; margin-top: 1px; +} +.sommaire-sep { color: #cbd5e1; } +.sommaire-body { + padding: 8px 12px 10px 12px; + border-top: 1px dashed #e2e8f0; + background: #f8fafc; +} + +@media (max-width: 480px) { + .cart-pill { padding: 8px 10px; gap: 8px; } + .cart-pill-title { font-size: 0.82rem; } + .cart-pill-sub { font-size: 0.7rem; } + .cart-pill-total-line { font-size: 0.8rem; } + .cart-pill-tax { font-size: 0.62rem; } + .cart-pill-totals { padding-left: 6px; } + .sommaire-head { padding: 6px 8px; gap: 8px; } + .sommaire-name { font-size: 0.82rem; } + .sommaire-meta { font-size: 0.7rem; } + .sommaire-badge { width: 20px; height: 20px; font-size: 0.66rem; } +} diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue index d30336d..8b520c8 100644 --- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue @@ -171,12 +171,13 @@
-
+
- Diagnostic WiFi avance + Diagnostic modem + {{ wifiDiag.durationMs }}ms
@@ -187,6 +188,63 @@
{{ wifiDiagError }}