From 7d7b4fdb06b0c5d18728b26bbfc10b86eefc70b6 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Wed, 1 Apr 2026 13:01:20 -0400 Subject: [PATCH] feat: nested tasks, project wizard, n8n webhooks, inline task editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major dispatch/task system overhaul: - Project templates with 3-step wizard (choose template → edit steps → publish) - 4 built-in templates: phone service, fiber install, move, repair - Nested task tree with recursive TaskNode component (parent_job hierarchy) - n8n webhook integration (on_open_webhook, on_close_webhook per task) - Inline task editing: status, priority, type, tech assignment, tags, delete - Tech assignment + tags from ticket modal → jobs appear on dispatch timeline - ERPNext custom fields: parent_job, on_open_webhook, on_close_webhook, step_order - Refactored ClientDetailPage, ChatterPanel, DetailModal, dispatch store - CSS consolidation, dead code cleanup, composable extraction - Dashboard KPIs with dispatch integration Co-Authored-By: Claude Opus 4.6 --- apps/ops/deploy.sh | 28 +- apps/ops/package-lock.json | 10 + apps/ops/package.json | 1 + apps/ops/quasar.config.js | 5 + apps/ops/src-pwa/register-service-worker.js | 3 +- apps/ops/src/api/auth.js | 10 +- apps/ops/src/api/erp.js | 11 +- apps/ops/src/api/sms.js | 31 +- .../src/components/customer/ChatterPanel.vue | 536 +++++++++++ .../src/components/customer/ComposeBar.vue | 166 ++++ .../src/components/customer/ContactCard.vue | 97 +- .../components/customer/CustomerHeader.vue | 70 +- .../components/customer/CustomerInfoCard.vue | 34 +- .../ops/src/components/customer/SmsThread.vue | 253 +++++ .../components/customer/chatter-panel.scss | 177 ++++ .../shared/CreateDispatchJobDialog.vue | 226 +++++ .../ops/src/components/shared/DetailModal.vue | 501 +--------- .../src/components/shared/ProjectWizard.vue | 424 +++++++++ apps/ops/src/components/shared/TaskNode.vue | 443 +++++++++ .../detail-sections/EquipmentDetail.vue | 84 ++ .../shared/detail-sections/InvoiceDetail.vue | 62 ++ .../shared/detail-sections/IssueDetail.vue | 282 ++++++ .../shared/detail-sections/PaymentDetail.vue | 29 + .../detail-sections/SubscriptionDetail.vue | 52 + apps/ops/src/composables/useContextMenus.js | 71 ++ apps/ops/src/composables/useCustomerNotes.js | 101 ++ apps/ops/src/composables/useDetailModal.js | 11 +- apps/ops/src/composables/useFormatters.js | 77 ++ apps/ops/src/composables/useGpsTracking.js | 103 ++ .../src/composables/usePeriodNavigation.js | 64 ++ apps/ops/src/composables/useResourceFilter.js | 63 ++ apps/ops/src/composables/useSSE.js | 105 ++ .../src/composables/useSubscriptionActions.js | 181 ++++ apps/ops/src/composables/useTagManagement.js | 56 ++ apps/ops/src/composables/useTechManagement.js | 74 ++ apps/ops/src/config/device-icons.js | 19 + apps/ops/src/config/erpnext.js | 3 + apps/ops/src/config/nav.js | 31 + apps/ops/src/config/project-templates.js | 210 ++++ apps/ops/src/config/table-columns.js | 37 + apps/ops/src/config/ticket-config.js | 55 ++ apps/ops/src/css/app.scss | 191 +++- apps/ops/src/layouts/MainLayout.vue | 637 +++---------- apps/ops/src/pages/ClientDetailPage.vue | 712 +++----------- apps/ops/src/pages/DashboardPage.vue | 91 +- apps/ops/src/pages/DispatchPage.vue | 901 ++---------------- apps/ops/src/pages/SettingsPage.vue | 229 +++++ apps/ops/src/pages/TicketsPage.vue | 124 +-- apps/ops/src/pages/dispatch-styles.scss | 459 +++++++++ apps/ops/src/router/index.js | 4 +- apps/ops/src/stores/dispatch.js | 251 ++--- docs/ARCHITECTURE.md | 2 + erpnext/add_depends_on_field.py | 24 + erpnext/setup_fsm_doctypes.py | 13 + 54 files changed, 5626 insertions(+), 2808 deletions(-) create mode 100644 apps/ops/src/components/customer/ChatterPanel.vue create mode 100644 apps/ops/src/components/customer/ComposeBar.vue create mode 100644 apps/ops/src/components/customer/SmsThread.vue create mode 100644 apps/ops/src/components/customer/chatter-panel.scss create mode 100644 apps/ops/src/components/shared/CreateDispatchJobDialog.vue create mode 100644 apps/ops/src/components/shared/ProjectWizard.vue create mode 100644 apps/ops/src/components/shared/TaskNode.vue create mode 100644 apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue create mode 100644 apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue create mode 100644 apps/ops/src/components/shared/detail-sections/IssueDetail.vue create mode 100644 apps/ops/src/components/shared/detail-sections/PaymentDetail.vue create mode 100644 apps/ops/src/components/shared/detail-sections/SubscriptionDetail.vue create mode 100644 apps/ops/src/composables/useContextMenus.js create mode 100644 apps/ops/src/composables/useCustomerNotes.js create mode 100644 apps/ops/src/composables/useGpsTracking.js create mode 100644 apps/ops/src/composables/usePeriodNavigation.js create mode 100644 apps/ops/src/composables/useResourceFilter.js create mode 100644 apps/ops/src/composables/useSSE.js create mode 100644 apps/ops/src/composables/useSubscriptionActions.js create mode 100644 apps/ops/src/composables/useTagManagement.js create mode 100644 apps/ops/src/composables/useTechManagement.js create mode 100644 apps/ops/src/config/device-icons.js create mode 100644 apps/ops/src/config/nav.js create mode 100644 apps/ops/src/config/project-templates.js create mode 100644 apps/ops/src/config/table-columns.js create mode 100644 apps/ops/src/config/ticket-config.js create mode 100644 apps/ops/src/pages/SettingsPage.vue create mode 100644 apps/ops/src/pages/dispatch-styles.scss create mode 100644 erpnext/add_depends_on_field.py diff --git a/apps/ops/deploy.sh b/apps/ops/deploy.sh index 08e09fc..5c18f60 100755 --- a/apps/ops/deploy.sh +++ b/apps/ops/deploy.sh @@ -9,8 +9,9 @@ # Static files go to /opt/ops-app/ on the host, mounted into the container. # # Usage: -# ./deploy.sh # deploy to remote server (production) +# ./deploy.sh # deploy to remote server (SPA mode, no service worker cache) # ./deploy.sh local # deploy to local Docker (development) +# ./deploy.sh pwa # deploy to remote server (PWA mode, for production) # # Prerequisites (remote): # - SSH key ~/.ssh/proxmox_vm for root@96.125.196.67 @@ -25,33 +26,42 @@ SERVER="root@96.125.196.67" SSH_KEY="$HOME/.ssh/proxmox_vm" DEST="/opt/ops-app" +# Default to SPA mode (no service worker = no cache headaches during dev) +BUILD_MODE="spa" +DIST_DIR="dist/spa" +if [ "$1" = "pwa" ]; then + BUILD_MODE="pwa" + DIST_DIR="dist/pwa" + shift +fi + echo "==> Installing dependencies..." npm ci --silent -echo "==> Building PWA (base=/ops/)..." -DEPLOY_BASE=/ops/ npx quasar build -m pwa +echo "==> Building $BUILD_MODE (base=/ops/)..." +DEPLOY_BASE=/ops/ npx quasar build -m "$BUILD_MODE" if [ "$1" = "local" ]; then # ── Local deploy ── echo "==> Deploying to local $DEST..." rm -rf "$DEST"/* - cp -r dist/pwa/* "$DEST/" + cp -r "$DIST_DIR"/* "$DEST/" echo "" echo "Done! Targo Ops: http://localhost/ops/" else # ── Remote deploy ── echo "==> Packaging..." - tar czf /tmp/ops-pwa.tar.gz -C dist/pwa . + tar czf /tmp/ops-build.tar.gz -C "$DIST_DIR" . echo "==> Deploying to $SERVER..." - cat /tmp/ops-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \ + cat /tmp/ops-build.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \ "cat > /tmp/ops.tar.gz && \ - rm -rf $DEST/*.js $DEST/*.html $DEST/*.json $DEST/assets $DEST/icons && \ + rm -rf $DEST/*.js $DEST/*.html $DEST/*.json $DEST/assets $DEST/icons $DEST/sw.js $DEST/workbox-*.js && \ cd $DEST && tar xzf /tmp/ops.tar.gz && \ rm -f /tmp/ops.tar.gz" - rm -f /tmp/ops-pwa.tar.gz + rm -f /tmp/ops-build.tar.gz echo "" - echo "Done! Targo Ops: https://erp.gigafibre.ca/ops/" + echo "Done! Targo Ops ($BUILD_MODE): https://erp.gigafibre.ca/ops/" fi diff --git a/apps/ops/package-lock.json b/apps/ops/package-lock.json index e7f50ce..25e3d1e 100644 --- a/apps/ops/package-lock.json +++ b/apps/ops/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@quasar/extras": "^1.16.12", + "lucide-vue-next": "^1.0.0", "pinia": "^2.1.7", "quasar": "^2.16.10", "vue": "^3.4.21", @@ -6916,6 +6917,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-vue-next": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/apps/ops/package.json b/apps/ops/package.json index 3231bff..edb9ff1 100644 --- a/apps/ops/package.json +++ b/apps/ops/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@quasar/extras": "^1.16.12", + "lucide-vue-next": "^1.0.0", "pinia": "^2.1.7", "quasar": "^2.16.10", "vue": "^3.4.21", diff --git a/apps/ops/quasar.config.js b/apps/ops/quasar.config.js index 5a32637..44964ee 100644 --- a/apps/ops/quasar.config.js +++ b/apps/ops/quasar.config.js @@ -25,6 +25,11 @@ module.exports = configure(function () { host: '0.0.0.0', port: 9001, proxy: { + '/ops/api': { + target: 'https://erp.gigafibre.ca', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/ops/, ''), + }, '/api': { target: 'https://erp.gigafibre.ca', changeOrigin: true, diff --git a/apps/ops/src-pwa/register-service-worker.js b/apps/ops/src-pwa/register-service-worker.js index 8a061c9..3c32b4d 100644 --- a/apps/ops/src-pwa/register-service-worker.js +++ b/apps/ops/src-pwa/register-service-worker.js @@ -9,11 +9,10 @@ register(process.env.SERVICE_WORKER_FILE, { cached () {}, updatefound () {}, updated (reg) { - // New service worker available — activate it and reload + // New service worker available — activate it silently (no reload) if (reg && reg.waiting) { reg.waiting.postMessage({ type: 'SKIP_WAITING' }) } - window.location.reload() }, offline () {}, error () {} diff --git a/apps/ops/src/api/auth.js b/apps/ops/src/api/auth.js index 92f3781..ac885ad 100644 --- a/apps/ops/src/api/auth.js +++ b/apps/ops/src/api/auth.js @@ -13,14 +13,12 @@ export function authFetch (url, opts = {}) { } else { opts.headers = { ...opts.headers } } - opts.redirect = 'manual' - if (opts.method && opts.method !== 'GET') { - opts.credentials = 'omit' - } return fetch(url, opts).then(res => { - if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) { + console.log('[authFetch]', opts.method || 'GET', url, '→', res.status, res.type) + if (res.status === 401 || res.status === 403) { + console.warn('authFetch: session expired, reloading') window.location.reload() - return new Response('{}', { status: 401 }) + return new Response('{}', { status: res.status }) } return res }) diff --git a/apps/ops/src/api/erp.js b/apps/ops/src/api/erp.js index 6f1758f..2e82a58 100644 --- a/apps/ops/src/api/erp.js +++ b/apps/ops/src/api/erp.js @@ -54,7 +54,8 @@ export async function createDoc (doctype, data) { // Update a document (partial update) export async function updateDoc (doctype, name, data) { - const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), { + const url = BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name) + const res = await authFetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -64,6 +65,14 @@ export async function updateDoc (doctype, name, data) { return json.data } +// Delete a document +export async function deleteDoc (doctype, name) { + const url = BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name) + const res = await authFetch(url, { method: 'DELETE' }) + if (!res.ok) throw new Error('Delete failed: ' + res.status) + return true +} + // Count documents export async function countDocs (doctype, filters = {}, or_filters) { const params = new URLSearchParams({ diff --git a/apps/ops/src/api/sms.js b/apps/ops/src/api/sms.js index 1a69bfb..985507c 100644 --- a/apps/ops/src/api/sms.js +++ b/apps/ops/src/api/sms.js @@ -1,25 +1,32 @@ -import { BASE_URL } from 'src/config/erpnext' -import { authFetch } from './auth' - /** - * Send a test SMS notification via ERPNext server script. - * Falls back to logging if Twilio is not configured. + * Send SMS via n8n webhook → Twilio. + * n8n handles Twilio auth + logs to ERPNext automatically. * * @param {string} phone - Phone number (e.g. +15145551234) * @param {string} message - SMS body - * @param {string} customer - Customer ID (e.g. CUST-4) - * @returns {Promise<{ok: boolean, message: string}>} + * @param {string} customer - Customer ID (e.g. CUST-00001) — logged as Communication in ERPNext + * @param {object} [opts] - Extra options + * @param {string} [opts.reference_doctype] - Link to specific doctype (default: Customer) + * @param {string} [opts.reference_name] - Link to specific record + * @returns {Promise<{ok: boolean, message: string, sid?: string}>} */ -export async function sendTestSms (phone, message, customer) { - const res = await authFetch(BASE_URL + '/api/method/send_sms_notification', { + +const N8N_WEBHOOK_URL = 'https://n8n.gigafibre.ca/webhook/sms-send' + +export async function sendTestSms (phone, message, customer, opts = {}) { + const payload = { phone, message, customer } + if (opts.reference_doctype) payload.reference_doctype = opts.reference_doctype + if (opts.reference_name) payload.reference_name = opts.reference_name + const res = await fetch(N8N_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phone, message, customer }), + body: JSON.stringify(payload), }) if (!res.ok) { const err = await res.text().catch(() => 'Unknown error') - throw new Error('SMS failed: ' + err) + throw new Error('SMS failed (' + res.status + '): ' + err) } const data = await res.json() - return data.message || { ok: true, message: 'Sent' } + if (data.ok === false) throw new Error(data.message || 'SMS send error') + return data } diff --git a/apps/ops/src/components/customer/ChatterPanel.vue b/apps/ops/src/components/customer/ChatterPanel.vue new file mode 100644 index 0000000..f06b4ba --- /dev/null +++ b/apps/ops/src/components/customer/ChatterPanel.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/apps/ops/src/components/customer/ComposeBar.vue b/apps/ops/src/components/customer/ComposeBar.vue new file mode 100644 index 0000000..9d17e86 --- /dev/null +++ b/apps/ops/src/components/customer/ComposeBar.vue @@ -0,0 +1,166 @@ + + + diff --git a/apps/ops/src/components/customer/ContactCard.vue b/apps/ops/src/components/customer/ContactCard.vue index 2d35a4f..8b70038 100644 --- a/apps/ops/src/components/customer/ContactCard.vue +++ b/apps/ops/src/components/customer/ContactCard.vue @@ -1,39 +1,16 @@ + + diff --git a/apps/ops/src/components/customer/CustomerInfoCard.vue b/apps/ops/src/components/customer/CustomerInfoCard.vue index 0cd0d39..76646f6 100644 --- a/apps/ops/src/components/customer/CustomerInfoCard.vue +++ b/apps/ops/src/components/customer/CustomerInfoCard.vue @@ -1,5 +1,5 @@ diff --git a/apps/ops/src/components/customer/SmsThread.vue b/apps/ops/src/components/customer/SmsThread.vue new file mode 100644 index 0000000..b2539e3 --- /dev/null +++ b/apps/ops/src/components/customer/SmsThread.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/apps/ops/src/components/customer/chatter-panel.scss b/apps/ops/src/components/customer/chatter-panel.scss new file mode 100644 index 0000000..3d1e7a4 --- /dev/null +++ b/apps/ops/src/components/customer/chatter-panel.scss @@ -0,0 +1,177 @@ +.chatter-panel { + background: white; + border: 1px solid #e2e8f0; + border-radius: 10px; + display: flex; + flex-direction: column; + height: calc(100vh - 220px); + min-height: 500px; + position: sticky; + top: 16px; +} + +.chatter-header { + display: flex; + align-items: center; + padding: 12px 14px 6px; +} + +.chatter-tabs { + padding: 0 10px 8px; +} + +.chatter-timeline { + flex: 1; + overflow-y: auto; + padding: 0 10px; + scroll-behavior: smooth; +} + +.chatter-date-sep { + text-align: center; + margin: 12px 0 6px; +} +.chatter-date-sep span { + background: #f1f5f9; + color: #94a3b8; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 10px; + border-radius: 10px; +} + +.chatter-entry { + display: flex; + gap: 8px; + padding: 6px 4px; + border-radius: 6px; + transition: background 0.1s; +} +.chatter-entry:hover { background: #f8fafc; } +.chatter-entry.entry-unread { background: #eff6ff; } + +.entry-icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; +} +.icon-sms { background: #e8eaf6; color: #3f51b5; } +.icon-phone { background: #e8f5e9; color: #2e7d32; } +.icon-email { background: #fff3e0; color: #e65100; } +.icon-note { background: #fff8e1; color: #f9a825; } +.icon-other { background: #f5f5f5; color: #757575; } + +.entry-body { flex: 1; min-width: 0; } + +.entry-header { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 1px; +} +.entry-author { font-size: 0.8rem; color: #334155; } +.entry-time { font-size: 0.7rem; } + +.entry-actions { + display: flex; + gap: 0; + margin-right: 4px; +} + +.entry-content { + font-size: 0.84rem; + line-height: 1.4; + color: #475569; + word-break: break-word; +} +.entry-note { + font-style: italic; + color: #92400e; + background: #fffbeb; + padding: 4px 8px; + border-radius: 6px; + border-left: 3px solid #f59e0b; +} + +.entry-edit { + margin-top: 2px; +} + +.entry-meta { margin-top: 2px; } +.entry-link { margin-top: 3px; } + +.chatter-compose { + border-top: 1px solid #e2e8f0; + padding: 8px 10px; + background: #fafbfc; + border-radius: 0 0 10px 10px; + position: relative; +} +.compose-channel-row { + display: flex; + align-items: center; + margin-bottom: 6px; +} + +// Canned responses dropdown +.canned-dropdown { + position: absolute; + bottom: 100%; + left: 8px; + right: 8px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 -4px 16px rgba(0,0,0,0.1); + max-height: 200px; + overflow-y: auto; + z-index: 10; +} +.canned-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.1s; + &:hover, &.canned-highlighted { background: #f1f5f9; } + &:not(:last-child) { border-bottom: 1px solid #f1f5f9; } +} +.canned-shortcut-label { + font-size: 0.75rem; + font-weight: 700; + color: #6366f1; + background: #eef2ff; + padding: 1px 6px; + border-radius: 4px; + white-space: nowrap; +} +.canned-preview { + font-size: 0.8rem; + color: #64748b; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Canned manager modal +.canned-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + border-bottom: 1px solid #f1f5f9; +} +.canned-shortcut { flex-shrink: 0; } +.canned-text { flex: 1; min-width: 0; } + +.chatter-timeline::-webkit-scrollbar { width: 4px; } +.chatter-timeline::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } +.chatter-timeline::-webkit-scrollbar-track { background: transparent; } diff --git a/apps/ops/src/components/shared/CreateDispatchJobDialog.vue b/apps/ops/src/components/shared/CreateDispatchJobDialog.vue new file mode 100644 index 0000000..2ead8dd --- /dev/null +++ b/apps/ops/src/components/shared/CreateDispatchJobDialog.vue @@ -0,0 +1,226 @@ + + + diff --git a/apps/ops/src/components/shared/DetailModal.vue b/apps/ops/src/components/shared/DetailModal.vue index dfda685..da9e950 100644 --- a/apps/ops/src/components/shared/DetailModal.vue +++ b/apps/ops/src/components/shared/DetailModal.vue @@ -13,7 +13,6 @@ - @@ -30,294 +29,20 @@ + - - - - - - - - - - - - - - - - + diff --git a/apps/ops/src/components/shared/ProjectWizard.vue b/apps/ops/src/components/shared/ProjectWizard.vue new file mode 100644 index 0000000..f465ad9 --- /dev/null +++ b/apps/ops/src/components/shared/ProjectWizard.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/apps/ops/src/components/shared/TaskNode.vue b/apps/ops/src/components/shared/TaskNode.vue new file mode 100644 index 0000000..d039a6e --- /dev/null +++ b/apps/ops/src/components/shared/TaskNode.vue @@ -0,0 +1,443 @@ + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue new file mode 100644 index 0000000..8184283 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue @@ -0,0 +1,84 @@ + + + diff --git a/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue b/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue new file mode 100644 index 0000000..ca55ff7 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue new file mode 100644 index 0000000..9a6f763 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/PaymentDetail.vue b/apps/ops/src/components/shared/detail-sections/PaymentDetail.vue new file mode 100644 index 0000000..dce0582 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/PaymentDetail.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/ops/src/components/shared/detail-sections/SubscriptionDetail.vue b/apps/ops/src/components/shared/detail-sections/SubscriptionDetail.vue new file mode 100644 index 0000000..c846272 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/SubscriptionDetail.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/ops/src/composables/useContextMenus.js b/apps/ops/src/composables/useContextMenus.js new file mode 100644 index 0000000..96cfd03 --- /dev/null +++ b/apps/ops/src/composables/useContextMenus.js @@ -0,0 +1,71 @@ +import { ref } from 'vue' +import { updateJob } from 'src/api/dispatch' +import { serializeAssistants } from 'src/composables/useHelpers' + +export function useContextMenus ({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal }) { + const ctxMenu = ref(null) + const techCtx = ref(null) + const assistCtx = ref(null) + const assistNoteModal = ref(null) + + function openCtxMenu (e, job, techId) { + e.preventDefault(); e.stopPropagation() + ctxMenu.value = { x: Math.min(e.clientX, window.innerWidth-180), y: Math.min(e.clientY, window.innerHeight-200), job, techId } + } + function closeCtxMenu () { ctxMenu.value = null } + function openTechCtx (e, tech) { + e.preventDefault(); e.stopPropagation() + techCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-200), tech } + } + function openAssistCtx (e, job, techId) { + e.preventDefault(); e.stopPropagation() + assistCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-150), job, techId } + } + function assistCtxTogglePin () { + if (!assistCtx.value) return + const { job, techId } = assistCtx.value + const assist = job.assistants.find(a => a.techId === techId) + if (assist) { + assist.pinned = !assist.pinned + updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) + invalidateRoutes() + } + assistCtx.value = null + } + function assistCtxRemove () { + if (!assistCtx.value) return + store.removeAssistant(assistCtx.value.job.id, assistCtx.value.techId) + invalidateRoutes(); assistCtx.value = null + } + function assistCtxNote () { + if (!assistCtx.value) return + const { job, techId } = assistCtx.value + const assist = job.assistants.find(a => a.techId === techId) + assistNoteModal.value = { job, techId, note: assist?.note || '' } + assistCtx.value = null + } + function confirmAssistNote () { + if (!assistNoteModal.value) return + const { job, techId, note } = assistNoteModal.value + const assist = job.assistants.find(a => a.techId === techId) + if (assist) { + assist.note = note + updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) + } + assistNoteModal.value = null + } + + function ctxDetails () { + const { job, techId } = ctxMenu.value + rightPanel.value = { mode: 'details', data: { job, tech: store.technicians.find(t => t.id === techId) } }; closeCtxMenu() + } + function ctxMove () { const { job, techId } = ctxMenu.value; openMoveModal(job, techId); closeCtxMenu() } + function ctxUnschedule () { fullUnassign(ctxMenu.value.job); closeCtxMenu() } + + return { + ctxMenu, techCtx, assistCtx, assistNoteModal, + openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx, + assistCtxTogglePin, assistCtxRemove, assistCtxNote, confirmAssistNote, + ctxDetails, ctxMove, ctxUnschedule, + } +} diff --git a/apps/ops/src/composables/useCustomerNotes.js b/apps/ops/src/composables/useCustomerNotes.js new file mode 100644 index 0000000..9ff0279 --- /dev/null +++ b/apps/ops/src/composables/useCustomerNotes.js @@ -0,0 +1,101 @@ +/** + * Composable for customer notes/comments management. + */ +import { ref, computed } from 'vue' +import { listDocs, updateDoc } from 'src/api/erp' +import { authFetch } from 'src/api/auth' +import { BASE_URL } from 'src/config/erpnext' + +/** + * @param {import('vue').Ref} comments - Reactive comments list + * @param {import('vue').Ref} customer - Reactive customer object + */ +export function useCustomerNotes (comments, customer) { + const newNote = ref('') + const addingNote = ref(false) + const noteInputFocused = ref(false) + const editingNote = ref(null) + + const sortedComments = computed(() => { + const pinned = comments.value.filter(c => c._sticky) + const rest = comments.value.filter(c => !c._sticky) + return [...pinned, ...rest] + }) + + async function addNote () { + if (!newNote.value?.trim() || addingNote.value) return + addingNote.value = true + try { + const body = { + doctype: 'Comment', comment_type: 'Comment', + reference_doctype: 'Customer', reference_name: customer.value.name, + content: newNote.value.trim(), + } + const res = await authFetch(BASE_URL + '/api/resource/Comment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (res.ok) { + const data = await res.json() + comments.value.unshift(data.data) + newNote.value = '' + noteInputFocused.value = false + } + } catch {} finally { + addingNote.value = false + } + } + + async function deleteNote (note) { + try { + const res = await authFetch(BASE_URL + '/api/resource/Comment/' + encodeURIComponent(note.name), { method: 'DELETE' }) + if (res.ok) { + comments.value = comments.value.filter(c => c.name !== note.name) + } + } catch {} + } + + function toggleStickyNote (note) { + note._sticky = !note._sticky + } + + async function saveEditNote (note, newContent) { + if (!newContent?.trim()) return + try { + await updateDoc('Comment', note.name, { content: newContent.trim() }) + note.content = newContent.trim() + editingNote.value = null + } catch {} + } + + function startEditNote (note) { + editingNote.value = note.name + note._editContent = note.content?.replace(/<[^>]*>/g, '') || '' + } + + async function onNoteAdded () { + try { + comments.value = await listDocs('Comment', { + filters: { reference_doctype: 'Customer', reference_name: customer.value?.name, comment_type: 'Comment' }, + fields: ['name', 'content', 'comment_by', 'creation'], + limit: 50, + orderBy: 'creation desc', + }) + } catch {} + } + + return { + newNote, + addingNote, + noteInputFocused, + editingNote, + sortedComments, + addNote, + deleteNote, + toggleStickyNote, + saveEditNote, + startEditNote, + onNoteAdded, + } +} diff --git a/apps/ops/src/composables/useDetailModal.js b/apps/ops/src/composables/useDetailModal.js index d931b6e..4724ab6 100644 --- a/apps/ops/src/composables/useDetailModal.js +++ b/apps/ops/src/composables/useDetailModal.js @@ -16,6 +16,7 @@ export function useDetailModal () { const modalComments = ref([]) const modalComms = ref([]) const modalFiles = ref([]) + const modalDispatchJobs = ref([]) const modalErpLink = computed(() => erpLink(modalDoctype.value, modalDocName.value)) @@ -42,6 +43,7 @@ export function useDetailModal () { modalComments.value = [] modalComms.value = [] modalFiles.value = [] + modalDispatchJobs.value = [] modalOpen.value = true modalLoading.value = true @@ -57,7 +59,7 @@ export function useDetailModal () { })) } - // Fetch communications, files, and comments for Issues + // Fetch communications, files, comments, and linked dispatch jobs for Issues if (doctype === 'Issue') { promises.push( listDocs('Communication', { @@ -75,6 +77,11 @@ export function useDetailModal () { fields: ['name', 'content', 'comment_by', 'creation'], limit: 200, orderBy: 'creation asc', }).catch(() => []), + listDocs('Dispatch Job', { + filters: { source_issue: name }, + fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'job_type', 'priority', 'depends_on', 'duration_h', 'parent_job', 'step_order', 'on_open_webhook', 'on_close_webhook'], + limit: 50, orderBy: 'step_order asc, creation asc', + }).catch(() => []), ) } @@ -87,6 +94,7 @@ export function useDetailModal () { modalComms.value = results[1] || [] modalFiles.value = results[2] || [] modalComments.value = results[3] || [] + modalDispatchJobs.value = results[4] || [] } // Auto-derive title from doc if not provided @@ -116,6 +124,7 @@ export function useDetailModal () { modalFiles, modalErpLink, modalDocFields, + modalDispatchJobs, openModal, closeModal, } diff --git a/apps/ops/src/composables/useFormatters.js b/apps/ops/src/composables/useFormatters.js index ee28e4c..b04db6f 100644 --- a/apps/ops/src/composables/useFormatters.js +++ b/apps/ops/src/composables/useFormatters.js @@ -58,3 +58,80 @@ export function erpFileUrl (url) { if (url.startsWith('http')) return url return ERP_BASE + url } + +export function formatDateTime (dt) { + if (!dt) return '' + const d = new Date(dt) + if (isNaN(d)) return dt + return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' + + d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) +} + +// ═══ Staff avatar helpers ═══ + +const AVATAR_COLORS = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#be185d','#0d9488','#6366f1','#ea580c'] + +/** + * Generate a consistent color from a name string (for avatar backgrounds). + * @param {string|null} name + * @returns {string} Hex color + */ +export function staffColor (name) { + if (!name) return '#9e9e9e' + let h = 0 + for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h) + return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length] +} + +/** + * Extract initials from a full name string. + * @param {string|null} name + * @returns {string} + */ +export function staffInitials (name) { + if (!name) return '' + const parts = name.trim().split(/\s+/) + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + return parts[0].substring(0, 2).toUpperCase() +} + +/** + * Decode HTML entities (' -> ', & -> &, etc.) + * @param {string|null} str + * @returns {string} + */ +export function decodeHtml (str) { + if (!str) return str + const el = document.createElement('textarea') + el.innerHTML = str + return el.value +} + +/** + * Extract display name from email (user@domain -> User) + * @param {string|null} email + * @returns {string} + */ +export function noteAuthorName (email) { + if (!email) return 'Système' + const local = email.split('@')[0] + return local.charAt(0).toUpperCase() + local.slice(1).replace(/[._-]/g, ' ') +} + +/** + * Relative time display for notes/comments. + * @param {string|null} d - Date/datetime string + * @returns {string} + */ +export function noteTimeAgo (d) { + if (!d) return '' + const diff = Date.now() - new Date(d).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return "À l'instant" + if (mins < 60) return `Il y a ${mins} min` + const hours = Math.floor(mins / 60) + if (hours < 24) return `Il y a ${hours}h` + const days = Math.floor(hours / 24) + if (days < 7) return `Il y a ${days}j` + return formatDate(d) +} diff --git a/apps/ops/src/composables/useGpsTracking.js b/apps/ops/src/composables/useGpsTracking.js new file mode 100644 index 0000000..51e398a --- /dev/null +++ b/apps/ops/src/composables/useGpsTracking.js @@ -0,0 +1,103 @@ +import { ref } from 'vue' +import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar' + +let __gpsStarted = false +let __gpsInterval = null +let __gpsPolling = false + +export function useGpsTracking (technicians) { + const traccarDevices = ref([]) + const _techsByDevice = {} + + function _buildTechDeviceMap () { + Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k]) + technicians.value.forEach(t => { + if (!t.traccarDeviceId) return + const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) + if (dev) _techsByDevice[dev.id] = t + }) + } + + function _applyPositions (positions) { + positions.forEach(p => { + const tech = _techsByDevice[p.deviceId] + if (!tech || !p.latitude || !p.longitude) return + const cur = tech.gpsCoords + if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) { + tech.gpsCoords = [p.longitude, p.latitude] + } + tech.gpsSpeed = p.speed || 0 + tech.gpsTime = p.fixTime + tech.gpsOnline = true + }) + } + + async function pollGps () { + if (__gpsPolling) return + __gpsPolling = true + try { + if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices() + _buildTechDeviceMap() + const deviceIds = Object.keys(_techsByDevice).map(Number) + if (!deviceIds.length) return + const positions = await fetchPositions(deviceIds) + _applyPositions(positions) + Object.values(_techsByDevice).forEach(t => { + if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false + }) + } catch { /* poll error */ } + finally { __gpsPolling = false } + } + + let __ws = null + let __wsBackoff = 1000 + + function _connectWs () { + if (__ws) return + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const url = proto + '//' + window.location.host + '/traccar/api/socket' + try { __ws = new WebSocket(url) } catch { return } + __ws.onopen = () => { + __wsBackoff = 1000 + if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null } + } + __ws.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.positions?.length) { + _buildTechDeviceMap() + _applyPositions(data.positions) + } + } catch { /* parse error */ } + } + __ws.onerror = () => {} + __ws.onclose = () => { + __ws = null + if (!__gpsStarted) return + if (!__gpsInterval) { + __gpsInterval = setInterval(pollGps, 30000) + } + setTimeout(_connectWs, __wsBackoff) + __wsBackoff = Math.min(__wsBackoff * 2, 60000) + } + } + + async function startGpsTracking () { + if (__gpsStarted) return + __gpsStarted = true + await pollGps() + const sessionOk = await createTraccarSession() + if (sessionOk) { + _connectWs() + } else { + __gpsInterval = setInterval(pollGps, 30000) + } + } + + function stopGpsTracking () { + if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null } + if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() } + } + + return { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } +} diff --git a/apps/ops/src/composables/usePeriodNavigation.js b/apps/ops/src/composables/usePeriodNavigation.js new file mode 100644 index 0000000..8cbe8aa --- /dev/null +++ b/apps/ops/src/composables/usePeriodNavigation.js @@ -0,0 +1,64 @@ +import { ref, computed, watch } from 'vue' +import { localDateStr, startOfWeek, startOfMonth } from 'src/composables/useHelpers' + +export function usePeriodNavigation () { + const currentView = ref(localStorage.getItem('sbv2-view') || 'week') + const savedDate = localStorage.getItem('sbv2-date') + const anchorDate = ref(savedDate ? new Date(savedDate + 'T00:00:00') : new Date()) + + watch(currentView, v => localStorage.setItem('sbv2-view', v)) + watch(anchorDate, d => localStorage.setItem('sbv2-date', localDateStr(d))) + + const periodStart = computed(() => { + const d = new Date(anchorDate.value); d.setHours(0,0,0,0) + if (currentView.value === 'day') return d + if (currentView.value === 'week') return startOfWeek(d) + return startOfMonth(d) + }) + const periodDays = computed(() => { + if (currentView.value === 'day') return 1 + if (currentView.value === 'week') return 7 + const s = periodStart.value + return new Date(s.getFullYear(), s.getMonth()+1, 0).getDate() + }) + const dayColumns = computed(() => { + const cols = [] + for (let i = 0; i < periodDays.value; i++) { + const d = new Date(periodStart.value); d.setDate(d.getDate() + i); cols.push(d) + } + return cols + }) + const periodLabel = computed(() => { + const s = periodStart.value + if (currentView.value === 'day') + return s.toLocaleDateString('fr-CA', { weekday:'long', day:'numeric', month:'long', year:'numeric' }) + if (currentView.value === 'week') { + const e = new Date(s); e.setDate(e.getDate() + 6) + return `${s.toLocaleDateString('fr-CA',{day:'numeric',month:'short'})} – ${e.toLocaleDateString('fr-CA',{day:'numeric',month:'short',year:'numeric'})}` + } + return s.toLocaleDateString('fr-CA', { month:'long', year:'numeric' }) + }) + const todayStr = localDateStr(new Date()) + + function prevPeriod () { + const d = new Date(anchorDate.value) + if (currentView.value === 'day') d.setDate(d.getDate()-1) + if (currentView.value === 'week') d.setDate(d.getDate()-7) + if (currentView.value === 'month') d.setMonth(d.getMonth()-1) + anchorDate.value = d + } + function nextPeriod () { + const d = new Date(anchorDate.value) + if (currentView.value === 'day') d.setDate(d.getDate()+1) + if (currentView.value === 'week') d.setDate(d.getDate()+7) + if (currentView.value === 'month') d.setMonth(d.getMonth()+1) + anchorDate.value = d + } + function goToToday () { anchorDate.value = new Date(); currentView.value = 'day' } + function goToDay (d) { anchorDate.value = new Date(d); currentView.value = 'day' } + + return { + currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr, + prevPeriod, nextPeriod, goToToday, goToDay, + } +} diff --git a/apps/ops/src/composables/useResourceFilter.js b/apps/ops/src/composables/useResourceFilter.js new file mode 100644 index 0000000..b726f37 --- /dev/null +++ b/apps/ops/src/composables/useResourceFilter.js @@ -0,0 +1,63 @@ +import { ref, computed, watch } from 'vue' + +export function useResourceFilter (store) { + const selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]')) + const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '') + const filterTags = ref(JSON.parse(localStorage.getItem('sbv2-filterTags') || '[]')) + const searchQuery = ref('') + const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default') + const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]')) + const resSelectorOpen = ref(false) + const tempSelectedIds = ref([]) + const dragReorderTech = ref(null) + + watch(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true }) + watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v)) + watch(techSort, v => localStorage.setItem('sbv2-techSort', v)) + + const filteredResources = computed(() => { + let list = store.technicians + if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(t => t.fullName.toLowerCase().includes(q)) } + if (filterStatus.value) list = list.filter(t => (t.status||'available') === filterStatus.value) + if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id)) + if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft))) + if (techSort.value === 'alpha') { + list = [...list].sort((a, b) => a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase())) + } else if (techSort.value === 'manual' && manualOrder.value.length) { + const order = manualOrder.value + list = [...list].sort((a, b) => (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id))) + } + return list + }) + + function onTechReorderStart (e, tech) { + dragReorderTech.value = tech + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', 'reorder') + } + function onTechReorderDrop (e, targetTech) { + e.preventDefault() + if (!dragReorderTech.value || dragReorderTech.value.id === targetTech.id) { dragReorderTech.value = null; return } + techSort.value = 'manual' + const ids = filteredResources.value.map(t => t.id) + const fromIdx = ids.indexOf(dragReorderTech.value.id), toIdx = ids.indexOf(targetTech.id) + ids.splice(fromIdx, 1); ids.splice(toIdx, 0, dragReorderTech.value.id) + manualOrder.value = ids; localStorage.setItem('sbv2-techOrder', JSON.stringify(ids)) + dragReorderTech.value = null + } + + function openResSelector () { tempSelectedIds.value = [...selectedResIds.value]; resSelectorOpen.value = true } + function applyResSelector () { selectedResIds.value = [...tempSelectedIds.value]; resSelectorOpen.value = false } + function toggleTempRes (id) { + const idx = tempSelectedIds.value.indexOf(id) + if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id) + } + function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; searchQuery.value = ''; filterTags.value = []; localStorage.removeItem('sbv2-filterTags') } + + return { + selectedResIds, filterStatus, filterTags, searchQuery, techSort, manualOrder, + filteredResources, resSelectorOpen, tempSelectedIds, dragReorderTech, + openResSelector, applyResSelector, toggleTempRes, clearFilters, + onTechReorderStart, onTechReorderDrop, + } +} diff --git a/apps/ops/src/composables/useSSE.js b/apps/ops/src/composables/useSSE.js new file mode 100644 index 0000000..7988973 --- /dev/null +++ b/apps/ops/src/composables/useSSE.js @@ -0,0 +1,105 @@ +import { ref, onUnmounted } from 'vue' + +const HUB_URL = 'https://msg.gigafibre.ca' + +/** + * SSE composable for real-time Communication events from targo-hub. + * + * @param {Object} opts + * @param {Function} opts.onMessage - callback(data) for 'message' events + * @param {Function} opts.onSmsIncoming - callback(data) for 'sms-incoming' events + * @param {Function} opts.onSmsStatus - callback(data) for 'sms-status' events + * @param {Function} opts.onError - callback(event) on connection error + */ +export function useSSE (opts = {}) { + const connected = ref(false) + const clientCount = ref(0) + let es = null + let reconnectTimer = null + let reconnectDelay = 1000 // start at 1s, exponential backoff + + function connect (topics) { + disconnect() + if (!topics || !topics.length) return + + const topicsStr = topics.join(',') + const url = `${HUB_URL}/sse?topics=${encodeURIComponent(topicsStr)}` + + es = new EventSource(url) + + es.onopen = () => { + connected.value = true + reconnectDelay = 1000 // reset backoff on successful connect + } + + es.addEventListener('message', (e) => { + try { + const data = JSON.parse(e.data) + if (opts.onMessage) opts.onMessage(data) + } catch {} + }) + + es.addEventListener('sms-incoming', (e) => { + try { + const data = JSON.parse(e.data) + if (opts.onSmsIncoming) opts.onSmsIncoming(data) + } catch {} + }) + + es.addEventListener('sms-status', (e) => { + try { + const data = JSON.parse(e.data) + if (opts.onSmsStatus) opts.onSmsStatus(data) + } catch {} + }) + + es.onerror = (e) => { + connected.value = false + if (opts.onError) opts.onError(e) + // EventSource auto-reconnects, but if it closes permanently, do manual reconnect + if (es.readyState === EventSource.CLOSED) { + scheduleReconnect(topics) + } + } + } + + function scheduleReconnect (topics) { + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 2, 30000) // max 30s + connect(topics) + }, reconnectDelay) + } + + function disconnect () { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (es) { + es.close() + es = null + } + connected.value = false + } + + onUnmounted(disconnect) + + return { connect, disconnect, connected } +} + +/** + * Send SMS via the hub instead of n8n. + */ +export async function sendSmsViaHub (phone, message, customer) { + const res = await fetch(`${HUB_URL}/send/sms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, message, customer }), + }) + if (!res.ok) { + const err = await res.text().catch(() => 'Unknown error') + throw new Error('SMS failed (' + res.status + '): ' + err) + } + return res.json() +} diff --git a/apps/ops/src/composables/useSubscriptionActions.js b/apps/ops/src/composables/useSubscriptionActions.js new file mode 100644 index 0000000..a4fc788 --- /dev/null +++ b/apps/ops/src/composables/useSubscriptionActions.js @@ -0,0 +1,181 @@ +/** + * Composable for subscription management actions. + * Handles status toggling, frequency changes, recurring settings, drag reorder, and field saves. + */ +import { ref } from 'vue' +import { Notify } from 'quasar' +import { authFetch } from 'src/api/auth' +import { BASE_URL } from 'src/config/erpnext' +import { formatMoney } from 'src/composables/useFormatters' + +// Frappe REST update for Subscription doctype +async function updateSub (name, fields) { + const res = await authFetch(BASE_URL + '/api/resource/Subscription/' + encodeURIComponent(name), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }) + if (!res.ok) { + const err = await res.text() + throw new Error('Update failed: ' + err) + } + return res.json() +} + +/** + * @param {import('vue').Ref} subscriptions - Reactive subscriptions list + * @param {import('vue').Ref} customer - Reactive customer object + * @param {import('vue').Ref} comments - Reactive comments list + * @param {Function} invalidateCache - Cache invalidation from useSubscriptionGroups + */ +export function useSubscriptionActions (subscriptions, customer, comments, invalidateCache) { + const subSaving = ref(null) + const togglingRecurring = ref(null) + + // Log a subscription change as a Comment on the Customer for audit trail + async function logSubChange (sub, message) { + try { + const label = sub.custom_description || sub.item_name || sub.item_code || sub.name + const body = { + doctype: 'Comment', comment_type: 'Comment', + reference_doctype: 'Customer', reference_name: customer.value.name, + content: `[${sub.name}] ${label} — ${message}`, + } + const res = await authFetch(BASE_URL + '/api/resource/Comment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (res.ok) { + const data = await res.json() + comments.value.unshift(data.data) + } + } catch {} + } + + async function toggleSubStatus (sub) { + const action = sub.name + ':status' + if (subSaving.value) return + subSaving.value = action + const newStatus = sub.status === 'Cancelled' ? 'Active' : 'Cancelled' + const today = new Date().toISOString().slice(0, 10) + try { + const updates = { status: newStatus } + if (newStatus === 'Cancelled') { + updates.cancelation_date = today + if (!sub.end_date) updates.end_date = sub.current_invoice_end || today + } else { + updates.cancelation_date = null + } + await updateSub(sub.name, updates) + sub.status = newStatus + if (updates.end_date) sub.end_date = updates.end_date + const msg = newStatus === 'Cancelled' + ? `Service désactivé le ${today}` + : `Service réactivé le ${today}` + logSubChange(sub, msg) + if (sub.service_location) invalidateCache(sub.service_location) + Notify.create({ type: 'positive', message: msg, timeout: 2500 }) + } catch (e) { + Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier le statut'), timeout: 4000 }) + } finally { + subSaving.value = null + } + } + + async function toggleFrequency (sub) { + const action = sub.name + ':freq' + if (subSaving.value) return + subSaving.value = action + const oldFreq = sub.billing_frequency + const newFreq = oldFreq === 'A' ? 'M' : 'A' + try { + await updateSub(sub.name, { billing_frequency: newFreq }) + const msg = newFreq === 'A' + ? `Fréquence changée: Mensuel → Annuel` + : `Fréquence changée: Annuel → Mensuel` + logSubChange(sub, msg) + if (sub.service_location) invalidateCache(sub.service_location) + sub.billing_frequency = newFreq + } catch {} finally { + subSaving.value = null + } + } + + async function toggleRecurring (sub) { + if (togglingRecurring.value) return + togglingRecurring.value = sub.name + const newVal = Number(sub.cancel_at_period_end) ? 0 : 1 + try { + const updates = { cancel_at_period_end: newVal } + if (newVal && !sub.end_date) updates.end_date = sub.current_invoice_end || new Date().toISOString().slice(0, 10) + await updateSub(sub.name, updates) + sub.cancel_at_period_end = newVal + if (updates.end_date) sub.end_date = updates.end_date + const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée' + logSubChange(sub, msg) + Notify.create({ type: 'positive', message: msg, timeout: 2500 }) + } catch { + Notify.create({ type: 'negative', message: 'Erreur: impossible de modifier le récurrent', timeout: 3000 }) + } finally { + togglingRecurring.value = null + } + } + + async function toggleRecurringModal (doc) { + const newVal = Number(doc.cancel_at_period_end) ? 0 : 1 + try { + const updates = { cancel_at_period_end: newVal } + if (newVal && !doc.end_date) updates.end_date = doc.current_invoice_end || new Date().toISOString().slice(0, 10) + await updateSub(doc.name, updates) + doc.cancel_at_period_end = newVal + if (updates.end_date) doc.end_date = updates.end_date + const listSub = subscriptions.value.find(s => s.name === doc.name) + if (listSub) { + listSub.cancel_at_period_end = newVal + if (updates.end_date) listSub.end_date = updates.end_date + } + const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée' + logSubChange(doc, msg) + Notify.create({ type: 'positive', message: msg, timeout: 2500 }) + } catch (e) { + Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier'), timeout: 3000 }) + } + } + + async function saveSubField (doc, field) { + try { + const oldVal = subscriptions.value.find(s => s.name === doc.name)?.[field] + await updateSub(doc.name, { [field]: doc[field] ?? '' }) + const listSub = subscriptions.value.find(s => s.name === doc.name) + if (listSub) { + listSub[field] = doc[field] + if (listSub.service_location) invalidateCache(listSub.service_location) + } + const fieldLabels = { actual_price: 'Prix', custom_description: 'Description' } + if (fieldLabels[field]) { + const newVal = doc[field] ?? '' + const msg = field === 'actual_price' + ? `${fieldLabels[field]} modifié: ${formatMoney(oldVal)} → ${formatMoney(newVal)}` + : `${fieldLabels[field]} modifié: "${oldVal || '—'}" → "${newVal || '—'}"` + logSubChange(doc, msg) + } + } catch {} + } + + function onSubDragChange (evt, locName) { + if (evt.added || evt.removed) invalidateCache(locName) + } + + return { + subSaving, + togglingRecurring, + toggleSubStatus, + toggleFrequency, + toggleRecurring, + toggleRecurringModal, + saveSubField, + logSubChange, + onSubDragChange, + } +} diff --git a/apps/ops/src/composables/useTagManagement.js b/apps/ops/src/composables/useTagManagement.js new file mode 100644 index 0000000..d573770 --- /dev/null +++ b/apps/ops/src/composables/useTagManagement.js @@ -0,0 +1,56 @@ +import { createTag as apiCreateTag, updateTag as apiUpdateTag, renameTag as apiRenameTag, deleteTag as apiDeleteTag, updateJob, updateTech } from 'src/api/dispatch' + +export function useTagManagement (store) { + function getTagColor (tagLabel) { + const t = store.allTags.find(x => x.label === tagLabel) + return t?.color || '#6b7280' + } + async function onCreateTag ({ label, color }) { + color = color || '#6b7280' + try { + const created = await apiCreateTag(label, 'Custom', color) + if (created) store.allTags.push({ name: created.name, label: created.label, color: created.color || color, category: created.category || 'Custom' }) + } catch (e) { + if (!store.allTags.some(t => t.label === label)) + store.allTags.push({ name: label, label, color, category: 'Custom' }) + } + } + async function onUpdateTag ({ name, color }) { + try { + await apiUpdateTag(name, { color }) + const t = store.allTags.find(x => x.name === name || x.label === name) + if (t) t.color = color + } catch (_e) { /* update failed */ } + } + async function onRenameTag ({ oldName, newName }) { + try { + await apiRenameTag(oldName, newName) + const t = store.allTags.find(x => x.name === oldName || x.label === oldName) + if (t) { t.name = newName; t.label = newName } + store.jobs.forEach(j => { j.tags = j.tags.map(tg => tg === oldName ? newName : tg) }) + store.technicians.forEach(tc => { tc.tags = tc.tags.map(tg => tg === oldName ? newName : tg) }) + } catch (_e) { /* rename failed */ } + } + async function onDeleteTag (label) { + try { + await apiDeleteTag(label) + store.allTags.splice(store.allTags.findIndex(t => t.label === label || t.name === label), 1) + store.jobs.forEach(j => { j.tags = j.tags.filter(t => t !== label) }) + store.technicians.forEach(tc => { tc.tags = tc.tags.filter(t => t !== label) }) + } catch (_e) { /* delete failed */ } + } + function _serializeTags (arr) { + return (arr || []).map(t => typeof t === 'string' ? { tag: t } : { tag: t.tag, level: t.level || 0, required: t.required || 0 }) + } + function persistJobTags (job) { + updateJob(job.name || job.id, { tags: _serializeTags(job.tagsWithLevel || job.tags) }).catch(() => {}) + } + function persistTechTags (tech) { + updateTech(tech.name || tech.id, { tags: _serializeTags(tech.tagsWithLevel || tech.tags) }).catch(() => {}) + } + + return { + getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag, + _serializeTags, persistJobTags, persistTechTags, + } +} diff --git a/apps/ops/src/composables/useTechManagement.js b/apps/ops/src/composables/useTechManagement.js new file mode 100644 index 0000000..2dfea63 --- /dev/null +++ b/apps/ops/src/composables/useTechManagement.js @@ -0,0 +1,74 @@ +import { ref } from 'vue' +import { updateTech } from 'src/api/dispatch' + +export function useTechManagement (store, invalidateRoutes) { + const editingTech = ref(null) + const newTechName = ref('') + const newTechPhone = ref('') + const newTechDevice = ref('') + const addingTech = ref(false) + + async function saveTechField (tech, field, value) { + const trimmed = typeof value === 'string' ? value.trim() : value + if (field === 'full_name') { + if (!trimmed || trimmed === tech.fullName) return + tech.fullName = trimmed + } else if (field === 'status') { + tech.status = trimmed + } else if (field === 'phone') { + if (trimmed === tech.phone) return + tech.phone = trimmed + } + try { await updateTech(tech.name || tech.id, { [field]: trimmed }) } + catch (_e) { /* save failed */ } + } + + async function addTech () { + const name = newTechName.value.trim() + if (!name || addingTech.value) return + addingTech.value = true + try { + const tech = await store.createTechnician({ + full_name: name, + phone: newTechPhone.value.trim() || '', + traccar_device_id: newTechDevice.value || '', + }) + newTechName.value = '' + newTechPhone.value = '' + newTechDevice.value = '' + if (tech.traccarDeviceId) await store.pollGps() + } catch (e) { + const msg = e?.message || String(e) + alert('Erreur création technicien:\n' + msg.replace(/<[^>]+>/g, '')) + } + finally { addingTech.value = false } + } + + async function removeTech (tech) { + if (!confirm(`Supprimer ${tech.fullName} ?`)) return + try { + const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id) + for (const job of linkedJobs) { + await store.unassignJob(job.id) + } + await store.deleteTechnician(tech.id) + } catch (e) { + const msg = e?.message || String(e) + alert('Erreur suppression:\n' + msg.replace(/<[^>]+>/g, '')) + } + } + + async function saveTraccarLink (tech, deviceId) { + tech.traccarDeviceId = deviceId || null + tech.gpsCoords = null + tech.gpsOnline = false + await updateTech(tech.name || tech.id, { traccar_device_id: deviceId || '' }) + await store.pollGps() + invalidateRoutes() + } + + return { + editingTech, newTechName, newTechPhone, newTechDevice, addingTech, + saveTechField, addTech, removeTech, saveTraccarLink, + } +} diff --git a/apps/ops/src/config/device-icons.js b/apps/ops/src/config/device-icons.js new file mode 100644 index 0000000..791376a --- /dev/null +++ b/apps/ops/src/config/device-icons.js @@ -0,0 +1,19 @@ +/** + * Maps equipment type strings to Material Design icon names. + */ + +const DEVICE_ICON_MAP = { + ONT: 'settings_input_hdmi', + Modem: 'router', + Routeur: 'router', + 'Décodeur TV': 'connected_tv', + 'Téléphone IP': 'phone_in_talk', + Switch: 'hub', + Amplificateur: 'cell_tower', + 'AP WiFi': 'wifi', + 'Câble/Connecteur': 'cable', +} + +export function deviceLucideIcon (type) { + return DEVICE_ICON_MAP[type] || 'devices_other' +} diff --git a/apps/ops/src/config/erpnext.js b/apps/ops/src/config/erpnext.js index b2564e0..4e013f0 100644 --- a/apps/ops/src/config/erpnext.js +++ b/apps/ops/src/config/erpnext.js @@ -4,6 +4,9 @@ const viteBase = import.meta.env.BASE_URL || '/' export const BASE_URL = viteBase === '/' ? '' : viteBase.replace(/\/$/, '') +// Direct link to ERPNext desk (for admin actions like user management) +export const ERP_DESK_URL = 'https://erp.gigafibre.ca' + export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg' export const TECH_COLORS = [ diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js new file mode 100644 index 0000000..5272a92 --- /dev/null +++ b/apps/ops/src/config/nav.js @@ -0,0 +1,31 @@ +// Ops sidebar navigation + search filter options +export const navItems = [ + { path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord' }, + { path: '/clients', icon: 'Users', label: 'Clients' }, + { path: '/dispatch', icon: 'Truck', label: 'Dispatch' }, + { path: '/tickets', icon: 'Ticket', label: 'Tickets' }, + { path: '/equipe', icon: 'UsersRound', label: 'Équipe' }, + { path: '/rapports', icon: 'BarChart3', label: 'Rapports' }, + { path: '/ocr', icon: 'ScanText', label: 'OCR Factures' }, + { path: '/settings', icon: 'Settings', label: 'Paramètres' }, +] + +export const territoryOptions = [ + { label: 'Gatineau', value: 'Gatineau' }, + { label: 'Ottawa', value: 'Ottawa' }, + { label: 'Aylmer', value: 'Aylmer' }, + { label: 'Hull', value: 'Hull' }, + { label: 'Buckingham', value: 'Buckingham' }, + { label: 'Masson-Angers', value: 'Masson-Angers' }, +] + +export const statusOptions = [ + { label: 'Actif', value: 'Active' }, + { label: 'Inactif', value: 'Inactive' }, + { label: 'En attente', value: 'Pending' }, +] + +export const customerTypeOptions = [ + { label: 'Individu', value: 'Individual' }, + { label: 'Entreprise', value: 'Company' }, +] diff --git a/apps/ops/src/config/project-templates.js b/apps/ops/src/config/project-templates.js new file mode 100644 index 0000000..e8240de --- /dev/null +++ b/apps/ops/src/config/project-templates.js @@ -0,0 +1,210 @@ +/** + * Project Templates — Pre-defined workflow trees for common service orders. + * + * Each template defines a sequence of Dispatch Job steps that get created + * together when applied to a ticket. Steps can have: + * - subject, job_type, priority, duration_h + * - assigned_group: which team/group handles it + * - depends_on_step: index (0-based) of the step this one depends on + * - on_open_webhook / on_close_webhook: n8n webhook URLs fired on status change + * + * Templates are stored in the frontend for now. Future: sync to ERPNext + * "Dispatch Template" doctype for admin management. + */ + +export const PROJECT_TEMPLATES = [ + { + id: 'phone_service', + name: 'Service téléphonique résidentiel', + icon: 'phone_in_talk', + description: 'Importation du numéro, installation fibre, portage du numéro', + category: 'Téléphonie', + steps: [ + { + subject: 'Importer le numéro de téléphone', + job_type: 'Autre', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: null, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Installation fibre (pré-requis portage)', + job_type: 'Installation', + priority: 'high', + duration_h: 2, + assigned_group: 'Tech Targo', + depends_on_step: 0, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Portage du numéro vers Gigafibre', + job_type: 'Autre', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: 1, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Validation et test du service téléphonique', + job_type: 'Dépannage', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Tech Targo', + depends_on_step: 2, + on_open_webhook: '', + on_close_webhook: '', + }, + ], + }, + { + id: 'fiber_install', + name: 'Installation fibre résidentielle', + icon: 'cable', + description: 'Vérification pré-install, installation, activation, test de débit', + category: 'Internet', + steps: [ + { + subject: 'Vérification pré-installation (éligibilité & OLT)', + job_type: 'Autre', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: null, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Installation fibre chez le client', + job_type: 'Installation', + priority: 'high', + duration_h: 3, + assigned_group: 'Tech Targo', + depends_on_step: 0, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Activation du service & configuration ONT', + job_type: 'Installation', + priority: 'high', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: 1, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Test de débit & validation client', + job_type: 'Dépannage', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Tech Targo', + depends_on_step: 2, + on_open_webhook: '', + on_close_webhook: '', + }, + ], + }, + { + id: 'move_service', + name: 'Déménagement de service', + icon: 'local_shipping', + description: 'Retrait ancien site, installation nouveau site, transfert abonnement', + category: 'Déménagement', + steps: [ + { + subject: 'Préparation déménagement (vérifier éligibilité nouveau site)', + job_type: 'Autre', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: null, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Retrait équipement ancien site', + job_type: 'Retrait', + priority: 'medium', + duration_h: 1, + assigned_group: 'Tech Targo', + depends_on_step: 0, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Installation au nouveau site', + job_type: 'Installation', + priority: 'high', + duration_h: 3, + assigned_group: 'Tech Targo', + depends_on_step: 1, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Transfert abonnement & mise à jour adresse', + job_type: 'Autre', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: 2, + on_open_webhook: '', + on_close_webhook: '', + }, + ], + }, + { + id: 'repair_service', + name: 'Réparation service client', + icon: 'build', + description: 'Diagnostic, intervention terrain, validation', + category: 'Support', + steps: [ + { + subject: 'Diagnostic à distance', + job_type: 'Dépannage', + priority: 'high', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: null, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Intervention terrain', + job_type: 'Réparation', + priority: 'high', + duration_h: 2, + assigned_group: 'Tech Targo', + depends_on_step: 0, + on_open_webhook: '', + on_close_webhook: '', + }, + { + subject: 'Validation & suivi client', + job_type: 'Dépannage', + priority: 'medium', + duration_h: 0.5, + assigned_group: 'Admin', + depends_on_step: 1, + on_open_webhook: '', + on_close_webhook: '', + }, + ], + }, +] + +export const ASSIGNED_GROUPS = [ + 'Admin', + 'Tech Targo', + 'Support', + 'NOC', + 'Facturation', +] diff --git a/apps/ops/src/config/table-columns.js b/apps/ops/src/config/table-columns.js new file mode 100644 index 0000000..b07249c --- /dev/null +++ b/apps/ops/src/config/table-columns.js @@ -0,0 +1,37 @@ +/** + * Static table column definitions for ClientDetailPage tables. + */ +import { decodeHtml } from 'src/composables/useFormatters' + +export const invoiceCols = [ + { name: 'name', label: 'N°', field: 'name', align: 'left' }, + { name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true }, + { name: 'grand_total', label: 'Total', field: 'grand_total', align: 'right', sortable: true }, + { name: 'outstanding_amount', label: 'Solde', field: 'outstanding_amount', align: 'right' }, + { name: 'status', label: 'Statut', field: 'status', align: 'center' }, +] + +export const paymentCols = [ + { name: 'name', label: 'N°', field: 'name', align: 'left' }, + { name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true }, + { name: 'paid_amount', label: 'Montant', field: 'paid_amount', align: 'right' }, + { name: 'mode_of_payment', label: 'Mode', field: 'mode_of_payment', align: 'left' }, + { name: 'reference_no', label: 'Référence', field: 'reference_no', align: 'left' }, +] + +export const invItemCols = [ + { name: 'item_name', label: 'Article', field: r => decodeHtml(r.item_name || r.item_code), align: 'left' }, + { name: 'qty', label: 'Qte', field: 'qty', align: 'center' }, + { name: 'rate', label: 'Prix unit.', field: 'rate', align: 'right' }, + { name: 'amount', label: 'Montant', field: 'amount', align: 'right' }, +] + +export const ticketCols = [ + { name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:20px;padding:0 2px' }, + { name: 'legacy_id', label: '', field: 'legacy_ticket_id', align: 'right', style: 'width:48px;padding:0 4px' }, + { name: 'subject', label: 'Sujet', field: 'subject', align: 'left' }, + { name: 'assigned', label: '', field: 'assigned_staff', align: 'center', style: 'width:48px;padding:0 2px' }, + { name: 'opening_date', label: '', field: 'opening_date', align: 'left', style: 'width:76px;white-space:nowrap;padding:0 4px' }, + { name: 'priority', label: '', field: 'priority', align: 'center', style: 'width:24px;padding:0 2px' }, + { name: 'status', label: '', field: 'status', align: 'center', style: 'width:64px;padding:0 4px' }, +] diff --git a/apps/ops/src/config/ticket-config.js b/apps/ops/src/config/ticket-config.js new file mode 100644 index 0000000..af61087 --- /dev/null +++ b/apps/ops/src/config/ticket-config.js @@ -0,0 +1,55 @@ +export const statusOptions = [ + { label: 'Tous', value: 'all' }, + { label: 'Non fermes', value: 'not_closed' }, + { label: 'Ouverts', value: 'Open' }, + { label: 'Repondus', value: 'Replied' }, + { label: 'Resolus', value: 'Resolved' }, + { label: 'Fermes', value: 'Closed' }, +] + +export const priorityOptions = [ + { label: 'Urgent', value: 'Urgent' }, + { label: 'Haute', value: 'High' }, + { label: 'Moyenne', value: 'Medium' }, + { label: 'Basse', value: 'Low' }, +] + +export const columns = [ + { name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:30px;padding:0' }, + { name: 'legacy_id', label: '#', field: 'legacy_ticket_id', align: 'left', sortable: true, style: 'width:70px' }, + { name: 'subject', label: 'Sujet', field: 'subject', align: 'left' }, + { name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true }, + { name: 'issue_type', label: 'Type', field: 'issue_type', align: 'left' }, + { name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true }, + { name: 'priority', label: 'Priorite', field: 'priority', align: 'center', sortable: true }, + { name: 'status', label: 'Statut', field: 'status', align: 'center' }, +] + +export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search, ownerFilter }) { + const filters = {} + if (statusFilter === 'not_closed') { + filters.status = ['!=', 'Closed'] + } else if (statusFilter !== 'all') { + filters.status = statusFilter + } + if (typeFilter) filters.issue_type = typeFilter + if (priorityFilter) filters.priority = priorityFilter + if (search?.trim()) { + const q = search.trim() + if (/^\d+$/.test(q)) { + filters.legacy_ticket_id = parseInt(q) + } else { + filters.subject = ['like', '%' + q + '%'] + } + } + if (ownerFilter === 'mine') { + filters.owner = ['like', '%'] + } + return filters +} + +export function getSortField (col) { + if (col === 'opening_date') return 'creation' + if (col === 'legacy_id') return 'legacy_ticket_id' + return col || 'creation' +} diff --git a/apps/ops/src/css/app.scss b/apps/ops/src/css/app.scss index 7ad8546..d4badee 100644 --- a/apps/ops/src/css/app.scss +++ b/apps/ops/src/css/app.scss @@ -1,16 +1,30 @@ // Targo Ops — Global styles :root { - --ops-primary: #1e293b; + // Shared dark palette (sidebar + dispatch) + --ops-sidebar-bg: #111422; + --ops-sidebar-hover: rgba(255,255,255,0.06); + --ops-sidebar-border: rgba(255,255,255,0.06); + --ops-sidebar-text: rgba(255,255,255,0.55); + --ops-sidebar-text-active: #ffffff; + + // Accent & semantic --ops-accent: #6366f1; --ops-success: #10b981; --ops-warning: #f59e0b; --ops-danger: #ef4444; + + // Light content area + --ops-bg-hover: #eef2ff; + --ops-bg-light: #f8fafc; --ops-bg: #f8fafc; --ops-surface: #ffffff; --ops-border: #e2e8f0; --ops-text: #1e293b; --ops-text-muted: #64748b; + + // Legacy alias + --ops-primary: #111422; } body { @@ -18,23 +32,78 @@ body { color: var(--ops-text); } -// Sidebar +// ── Sidebar ───────────────────────────────────────────────────────────────── .ops-sidebar { - background: var(--ops-primary); - width: 220px; + background: var(--ops-sidebar-bg) !important; + border-right: 1px solid var(--ops-sidebar-border) !important; + transition: width 0.2s ease; + + // Kill Quasar's default white border + &.q-drawer--bordered { border-right-color: var(--ops-sidebar-border) !important; } + + .q-list { padding-top: 0; } + .q-item { - color: rgba(255,255,255,0.7); + color: var(--ops-sidebar-text); border-radius: 8px; margin: 2px 8px; - &:hover { background: rgba(255,255,255,0.08); } + min-height: 40px; + transition: all 0.15s ease; + &:hover { background: var(--ops-sidebar-hover); color: rgba(255,255,255,0.8); } &.active-link { - color: #fff; + color: var(--ops-sidebar-text-active); background: var(--ops-accent); } } + + .q-separator--dark { background: var(--ops-sidebar-border) !important; } + + // Collapsed state + &.ops-sidebar-mini { + .q-item { + margin: 2px 6px; + padding: 8px 0; + justify-content: center; + .q-item__section--avatar { min-width: unset; padding-right: 0; } + } + } } -// Cards +// Sidebar bottom section +.ops-sidebar-bottom { + position: absolute; bottom: 0; left: 0; right: 0; + padding: 8px 0; + border-top: 1px solid var(--ops-sidebar-border); + .q-item { + color: var(--ops-sidebar-text); + font-size: 0.78rem; + &:hover { color: rgba(255,255,255,0.8); } + } +} + +.ops-collapse-btn { + color: var(--ops-sidebar-text) !important; + opacity: 0.7; + &:hover { opacity: 1; } +} + +// ── Mobile header ─────────────────────────────────────────────────────────── +.ops-mobile-header { + background: var(--ops-sidebar-bg) !important; + border-bottom: 1px solid var(--ops-sidebar-border) !important; +} + +// ── Desktop top bar ───────────────────────────────────────────────────────── +.ops-topbar { + display: flex; + align-items: center; + padding: 8px 24px; + border-bottom: 1px solid var(--ops-border); + background: var(--ops-surface); + position: relative; +} + +// ── Cards ─────────────────────────────────────────────────────────────────── .ops-card { background: var(--ops-surface); border: 1px solid var(--ops-border); @@ -42,37 +111,25 @@ body { padding: 16px; } -// Stat cards +// ── Stat cards ────────────────────────────────────────────────────────────── .ops-stat { text-align: center; - .ops-stat-value { - font-size: 1.8rem; - font-weight: 700; - line-height: 1.2; - } - .ops-stat-label { - font-size: 0.8rem; - color: var(--ops-text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - } + .ops-stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1.2; } + .ops-stat-label { font-size: 0.8rem; color: var(--ops-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } } -// Data tables +// ── Data tables ───────────────────────────────────────────────────────────── .ops-table { .q-table__top { padding: 8px 16px; } th { font-weight: 600; color: var(--ops-text-muted); font-size: 0.75rem; text-transform: uppercase; } td { font-size: 0.875rem; } } -// Status badges +// ── Status badges ─────────────────────────────────────────────────────────── .ops-badge { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border-radius: 20px; - font-size: 0.75rem; - font-weight: 600; + display: inline-flex; align-items: center; + padding: 2px 10px; border-radius: 20px; + font-size: 0.75rem; font-weight: 600; &.active { background: #d1fae5; color: #065f46; } &.inactive { background: #fee2e2; color: #991b1b; } &.draft { background: #e0e7ff; color: #3730a3; } @@ -80,7 +137,7 @@ body { &.closed { background: #f1f5f9; color: #475569; } } -// Search bar +// ── Search bar ────────────────────────────────────────────────────────────── .ops-search { .q-field__control { border-radius: 10px; @@ -88,3 +145,79 @@ body { border: 1px solid var(--ops-border); } } + +.ops-search-dark { + .q-field__control { + border-radius: 10px; + background: rgba(255,255,255,0.06); + border: 1px solid var(--ops-sidebar-border); + } + .q-field__native { color: #fff; } +} + +// ── Search results dropdown ──────────────────────────────────────────────── +.ops-search-results { + background: #fff; + border: 1px solid var(--ops-border); + border-top: none; + border-radius: 0 0 10px 10px; + box-shadow: 0 8px 24px rgba(0,0,0,0.12); + max-height: 400px; + overflow-y: auto; +} +.ops-search-results-desktop { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 9999; +} +.ops-search-result { + display: flex; + align-items: center; + padding: 10px 14px; + cursor: pointer; + transition: background 0.1s; + &:hover, &.ops-search-highlighted { background: #f1f5f9; } + &:not(:last-child) { border-bottom: 1px solid #f1f5f9; } +} +.ops-search-title { font-size: 0.875rem; font-weight: 600; color: var(--ops-text); line-height: 1.2; } +.ops-search-sub { font-size: 0.75rem; color: var(--ops-text-muted); } +.ops-search-type { + font-size: 0.65rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--ops-text-muted); margin-left: auto; white-space: nowrap; +} + +// ── ERP links ────────────────────────────────────────────────────────────── +.erp-link { + color: var(--ops-accent, #6366f1); + text-decoration: none; + cursor: pointer; + &:hover { text-decoration: underline; } +} + +// ── Clickable table rows ─────────────────────────────────────────────────── +.clickable-table :deep(tbody tr) { + cursor: pointer; + &:hover td { background: var(--ops-bg-hover, #eef2ff) !important; } +} + +// ── Sticky Convos panel (right column) ───────────────────────────────────── +.convos-sticky { + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + overflow: hidden; + display: flex; + flex-direction: column; + .chatter-panel { + display: flex; + flex-direction: column; + max-height: calc(100vh - 32px); + overflow: hidden; + .chatter-timeline { + flex: 1; + overflow-y: auto; + } + } +} diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index 1a043fb..566565e 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -1,89 +1,60 @@ @@ -1451,16 +1009,6 @@ code { font-size: 0.8rem; } -.erp-link { - color: var(--ops-accent, #6366f1); - text-decoration: none; - font-size: 0.85rem; - cursor: pointer; - &:hover { - text-decoration: underline; - } -} - .clickable-row { cursor: pointer; transition: background 0.1s; @@ -1474,12 +1022,6 @@ code { } } -.clickable-table :deep(tbody tr) { - cursor: pointer; - &:hover td { - background: #eef2ff !important; - } -} .ticket-subject-cell { white-space: normal !important; word-break: break-word; @@ -1506,40 +1048,6 @@ code { margin-left: 0; } -.modal-field-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2px 16px; -} - -.mf { - display: flex; - align-items: baseline; - gap: 8px; - padding: 6px 0; - font-size: 0.875rem; - border-bottom: 1px solid #f1f5f9; -} - -.mf-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--ops-text-muted); - min-width: 80px; - flex-shrink: 0; -} - -.modal-desc { - font-size: 0.85rem; - line-height: 1.5; - color: var(--ops-text); - background: #f8fafc; - border-radius: 8px; - padding: 10px 12px; - max-height: 300px; - overflow-y: auto; -} - .device-strip { display: flex; flex-wrap: wrap; diff --git a/apps/ops/src/pages/DashboardPage.vue b/apps/ops/src/pages/DashboardPage.vue index 83cbd02..5073fc3 100644 --- a/apps/ops/src/pages/DashboardPage.vue +++ b/apps/ops/src/pages/DashboardPage.vue @@ -4,8 +4,13 @@
-
{{ stat.value }}
-
{{ stat.label }}
+
+ +
+
{{ stat.value }}
+
{{ stat.label }}
+
+
@@ -59,7 +64,11 @@
Tickets ouverts
- + + + + {{ t.subject }} {{ t.customer_name || t.customer }} @@ -78,20 +87,30 @@
-
Dispatch aujourd'hui
+
+ Dispatch aujourd'hui + +
- + + + + {{ j.subject || j.name }} - {{ j.customer_name }} + + {{ j.assigned_tech }} + · {{ j.customer }} + - {{ j.status }} + {{ j.status }} - Aucune tâche aujourd'hui + Aucune tâche planifiée aujourd'hui @@ -108,11 +127,11 @@ import { authFetch } from 'src/api/auth' import { BASE_URL } from 'src/config/erpnext' const stats = ref([ - { label: 'Abonnés', value: '...', color: 'var(--ops-accent)' }, - { label: 'Clients', value: '...', color: 'var(--ops-primary)' }, - { label: 'Abonnements', value: '...', color: 'var(--ops-success)' }, - { label: 'Locations', value: '...', color: '#6b7280' }, - { label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)' }, + { label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' }, + { label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' }, + { label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' }, + { label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' }, + { label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' }, ]) const openTickets = ref([]) @@ -169,36 +188,48 @@ async function runBilling () { onMounted(async () => { fetchSchedulerStatus() - const [clients, tickets, subs, locations] = await Promise.all([ + const today = new Date().toISOString().slice(0, 10) + + const [clients, tickets, todayDispatch, openTix] = await Promise.all([ countDocs('Customer', { disabled: 0 }), countDocs('Issue', { status: 'Open' }), - countDocs('Service Subscription', { status: 'Actif' }), - countDocs('Service Location', { status: 'Active' }), + listDocs('Dispatch Job', { + filters: { scheduled_date: today }, + fields: ['name', 'subject', 'status', 'assigned_tech', 'customer', 'priority', 'source_issue'], + limit: 50, orderBy: 'start_time asc', + }).catch(() => []), + listDocs('Issue', { + filters: { status: 'Open' }, + fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'], + limit: 10, orderBy: 'opening_date desc', + }), ]) // Abonnés = unique customers with active subscriptions (via server script) let abonnes = 0 + let monthlyRev = 0 try { - const res = await authFetch(BASE_URL + '/api/method/subscriber_count') - if (res.ok) { - const data = await res.json() - abonnes = data.message?.count || 0 - } + const [subRes, revRes] = await Promise.all([ + authFetch(BASE_URL + '/api/method/subscriber_count').then(r => r.ok ? r.json() : null).catch(() => null), + listDocs('Service Subscription', { + filters: { status: 'Actif' }, + fields: ['actual_price'], + limit: 0, + }).catch(() => []), + ]) + abonnes = subRes?.message?.count || clients + monthlyRev = revRes.reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0) } catch { abonnes = clients } stats.value[0].value = abonnes.toLocaleString() stats.value[1].value = clients.toLocaleString() - stats.value[2].value = subs.toLocaleString() - stats.value[3].value = locations.toLocaleString() - stats.value[4].value = tickets.toLocaleString() + stats.value[2].value = monthlyRev ? (Math.round(monthlyRev).toLocaleString() + ' $') : '...' + stats.value[3].value = tickets.toLocaleString() + stats.value[4].value = todayDispatch.length.toLocaleString() - openTickets.value = await listDocs('Issue', { - filters: { status: 'Open' }, - fields: ['name', 'subject', 'customer', 'priority', 'opening_date'], - limit: 10, - orderBy: 'opening_date desc', - }) + openTickets.value = openTix + todayJobs.value = todayDispatch }) diff --git a/apps/ops/src/pages/DispatchPage.vue b/apps/ops/src/pages/DispatchPage.vue index d208ee5..82d51ce 100644 --- a/apps/ops/src/pages/DispatchPage.vue +++ b/apps/ops/src/pages/DispatchPage.vue @@ -4,7 +4,7 @@ import { useDispatchStore } from 'src/stores/dispatch' import { useAuthStore } from 'src/stores/auth' import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext' import { fetchOpenRequests } from 'src/api/service-request' -import { updateJob, updateTech, createTag as apiCreateTag, updateTag as apiUpdateTag, renameTag as apiRenameTag, deleteTag as apiDeleteTag } from 'src/api/dispatch' +import { updateJob } from 'src/api/dispatch' // ── Components ────────────────────────────────────────────────────────────────── import TagEditor from 'src/components/shared/TagEditor.vue' @@ -18,7 +18,7 @@ import RightPanel from 'src/modules/dispatch/components/RightPanel.vue' // ── Composables ───────────────────────────────────────────────────────────────── import { - localDateStr, startOfWeek, startOfMonth, timeToH, fmtDur, + localDateStr, timeToH, fmtDur, SVC_COLORS, prioLabel, prioClass, serializeAssistants, jobColor as _jobColorBase, ICON, prioColor, } from 'src/composables/useHelpers' @@ -29,118 +29,37 @@ import { useBottomPanel } from 'src/composables/useBottomPanel' import { useDragDrop } from 'src/composables/useDragDrop' import { useSelection } from 'src/composables/useSelection' import { useAutoDispatch } from 'src/composables/useAutoDispatch' +import { usePeriodNavigation } from 'src/composables/usePeriodNavigation' +import { useResourceFilter } from 'src/composables/useResourceFilter' +import { useTagManagement } from 'src/composables/useTagManagement' +import { useContextMenus } from 'src/composables/useContextMenus' +import { useTechManagement } from 'src/composables/useTechManagement' // ─── Store ──────────────────────────────────────────────────────────────────── const store = useDispatchStore() const auth = useAuthStore() const erpUrl = BASE_URL || window.location.origin -// ─── Date / View ───────────────────────────────────────────────────────────── -const currentView = ref(localStorage.getItem('sbv2-view') || 'week') -const savedDate = localStorage.getItem('sbv2-date') -const anchorDate = ref(savedDate ? new Date(savedDate + 'T00:00:00') : new Date()) +// ─── Period Navigation ─────────────────────────────────────────────────────── +const { + currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr, + prevPeriod, nextPeriod, goToToday, goToDay, +} = usePeriodNavigation() -watch(currentView, v => localStorage.setItem('sbv2-view', v)) -watch(anchorDate, d => localStorage.setItem('sbv2-date', localDateStr(d))) - -const periodStart = computed(() => { - const d = new Date(anchorDate.value); d.setHours(0,0,0,0) - if (currentView.value === 'day') return d - if (currentView.value === 'week') return startOfWeek(d) - return startOfMonth(d) -}) -const periodDays = computed(() => { - if (currentView.value === 'day') return 1 - if (currentView.value === 'week') return 7 - const s = periodStart.value - return new Date(s.getFullYear(), s.getMonth()+1, 0).getDate() -}) -const dayColumns = computed(() => { - const cols = [] - for (let i = 0; i < periodDays.value; i++) { - const d = new Date(periodStart.value); d.setDate(d.getDate() + i); cols.push(d) - } - return cols -}) -const periodLabel = computed(() => { - const s = periodStart.value - if (currentView.value === 'day') - return s.toLocaleDateString('fr-CA', { weekday:'long', day:'numeric', month:'long', year:'numeric' }) - if (currentView.value === 'week') { - const e = new Date(s); e.setDate(e.getDate() + 6) - return `${s.toLocaleDateString('fr-CA',{day:'numeric',month:'short'})} – ${e.toLocaleDateString('fr-CA',{day:'numeric',month:'short',year:'numeric'})}` - } - return s.toLocaleDateString('fr-CA', { month:'long', year:'numeric' }) -}) -const todayStr = localDateStr(new Date()) - -function prevPeriod () { - const d = new Date(anchorDate.value) - if (currentView.value === 'day') d.setDate(d.getDate()-1) - if (currentView.value === 'week') d.setDate(d.getDate()-7) - if (currentView.value === 'month') d.setMonth(d.getMonth()-1) - anchorDate.value = d -} -function nextPeriod () { - const d = new Date(anchorDate.value) - if (currentView.value === 'day') d.setDate(d.getDate()+1) - if (currentView.value === 'week') d.setDate(d.getDate()+7) - if (currentView.value === 'month') d.setMonth(d.getMonth()+1) - anchorDate.value = d -} -function goToToday () { anchorDate.value = new Date(); currentView.value = 'day' } -function goToDay (d) { anchorDate.value = new Date(d); currentView.value = 'day' } +// ─── Resource Filter ───────────────────────────────────────────────────────── +const { + selectedResIds, filterStatus, filterTags, searchQuery, techSort, manualOrder, + filteredResources, resSelectorOpen, tempSelectedIds, dragReorderTech, + openResSelector, applyResSelector, toggleTempRes, clearFilters, + onTechReorderStart, onTechReorderDrop, +} = useResourceFilter(store) // ─── Tags ──────────────────────────────────────────────────────────────────── const techTagModal = ref(null) -function getTagColor (tagLabel) { - const t = store.allTags.find(x => x.label === tagLabel) - return t?.color || '#6b7280' -} -async function onCreateTag ({ label, color }) { - color = color || '#6b7280' - try { - const created = await apiCreateTag(label, 'Custom', color) - if (created) store.allTags.push({ name: created.name, label: created.label, color: created.color || color, category: created.category || 'Custom' }) - } catch (e) { - if (!store.allTags.some(t => t.label === label)) - store.allTags.push({ name: label, label, color, category: 'Custom' }) - } -} -async function onUpdateTag ({ name, color }) { - try { - await apiUpdateTag(name, { color }) - const t = store.allTags.find(x => x.name === name || x.label === name) - if (t) t.color = color - } catch (e) { console.error('updateTag', e) } -} -async function onRenameTag ({ oldName, newName }) { - try { - await apiRenameTag(oldName, newName) - const t = store.allTags.find(x => x.name === oldName || x.label === oldName) - if (t) { t.name = newName; t.label = newName } - // Update references in all jobs and techs - store.jobs.forEach(j => { j.tags = j.tags.map(tg => tg === oldName ? newName : tg) }) - store.technicians.forEach(tc => { tc.tags = tc.tags.map(tg => tg === oldName ? newName : tg) }) - } catch (e) { console.error('renameTag', e) } -} -async function onDeleteTag (label) { - try { - await apiDeleteTag(label) - store.allTags.splice(store.allTags.findIndex(t => t.label === label || t.name === label), 1) - store.jobs.forEach(j => { j.tags = j.tags.filter(t => t !== label) }) - store.technicians.forEach(tc => { tc.tags = tc.tags.filter(t => t !== label) }) - } catch (e) { console.error('deleteTag', e) } -} -function _serializeTags (arr) { - return (arr || []).map(t => typeof t === 'string' ? { tag: t } : { tag: t.tag, level: t.level || 0, required: t.required || 0 }) -} -function persistJobTags (job) { - updateJob(job.name || job.id, { tags: _serializeTags(job.tagsWithLevel || job.tags) }).catch(() => {}) -} -function persistTechTags (tech) { - updateTech(tech.name || tech.id, { tags: _serializeTags(tech.tagsWithLevel || tech.tags) }).catch(() => {}) -} +const { + getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag, + _serializeTags, persistJobTags, persistTechTags, +} = useTagManagement(store) // ─── Address autocomplete ──────────────────────────────────────────────────── const addrResults = ref([]) @@ -175,13 +94,11 @@ function setEndDate (job, endDate) { } // ─── Layout state ───────────────────────────────────────────────────────────── -const sidebarCollapsed = ref(localStorage.getItem('sbv2-sideCollapsed') !== 'false') -const sidebarFlyout = ref(null) +const filterPanelOpen = ref(false) +const projectsPanelOpen = ref(false) const mapVisible = ref(localStorage.getItem('sbv2-map') === 'true') const rightPanel = ref(null) -const searchQuery = ref('') -watch(sidebarCollapsed, v => localStorage.setItem('sbv2-sideCollapsed', v ? 'true' : 'false')) watch(mapVisible, v => localStorage.setItem('sbv2-map', v ? 'true' : 'false')) // ─── Edit modal (double-click) ─────────────────────────────────────────────── @@ -209,60 +126,6 @@ function confirmEdit () { invalidateRoutes() } -// ─── Resources & Filters ───────────────────────────────────────────────────── -const selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]')) -const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '') -const filterTags = ref(JSON.parse(localStorage.getItem('sbv2-filterTags') || '[]')) - -watch(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true }) -watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v)) -const resSelectorOpen = ref(false) -const tempSelectedIds = ref([]) - -const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default') -const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]')) -watch(techSort, v => localStorage.setItem('sbv2-techSort', v)) - -const filteredResources = computed(() => { - let list = store.technicians - if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(t => t.fullName.toLowerCase().includes(q)) } - if (filterStatus.value) list = list.filter(t => (t.status||'available') === filterStatus.value) - if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id)) - if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft))) - if (techSort.value === 'alpha') { - list = [...list].sort((a, b) => a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase())) - } else if (techSort.value === 'manual' && manualOrder.value.length) { - const order = manualOrder.value - list = [...list].sort((a, b) => (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id))) - } - return list -}) - -const dragReorderTech = ref(null) -function onTechReorderStart (e, tech) { - dragReorderTech.value = tech - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', 'reorder') -} -function onTechReorderDrop (e, targetTech) { - e.preventDefault() - if (!dragReorderTech.value || dragReorderTech.value.id === targetTech.id) { dragReorderTech.value = null; return } - techSort.value = 'manual' - const ids = filteredResources.value.map(t => t.id) - const fromIdx = ids.indexOf(dragReorderTech.value.id), toIdx = ids.indexOf(targetTech.id) - ids.splice(fromIdx, 1); ids.splice(toIdx, 0, dragReorderTech.value.id) - manualOrder.value = ids; localStorage.setItem('sbv2-techOrder', JSON.stringify(ids)) - dragReorderTech.value = null -} - -function openResSelector () { tempSelectedIds.value = [...selectedResIds.value]; resSelectorOpen.value = true } -function applyResSelector () { selectedResIds.value = [...tempSelectedIds.value]; resSelectorOpen.value = false } -function toggleTempRes (id) { - const idx = tempSelectedIds.value.indexOf(id) - if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id) -} -function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; searchQuery.value = ''; filterTags.value = []; localStorage.removeItem('sbv2-filterTags') } - // ─── Job schedule helpers ───────────────────────────────────────────────────── function getJobDate (jobId) { return store.jobs.find(j => j.id === jobId)?.scheduledDate || null } function getJobTime (jobId) { return store.jobs.find(j => j.id === jobId)?.startTime || null } @@ -340,6 +203,14 @@ function fullUnassign (job) { invalidateRoutes() } +// ─── Context menus ──────────────────────────────────────────────────────────── +const { + ctxMenu, techCtx, assistCtx, assistNoteModal, + openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx, + assistCtxTogglePin, assistCtxRemove, assistCtxNote, confirmAssistNote, + ctxDetails, ctxMove, ctxUnschedule, +} = useContextMenus({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal }) + // ─── Bottom panel composable ────────────────────────────────────────────────── const { bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize, @@ -356,7 +227,6 @@ const { techHasLinkedJob, techIsHovered, } = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign }) -// Wrap selectJob to pass rightPanel ref function selectJob (job, techId, isAssist = false, assistTechId = null, event = null) { _selectJob(job, techId, isAssist, assistTechId, event, rightPanel) } @@ -411,66 +281,6 @@ watch( }, ) -// ─── Context menus ──────────────────────────────────────────────────────────── -const ctxMenu = ref(null) -const techCtx = ref(null) -const assistCtx = ref(null) -const assistNoteModal = ref(null) - -function openCtxMenu (e, job, techId) { - e.preventDefault(); e.stopPropagation() - ctxMenu.value = { x: Math.min(e.clientX, window.innerWidth-180), y: Math.min(e.clientY, window.innerHeight-200), job, techId } -} -function closeCtxMenu () { ctxMenu.value = null } -function openTechCtx (e, tech) { - e.preventDefault(); e.stopPropagation() - techCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-200), tech } -} -function openAssistCtx (e, job, techId) { - e.preventDefault(); e.stopPropagation() - assistCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-150), job, techId } -} -function assistCtxTogglePin () { - if (!assistCtx.value) return - const { job, techId } = assistCtx.value - const assist = job.assistants.find(a => a.techId === techId) - if (assist) { - assist.pinned = !assist.pinned - updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) - invalidateRoutes() - } - assistCtx.value = null -} -function assistCtxRemove () { - if (!assistCtx.value) return - store.removeAssistant(assistCtx.value.job.id, assistCtx.value.techId) - invalidateRoutes(); assistCtx.value = null -} -function assistCtxNote () { - if (!assistCtx.value) return - const { job, techId } = assistCtx.value - const assist = job.assistants.find(a => a.techId === techId) - assistNoteModal.value = { job, techId, note: assist?.note || '' } - assistCtx.value = null -} -function confirmAssistNote () { - if (!assistNoteModal.value) return - const { job, techId, note } = assistNoteModal.value - const assist = job.assistants.find(a => a.techId === techId) - if (assist) { - assist.note = note - updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) - } - assistNoteModal.value = null -} - -function ctxDetails () { - const { job, techId } = ctxMenu.value - rightPanel.value = { mode: 'details', data: { job, tech: store.technicians.find(t => t.id === techId) } }; closeCtxMenu() -} -function ctxMove () { const { job, techId } = ctxMenu.value; openMoveModal(job, techId); closeCtxMenu() } -function ctxUnschedule () { fullUnassign(ctxMenu.value.job); closeCtxMenu() } - // ─── Move modal ─────────────────────────────────────────────────────────────── const moveModalOpen = ref(false) const moveForm = ref(null) @@ -494,72 +304,12 @@ const bookingOverlay = ref(null) const woModal = ref(null) const gpsSettingsOpen = ref(false) -async function saveTraccarLink (tech, deviceId) { - tech.traccarDeviceId = deviceId || null - tech.gpsCoords = null - tech.gpsOnline = false - await updateTech(tech.name || tech.id, { traccar_device_id: deviceId || '' }) - await store.pollGps() - invalidateRoutes() -} - // ─── Tech management (GPS modal) ───────────────────────────────────────────── -const editingTech = ref(null) // tech.id being edited inline -const newTechName = ref('') -const newTechPhone = ref('') -const newTechDevice = ref('') -const addingTech = ref(false) +const { + editingTech, newTechName, newTechPhone, newTechDevice, addingTech, + saveTechField, addTech, removeTech, saveTraccarLink, +} = useTechManagement(store, invalidateRoutes) -async function saveTechField (tech, field, value) { - const trimmed = typeof value === 'string' ? value.trim() : value - if (field === 'full_name') { - if (!trimmed || trimmed === tech.fullName) return - tech.fullName = trimmed - } else if (field === 'status') { - tech.status = trimmed - } else if (field === 'phone') { - if (trimmed === tech.phone) return - tech.phone = trimmed - } - try { await updateTech(tech.name || tech.id, { [field]: trimmed }) } - catch (e) { console.error('Erreur sauvegarde tech:', e) } -} - -async function addTech () { - const name = newTechName.value.trim() - if (!name || addingTech.value) return - addingTech.value = true - try { - const tech = await store.createTechnician({ - full_name: name, - phone: newTechPhone.value.trim() || '', - traccar_device_id: newTechDevice.value || '', - }) - newTechName.value = '' - newTechPhone.value = '' - newTechDevice.value = '' - if (tech.traccarDeviceId) await store.pollGps() - } catch (e) { - const msg = e?.message || String(e) - alert('Erreur création technicien:\n' + msg.replace(/<[^>]+>/g, '')) - } - finally { addingTech.value = false } -} - -async function removeTech (tech) { - if (!confirm(`Supprimer ${tech.fullName} ?`)) return - try { - // First unassign all jobs linked to this tech - const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id) - for (const job of linkedJobs) { - await store.unassignJob(job.id) - } - await store.deleteTechnician(tech.id) - } catch (e) { - const msg = e?.message || String(e) - alert('Erreur suppression:\n' + msg.replace(/<[^>]+>/g, '')) - } -} function openWoModal (prefillDate = null, prefillTech = null) { woModal.value = { subject: '', address: '', latitude: null, longitude: null, duration_h: 1, priority: 'low', note: '', tags: [], techId: prefillTech || '', date: prefillDate || todayStr } } @@ -607,10 +357,8 @@ function onDropUnassign (e) { // ─── Click empty space = deselect all ───────────────────────────────────────── let _lassoJustEnded = false function onRootClick (e) { - // Skip if a lasso just finished (mouseup → click fires immediately after) if (_lassoJustEnded) { _lassoJustEnded = false; return } - // Only if clicking on empty space — not on blocks, rows, buttons, inputs, panels - const interactive = e.target.closest('.sb-block, .sb-chip, .sb-bottom-row, .sb-bottom-hdr, button, input, select, a, .sb-ctx-menu, .sb-right-panel, .sb-wo-modal, .sb-edit-modal, .sb-criteria-modal, .sb-gps-modal, .sb-modal-overlay, .sb-multi-bar, .sb-sidebar-strip, .sb-sidebar-flyout, .sb-header, .sb-bt-checkbox, .sb-res-cell, .sb-bottom-date-sep') + const interactive = e.target.closest('.sb-block, .sb-chip, .sb-bottom-row, .sb-bottom-hdr, button, input, select, a, .sb-ctx-menu, .sb-right-panel, .sb-wo-modal, .sb-edit-modal, .sb-criteria-modal, .sb-gps-modal, .sb-modal-overlay, .sb-multi-bar, .sb-toolbar-panel, .sb-header, .sb-bt-checkbox, .sb-res-cell, .sb-bottom-date-sep') if (interactive) return if (selectedJob.value || multiSelect.value.length || bottomSelected.size || rightPanel.value) { selectedJob.value = null @@ -626,7 +374,8 @@ function onKeyDown (e) { closeCtxMenu(); assistCtx.value = null; techCtx.value = null; assistNoteModal.value = null moveModalOpen.value = false; resSelectorOpen.value = false; rightPanel.value = null timeModal.value = null; woModal.value = null; editModal.value = null - dispatchCriteriaModal.value = false; bookingOverlay.value = null; sidebarFlyout.value = null + dispatchCriteriaModal.value = false; bookingOverlay.value = null + filterPanelOpen.value = false; projectsPanelOpen.value = false selectedJob.value = null; multiSelect.value = [] } if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { @@ -684,7 +433,6 @@ provide('addrResults', addrResults) provide('selectAddr', selectAddr) // ─── Lifecycle ──────────────────────────────────────────────────────────────── -// Auth is handled by App.vue — this component only mounts when auth.user is set onMounted(async () => { if (!store.technicians.length) await store.loadAll() const savedCoords = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}') @@ -699,7 +447,6 @@ onMounted(async () => { const l = document.createElement('link'); l.id='mapbox-css'; l.rel='stylesheet' l.href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) } - // Start GPS tracking (WebSocket + initial REST) store.startGpsTracking() }) onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('click', closeCtxMenu); destroyMap(); store.stopGpsTracking() }) @@ -708,15 +455,21 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document - + diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue new file mode 100644 index 0000000..4c2b5c7 --- /dev/null +++ b/apps/ops/src/pages/SettingsPage.vue @@ -0,0 +1,229 @@ + + + diff --git a/apps/ops/src/pages/TicketsPage.vue b/apps/ops/src/pages/TicketsPage.vue index 7c11b9f..20ded44 100644 --- a/apps/ops/src/pages/TicketsPage.vue +++ b/apps/ops/src/pages/TicketsPage.vue @@ -15,12 +15,12 @@ :options="statusOptions" @update:model-value="resetAndLoad" />
-
Type / Département
+
Type / Departement
-
Priorité
+
Priorite
@@ -72,7 +72,7 @@ {{ props.row.customer_name || props.row.customer }} - + --- -