diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index ba2cc03..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,151 +0,0 @@ -# Targo/Gigafibre FSM — Context for Claude Cowork - -> Last updated: 2026-03-30 - -## Project Overview - -Targo Internet is a fiber ISP in Quebec. Gigafibre is the consumer brand. We're migrating from a legacy PHP/MariaDB system to ERPNext v16 + custom Vue.js (Quasar) apps. - -## Architecture - -``` -Server: 96.125.196.67 (Proxmox VM) -Traefik v2.11 (80/443) → Docker containers: - erp.gigafibre.ca → ERPNext v16.10.1 (PostgreSQL, 9 containers) - erp.gigafibre.ca/ops/ → Ops PWA (Quasar/Vue3, Authentik SSO via forwardAuth) - dispatch.gigafibre.ca → Legacy dispatch app (being replaced by ops /dispatch) - auth.targo.ca → Authentik SSO - oss.gigafibre.ca → Oktopus CE (TR-069) - git.targo.ca → Gitea - n8n.gigafibre.ca → n8n workflows -``` - -## Codebase Layout - -``` -gigafibre-fsm/ -├── apps/ -│ ├── ops/ ← Main ops PWA (Quasar v2 + Vite) -│ │ ├── src/ -│ │ │ ├── api/ -│ │ │ │ ├── auth.js # authFetch with token: b273a666c86d2d0:06120709db5e414 -│ │ │ │ ├── dispatch.js # CRUD for Dispatch Job, Tech, Tag + rename/delete -│ │ │ │ ├── service-request.js # ServiceRequest, ServiceBid, EquipmentInstall -│ │ │ │ └── traccar.js # GPS tracking (Traccar API) -│ │ │ ├── components/ -│ │ │ │ ├── shared/ -│ │ │ │ │ ├── TagEditor.vue # Universal inline tag editor (autocomplete, color, level, required) -│ │ │ │ │ └── TagInput.vue # Old tag input (deprecated, replaced by TagEditor) -│ │ │ │ └── customer/ # CustomerHeader, CustomerInfoCard, etc. -│ │ │ ├── composables/ # useHelpers, useScheduler, useMap, useDragDrop, etc. -│ │ │ ├── config/ -│ │ │ │ └── erpnext.js # BASE_URL='', MAPBOX_TOKEN, TECH_COLORS -│ │ │ ├── modules/ -│ │ │ │ └── dispatch/ -│ │ │ │ └── components/ # TimelineRow, BottomPanel, JobEditModal, RightPanel, etc. -│ │ │ ├── pages/ -│ │ │ │ ├── DispatchPage.vue # Full-screen dispatch V2 (1600+ lines) -│ │ │ │ ├── ClientDetailPage.vue -│ │ │ │ ├── ClientsPage.vue -│ │ │ │ ├── TicketsPage.vue -│ │ │ │ └── ... -│ │ │ ├── stores/ -│ │ │ │ ├── dispatch.js # Pinia store: technicians, jobs, allTags -│ │ │ │ └── auth.js -│ │ │ └── router/index.js # / = MainLayout, /dispatch = standalone -│ │ └── deploy.sh # Build + deploy to erp.gigafibre.ca -│ └── dispatch/ # Legacy standalone dispatch app (deprecated) -├── scripts/migration/ # Python migration scripts (tickets, customers, etc.) -└── CONTEXT.md # This file -``` - -## Key ERPNext Custom Doctypes - -### Dispatch Job -- `ticket_id`, `subject`, `customer` (Link→Customer), `service_location` (Link→Service Location) -- `job_type` (Select: Installation/Réparation/Maintenance/Retrait/Dépannage/Autre) -- `source_issue` (Link→Issue), `address`, `longitude`, `latitude` -- `priority` (low/medium/high), `duration_h`, `status` (open/assigned/in_progress/done) -- `assigned_tech` (Link→Dispatch Technician), `assigned_user` (Link→User) -- `scheduled_date`, `start_time`, `end_date` -- `tags` (Table→Dispatch Tag Link), `assistants` (Table→Dispatch Job Assistant) -- `equipment_items`, `materials_used`, `checklist`, `photos`, `customer_signature` -- `actual_start`, `actual_end`, `travel_time_min`, `completion_notes` - -### Dispatch Technician -- `technician_id`, `full_name`, `user` (Link→User), `phone`, `email` -- `status` (Disponible/En route/En pause/Hors ligne), `color_hex` -- `longitude`, `latitude`, `traccar_device_id`, `employee` (Link→Employee) -- `tags` (Table→Dispatch Tag Link) - -### Dispatch Tag -- `label`, `color` (hex), `category` (Skill/Service/Region/Equipment/Custom) -- Current tags: Fibre (#3b82f6), Téléphonie (#f59e0b), TV (#06b6d4), Installation (#06b6d4), Fusionneur (#f59e0b), Monteur (#8b5cf6), Câblage (#10b981), Caméra IP (#a855f7), Garage (#78716c), Urgence (#ef4444), Rive-Sud (#14b8a6), Montréal (#06b6d4) - -### Dispatch Tag Link (child table) -- `tag` (Link→Dispatch Tag), `level` (Int, default 0), `required` (Check, default 0) -- On **techs**: level = skill proficiency (1=base, 5=expert) -- On **jobs**: level = minimum required proficiency, required = mandatory for dispatch matching - -### Issue (ERPNext standard + custom fields) -- `legacy_ticket_id`, `assigned_staff` (group name e.g. "Tech Targo"), `opened_by_staff` -- `issue_type`: "Reparation Fibre", "Installation Fibre", "Install/Reparation Télé", "Téléphonie", "Télévision", "Monteur", "Fusionneur", "Installation", "Support", "ToDo", "Projet", "Conception" -- `is_incident`, `impact_zone`, `affected_clients`, `parent_incident`, `is_important` -- `service_location` (Link→Service Location) - -## Auth Pattern - -All API calls use token auth via `authFetch()`: -```js -Authorization: token $ERP_SERVICE_TOKEN // see server .env -``` -Authentik SSO protects the ops app at Traefik level (forwardAuth). The token is injected server-side by the nginx proxy (or via `VITE_ERP_TOKEN` in dev). - -## Tag/Skill System — Auto-Dispatch Logic (designed, not yet wired) - -The dispatch system uses a cost-optimization model inspired by AI agent routing: - -1. Each **job** has tags with `required` flag and `level` (minimum skill needed) -2. Each **tech** has tags with `level` (skill proficiency 1-5) -3. Auto-dispatch: find techs where `tech.level >= job.requiredLevel` for all **required** tags -4. Among matches, pick the **lowest adequate** tech (closest skill to required level) -5. This preserves experts for complex jobs — don't send the level-5 splicer to a basic install - -Example: -- Job: Fibre (required, level 3) → needs someone competent -- Tech A: Fibre level 5 (expert) — can do it but overkill -- Tech B: Fibre level 3 (adequate) — send this one, keep A free - -## 20 Test Dispatch Jobs (2026-03-31) - -Created from Tech Targo Issues. Tagged by issue_type: -- 6 jobs: Fibre only (Reparation Fibre) -- 4 jobs: Fibre + Installation (Installation/Installation Fibre) -- 10 jobs: TV + Téléphonie (Install/Reparation Télé) - -## 46 Dispatch Technicians - -All imported from legacy system. **Not yet tagged with skills** — need team breakdown. - -## Deploy - -```bash -cd apps/ops && bash deploy.sh # builds Quasar PWA + deploys to erp.gigafibre.ca -``` - -## Pending Work - -1. **Tag technicians with skills** — assign Fibre/Télé/etc. tags + levels to 46 techs -2. **Wire auto-dispatch logic** — implement the matching algorithm in useAutoDispatch.js -3. **Ticket-to-dispatch UI** — button in ticket detail modal to create Dispatch Job from Issue -4. **Customer portal** — store.targo.ca/clients replacement with Stripe payments -5. **Field tech mobile app** — barcode scanner, equipment install, job completion -6. **Code optimization** — extract components from ClientDetailPage.vue (1500+ lines) - -## ERPNext PostgreSQL Gotchas - -- GROUP BY requires all selected columns (not just aggregates) -- HAVING clause needs explicit column references -- Double-quoted identifiers are case-sensitive -- Transaction abort cascades (one error blocks subsequent queries until rollback) -- Patches applied in `scripts/migration/` for bulk operations diff --git a/README.md b/README.md index a36fd23..32d46fd 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,130 @@ # Gigafibre FSM -Complete operations platform for **Gigafibre** (consumer brand of TARGO Internet), a fiber ISP in Quebec. Replaces a legacy PHP/MariaDB billing system with ERPNext v16 + custom Vue.js (Quasar) apps. +Gigafibre FSM is the operations platform for **Gigafibre** (consumer brand of TARGO Internet), a fiber ISP in Quebec. It replaces a legacy PHP/MariaDB billing system with ERPNext v16 + Vue 3/Quasar apps for ops, dispatch, field service, and customer self-service. -## What This Repo Contains +## Repository Structure -| Directory | Description | -|-----------|-------------| -| `apps/ops/` | **Targo Ops** — main operations PWA (Vue 3 / Quasar v2) | -| `apps/field/` | **Targo Field** — mobile app for technicians (barcode, diagnostics, offline) | -| `apps/client/` | **Gigafibre Portal** — customer self-service portal | -| `apps/website/` | **www.gigafibre.ca** — marketing site (React / Vite / Tailwind) | -| `apps/dispatch/` | Legacy dispatch app (replaced by `apps/ops/` dispatch module) | -| `apps/portal/` | Customer portal deploy configs | -| `erpnext/` | ERPNext custom doctype setup scripts | -| `scripts/migration/` | 51 Python scripts for legacy-to-ERPNext data migration | -| `scripts/` | Utility scripts (bulk submit, PostgreSQL fixes) | -| `docs/` | Architecture, infrastructure, migration plan, changelog | +``` +gigafibre-fsm/ + apps/ + ops/ Targo Ops -- main operations PWA (Vue 3 / Quasar v2) + field/ Targo Field -- mobile app for technicians + client/ Gigafibre Portal -- customer self-service + website/ www.gigafibre.ca -- marketing site (React / Vite / Tailwind) + portal/ Customer portal deploy configs + services/ + targo-hub/ Node.js API gateway (ERPNext, GenieACS, Twilio, Traccar) + modem-bridge/ SNMP/TR-069 bridge for CPE diagnostics + legacy-db/ Legacy MariaDB read-only access + docuseal/ Document signing service + erpnext/ Custom doctype setup scripts (setup_fsm_doctypes.py) + scripts/ + migration/ 51 Python scripts for legacy-to-ERPNext data migration + bulk_submit.py, fix_ple_*.py/sh -- PostgreSQL patches, bulk ops + docs/ Architecture, infrastructure, migration, strategy docs + docker/ Docker compose fragments + patches/ ERPNext patches +``` ## Architecture ``` -96.125.196.67 (Proxmox VM, Ubuntu 24.04) - | - Traefik v2.11 (TLS via Let's Encrypt) - | - +-- erp.gigafibre.ca ERPNext v16.10.1 (PostgreSQL, 9 containers) - +-- erp.gigafibre.ca/ops/ Ops PWA (Quasar/Vue3, Authentik SSO) - +-- id.gigafibre.ca Authentik SSO (customer-facing) - +-- auth.targo.ca Authentik SSO (staff, federated to id.gigafibre.ca) - +-- n8n.gigafibre.ca n8n workflow automation - +-- git.targo.ca Gitea - +-- www.gigafibre.ca Marketing site + address API - +-- oss.gigafibre.ca Oktopus CE (TR-069 CPE management) - +-- tracker.targointernet.com Traccar GPS tracking + Internet + | + 96.125.196.67 (Proxmox VM, Ubuntu 24.04, Docker) + | + Traefik v2.11 (TLS via Let's Encrypt) + | + +----------+----------+----------+----------+----------+ + | | | | | | +ERPNext Ops PWA Authentik n8n Website Oktopus +erp. erp. auth. n8n. www. oss. +gigafibre gigafibre targo.ca giga giga giga +.ca .ca/ops/ fibre fibre fibre + .ca .ca .ca + | + targo-hub (API gateway) + | + +----------+----------+----------+ + | | | | +GenieACS Twilio Traccar modem-bridge +(TR-069) (SMS) (GPS) (SNMP/TR-069) ``` -## Features +## Services & Dependencies -### Ops App (`apps/ops/`) - -- **Client Management** — customer list with search by name, account ID, legacy ID. Inline editing (double-click any field to edit, saves to ERPNext in background) -- **Client Detail** — full customer view with contact, billing KPIs, service locations, subscriptions, equipment, tickets, invoices, payments, notes -- **Inline Editing** — Odoo-style double-click-to-edit on all fields (locations, equipment, tickets, invoices). Uses `InlineField` component + `useInlineEdit` composable -- **Dispatch Timeline** — drag-drop job scheduling with Mapbox map, GPS tracking (Traccar), technician management, tag/skill system -- **Ticket Management** — list with inline status/priority editing, detail modal with reply thread -- **Equipment Tracking** — serial/MAC, status lifecycle, OLT info, per-location grouping -- **SMS/Email Notifications** — send from contact card via n8n webhooks (Twilio SMS, Mailjet email) -- **Invoice OCR** — scan paper bills using Ollama Vision (llama3.2-vision) -- **PWA** — installable, offline-capable with Workbox - -### Legacy Migration (completed) - -Migrated from a 15-year-old PHP/MariaDB billing system: - -| Data | Volume | Status | -|------|--------|--------| -| Customers | 6,667 (active + terminated) | Migrated | -| Contacts + Addresses | ~6,600 each | Migrated | -| Service Locations | ~17,000 | Migrated | -| Subscriptions | 21,876 (with RADIUS credentials) | Migrated | -| Items (products/plans) | 833 | Migrated | -| Sales Invoices | 115,000+ | Migrated | -| Payments | 99,000+ | Migrated with invoice references | -| Tickets (Issues) | 242,000+ (parent/child hierarchy) | Migrated | -| Ticket Messages (Communications) | 784,000+ | Migrated | -| Customer Memos (Comments) | 29,000+ | Migrated with real dates | -| Employees | 45 ERPNext Users from legacy staff | Migrated | - -### Bugs Fixed From Legacy System - -- **Date corruption** — Unix timestamps stored as strings; fixed during import with timezone-aware conversion -- **Invoice outstanding amounts** — payment_item references broken in legacy; reconciled during migration -- **Customer links** — orphan records (invoices/payments without valid customer); rebuilt relationships -- **Subscription details** — missing service_location links, incorrect billing frequencies; corrected via analysis scripts -- **Reversal transactions** — credit notes improperly recorded; mapped to ERPNext return invoices -- **Annual billing dates** — yearly subscriptions had wrong period boundaries; recalculated -- **Duplicate customer IDs** — legacy allowed duplicate customer_id; resolved with rename script -- **Staff/ticket ownership** — legacy used numeric IDs for assignment; mapped to ERPNext User emails - -### ERPNext Adjustments for Import - -- **Direct PostgreSQL inserts** — Frappe ORM too slow for 100K+ records; used psycopg2 with proper `tabXxx` schema -- **Scheduler paused** — disabled auto-invoicing during import to prevent 21K subscriptions from generating invoices -- **Custom fields on Customer** — `legacy_account_id`, `legacy_customer_id`, `ppa_enabled`, `stripe_id`, date fields -- **Custom fields on Item** — `legacy_product_id`, download/upload speeds, quotas, OLT profiles -- **Custom fields on Subscription** — `radius_user`, `radius_pwd`, `legacy_service_id` -- **Custom fields on Issue** — `legacy_ticket_id`, `assigned_staff`, `opened_by_staff`, `issue_type`, `is_important`, `service_location` -- **PostgreSQL GROUP BY patch** — ERPNext v16 generates MySQL-style queries; patched `number_card.py` and PLE reports -- **Subscription API unlock** — removed `read_only` and `set_only_once` restrictions on Subscription fields for REST API updates -- **Portal auth bridge** — server script for legacy MD5 password migration to PBKDF2 +| Service | URL | Port | Stack | Purpose | +|---------|-----|------|-------|---------| +| ERPNext | erp.gigafibre.ca | 8080 | Frappe v16, PostgreSQL | ERP backend, API | +| Ops PWA | erp.gigafibre.ca/ops/ | 80 | Vue 3, Quasar, Pinia | Staff operations app | +| targo-hub | internal | 3100 | Node.js, Express | API gateway to external services | +| modem-bridge | internal | 3200 | Node.js | SNMP/TR-069 CPE diagnostics | +| Authentik | auth.targo.ca / id.gigafibre.ca | 9000 | Python, PostgreSQL | SSO (staff + customers) | +| n8n | n8n.gigafibre.ca | 5678 | Node.js | Workflow automation (SMS, email) | +| Traefik | -- | 80/443 | Go | Reverse proxy, TLS, forwardAuth | +| Oktopus | oss.gigafibre.ca | 8428 | Go | TR-069 CPE management | +| Website | www.gigafibre.ca | 80 | React, Vite, Tailwind | Marketing site + address API | +| Traccar | tracker.targointernet.com | 8082 | Java | GPS tracking for techs | ## ERPNext Custom Doctypes -### Field Service Management - | Doctype | ID Pattern | Purpose | |---------|-----------|---------| | Service Location | LOC-##### | Customer premises (address, GPS, OLT port, network config) | -| Service Equipment | EQP-##### | Deployed hardware (ONT, router, TV box — serial, MAC, IP) | -| Service Subscription | SUB-##### | Active service plans (speed, price, billing cycle, RADIUS) | +| Service Equipment | EQP-##### | Deployed hardware (ONT, router, TV box -- serial, MAC, IP) | +| Service Subscription | SUB-##### | Active service plans (speed, price, billing, RADIUS) | +| Dispatch Job | DJ-##### | Work orders with equipment, materials, checklist, photos, signature | +| Dispatch Technician | DT-##### | Tech profiles with GPS (Traccar), skills, color coding | +| Dispatch Tag | -- | Skill/service/region tags with levels (Fibre, TV, Telephonie, etc.) | -### Dispatch +## Key Custom Fields -| Doctype | Purpose | -|---------|---------| -| Dispatch Job | Work orders with equipment, materials, checklist, photos, signature | -| Dispatch Technician | Tech profiles with GPS link (Traccar), skills, color coding | -| Dispatch Tag | Categorization with skill levels (Fibre, TV, Telephonie, etc.) | -| Dispatch Tag Link | Child table linking tags to jobs/techs with level + required flag | +| Doctype | Custom Fields | +|---------|---------------| +| Customer | `legacy_account_id`, `legacy_customer_id`, `ppa_enabled`, `stripe_id` | +| Item | `legacy_product_id`, `download_speed`, `upload_speed`, `olt_profile` | +| Subscription | `radius_user`, `radius_pwd`, `legacy_service_id` | +| Issue | `legacy_ticket_id`, `assigned_staff`, `issue_type`, `is_important`, `service_location` | -### Child Tables +## Tech Stack -Equipment Move Log, Job Equipment Item, Job Material Used, Job Checklist Item, Job Photo, Dispatch Job Assistant, Checklist Template + Items +**Frontend:** Vue 3, Quasar v2, Pinia, Vite, Mapbox GL JS +**Backend:** ERPNext v16 / Frappe (Python), PostgreSQL, Node.js (targo-hub) +**Infra:** Docker, Traefik v2.11, Authentik SSO, Proxmox +**Integrations:** Twilio (SMS), Mailjet (email), Stripe (payments), Traccar (GPS), GenieACS (TR-069), Ollama (OCR) -## Quick Start +## Data Volumes (migrated from legacy) -### Development +| Entity | Volume | +|--------|--------| +| Customers | 6,667 (active + terminated) | +| Subscriptions | 21,876 (with RADIUS credentials) | +| Sales Invoices | 115,000+ | +| Payments | 99,000+ (with invoice references) | +| Tickets (Issues) | 242,000+ (parent/child hierarchy) | +| Ticket Messages | 784,000+ | +| Devices | 7,600+ (ONT, router, TV box) | +| Service Locations | ~17,000 | + +## Development ```bash # Ops app -cd apps/ops -npm install -npx quasar dev +cd apps/ops && npm install && npx quasar dev # Website -cd apps/website -npm install -npm run dev +cd apps/website && npm install && npm run dev + +# targo-hub +cd services/targo-hub && npm install && npm run dev + +# Deploy ops to production +cd apps/ops && bash deploy.sh ``` -### Deploy Ops to Production +## Auth Pattern -```bash -cd apps/ops -bash deploy.sh # builds PWA + deploys to server via SSH -``` - -### Run Migration Scripts - -```bash -# Scripts run inside the ERPNext backend container -docker cp scripts/migration/migrate_all.py frappe_docker-backend-1:/tmp/ -docker exec frappe_docker-backend-1 bench --site erp.gigafibre.ca execute /tmp/migrate_all.main -``` - -### Setup FSM Doctypes - -```bash -docker cp erpnext/setup_fsm_doctypes.py frappe_docker-backend-1:/home/frappe/frappe-bench/apps/frappe/frappe/ -docker exec frappe_docker-backend-1 bench --site erp.gigafibre.ca execute frappe.setup_fsm_doctypes.create_all -``` +Authentik SSO protects staff apps via Traefik `forwardAuth`. The ops app reads `X-Authentik-Email` from the proxied request header. All ERPNext API calls from targo-hub and the ops nginx proxy use `Authorization: token ` (Bearer token from server `.env`). Customer-facing SSO is at id.gigafibre.ca, federated from auth.targo.ca. ## Documentation @@ -157,42 +132,13 @@ docker exec frappe_docker-backend-1 bench --site erp.gigafibre.ca execute frappe |----------|---------| | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Data model, tech stack, authentication flow, doctype reference | | [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) | Server, DNS, Traefik, Authentik, Docker, n8n, gotchas | -| [MIGRATION-PLAN.md](docs/MIGRATION-PLAN.md) | Legacy system portrait, mapping, phases, risks | +| [MIGRATION-PLAN.md](docs/MIGRATION-PLAN.md) | Legacy system portrait, field mapping, phases, risks | | [CHANGELOG.md](docs/CHANGELOG.md) | Detailed migration log with volumes and methods | | [ROADMAP.md](docs/ROADMAP.md) | 5-phase implementation plan | -| [MIGRATION_MAP.md](scripts/migration/MIGRATION_MAP.md) | Field-level mapping legacy tables to ERPNext | +| [ECOSYSTEM-OVERVIEW.md](docs/ECOSYSTEM-OVERVIEW.md) | Full platform ecosystem and integration map | +| [PLATFORM-STRATEGY.md](docs/PLATFORM-STRATEGY.md) | Platform strategy and product direction | +| [CUSTOMER-360-FLOWS.md](docs/CUSTOMER-360-FLOWS.md) | Customer lifecycle flows and 360 view design | +| [DESIGN_GUIDELINES.md](docs/DESIGN_GUIDELINES.md) | UI/UX design guidelines for ops apps | | [COMPETITIVE-ANALYSIS.md](docs/COMPETITIVE-ANALYSIS.md) | Comparison with Gaiia, Odoo, Zuper, Salesforce, ServiceTitan | - -## Infrastructure - -| Service | URL | Technology | -|---------|-----|------------| -| ERP | erp.gigafibre.ca | ERPNext v16.10.1 (Frappe, PostgreSQL) | -| Ops | erp.gigafibre.ca/ops/ | Quasar v2 PWA | -| SSO (staff) | auth.targo.ca | Authentik | -| SSO (customers) | id.gigafibre.ca | Authentik (federated from auth.targo.ca) | -| Workflows | n8n.gigafibre.ca | n8n | -| Git | git.targo.ca | Gitea | -| GPS | tracker.targointernet.com | Traccar | -| Website | www.gigafibre.ca | React / Vite / Tailwind | -| CPE Mgmt | oss.gigafibre.ca | Oktopus CE (TR-069) | -| DNS | Cloudflare | gigafibre.ca (DNS-only, Traefik handles TLS) | -| Email | Mailjet | noreply@targo.ca | -| SMS | Twilio | +1 438 231-3838 | - -## Tech Stack - -| Layer | Technology | -|-------|-----------| -| Backend | ERPNext v16 / Frappe (Python) on PostgreSQL | -| Frontend (ops) | Vue 3, Quasar v2, Pinia, Vite | -| Frontend (website) | React, Vite, Tailwind, shadcn/ui | -| Maps | Mapbox GL JS + Directions API | -| GPS | Traccar (REST + WebSocket) | -| Auth | Authentik SSO (forwardAuth via Traefik) | -| Proxy | Traefik v2.11 (Let's Encrypt) | -| Automation | n8n webhooks | -| SMS | Twilio (via n8n) | -| Email | Mailjet (via n8n) | -| OCR | Ollama (llama3.2-vision) | -| Hosting | Proxmox VM, Ubuntu 24.04, Docker | +| [TR069-TO-TR369-MIGRATION.md](docs/TR069-TO-TR369-MIGRATION.md) | CPE management protocol migration plan | +| [scripts/migration/MIGRATION_MAP.md](scripts/migration/MIGRATION_MAP.md) | Field-level mapping from legacy tables to ERPNext | diff --git a/apps/client/src/api/payments.js b/apps/client/src/api/payments.js new file mode 100644 index 0000000..3d8356b --- /dev/null +++ b/apps/client/src/api/payments.js @@ -0,0 +1,49 @@ +/** + * Payment API — talks to targo-hub /payments/* endpoints + */ +const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' + +async function hubGet (path) { + const r = await fetch(HUB + path) + if (!r.ok) { + const data = await r.json().catch(() => ({})) + throw new Error(data.error || `Hub ${r.status}`) + } + return r.json() +} + +async function hubPost (path, body) { + const r = await fetch(HUB + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(data.error || `Hub ${r.status}`) + return data +} + +/** Get customer balance + unpaid invoices */ +export const getBalance = (customer) => hubGet(`/payments/balance/${encodeURIComponent(customer)}`) + +/** Get saved payment methods for customer */ +export const getPaymentMethods = (customer) => hubGet(`/payments/methods/${encodeURIComponent(customer)}`) + +/** Get invoice payment info */ +export const getInvoicePaymentInfo = (invoice) => hubGet(`/payments/invoice/${encodeURIComponent(invoice)}`) + +/** Create checkout session for full balance */ +export const checkoutBalance = (customer) => hubPost('/payments/checkout', { customer }) + +/** Create checkout session for specific invoice */ +export const checkoutInvoice = (customer, invoice, { save_card = true, payment_method = 'card' } = {}) => + hubPost('/payments/checkout-invoice', { customer, invoice, save_card, payment_method }) + +/** Save card (setup mode) */ +export const setupCard = (customer) => hubPost('/payments/setup', { customer }) + +/** Open Stripe billing portal */ +export const openBillingPortal = (customer) => hubPost('/payments/portal', { customer }) + +/** Toggle auto-pay (PPA) */ +export const togglePPA = (customer, enabled) => hubPost('/payments/toggle-ppa', { customer, enabled }) diff --git a/apps/client/src/api/portal.js b/apps/client/src/api/portal.js index 3108453..4cef508 100644 --- a/apps/client/src/api/portal.js +++ b/apps/client/src/api/portal.js @@ -119,6 +119,55 @@ export async function fetchAddresses (customer) { return data.data || [] } +/** + * Fetch Service Locations with their active Service Subscriptions. + * Returns locations grouped with subscriptions and monthly totals. + */ +export async function fetchServiceLocations (customer) { + // Fetch locations + const locFields = JSON.stringify([ + 'name', 'location_name', 'address_line', 'city', 'postal_code', + 'province', 'status', 'connection_type', + ]) + const locFilters = JSON.stringify([['customer', '=', customer]]) + const locPath = `/api/resource/Service Location?filters=${encodeURIComponent(locFilters)}&fields=${encodeURIComponent(locFields)}&order_by=creation asc&limit_page_length=50` + const locData = await apiGet(locPath) + const locations = locData.data || [] + + // Fetch subscriptions + const subFields = JSON.stringify([ + 'name', 'service_location', 'status', 'service_category', + 'plan_name', 'monthly_price', 'speed_down', 'speed_up', + 'billing_cycle', 'start_date', 'end_date', 'promo_end', + ]) + const subFilters = JSON.stringify([ + ['customer', '=', customer], + ['status', '=', 'Actif'], + ]) + const subPath = `/api/resource/Service Subscription?filters=${encodeURIComponent(subFilters)}&fields=${encodeURIComponent(subFields)}&order_by=service_category asc, monthly_price desc&limit_page_length=200` + const subData = await apiGet(subPath) + const subscriptions = subData.data || [] + + // Group subscriptions by location + const subsByLoc = {} + for (const sub of subscriptions) { + const loc = sub.service_location || '_unassigned' + if (!subsByLoc[loc]) subsByLoc[loc] = [] + subsByLoc[loc].push(sub) + } + + // Attach subscriptions to locations and compute totals + for (const loc of locations) { + loc.subscriptions = subsByLoc[loc.name] || [] + loc.monthly_total = loc.subscriptions.reduce((sum, s) => sum + (s.monthly_price || 0), 0) + } + + // Grand total across all locations + const grandTotal = locations.reduce((sum, loc) => sum + loc.monthly_total, 0) + + return { locations, grandTotal, subscriptionCount: subscriptions.length } +} + /** * Fetch a single Sales Invoice with line items. */ diff --git a/apps/client/src/composables/useMagicToken.js b/apps/client/src/composables/useMagicToken.js new file mode 100644 index 0000000..f67c518 --- /dev/null +++ b/apps/client/src/composables/useMagicToken.js @@ -0,0 +1,52 @@ +/** + * Magic link token handling for payment return pages. + * Reads ?token=JWT from the URL, decodes it, hydrates the customer store. + * If token is expired/invalid, returns { expired: true } so the page can show a fallback. + */ +import { useRoute } from 'vue-router' +import { useCustomerStore } from 'src/stores/customer' + +// Minimal JWT decode (no verification — server already signed it) +function decodeJwt (token) { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) + // Check expiry + if (payload.exp && Date.now() / 1000 > payload.exp) { + return { ...payload, _expired: true } + } + return payload + } catch { + return null + } +} + +export function useMagicToken () { + const route = useRoute() + const store = useCustomerStore() + + const token = route.query.token || '' + let authenticated = false + let expired = false + + if (token) { + const payload = decodeJwt(token) + if (payload && payload.scope === 'customer' && !payload._expired) { + // Hydrate customer store from magic link + store.customerId = payload.sub + store.customerName = payload.name || payload.sub + store.email = payload.email || '' + store.loading = false + store.error = null + authenticated = true + } else if (payload?._expired) { + expired = true + } + } else if (store.customerId) { + // Already logged in via Authentik SSO + authenticated = true + } + + return { authenticated, expired, customerId: store.customerId } +} diff --git a/apps/client/src/css/app.scss b/apps/client/src/css/app.scss index d48c83d..c3ec6e1 100644 --- a/apps/client/src/css/app.scss +++ b/apps/client/src/css/app.scss @@ -64,6 +64,33 @@ body { margin-bottom: 16px; } +// Monthly total banner (service locations) +.monthly-total-banner { + background: linear-gradient(135deg, #f0f9ff, #e0f2fe); + border: 1px solid #bae6fd; + border-radius: 10px; + padding: 14px 18px; +} + +// Location cards +.location-card { + background: var(--gf-bg); + border: 1px solid var(--gf-border); + border-radius: 10px; + padding: 14px; +} + +.subscription-row { + padding: 6px 0; + border-bottom: 1px dashed var(--gf-border); + &:last-child { border-bottom: none; } +} + +.location-subtotal { + padding-top: 6px; + border-top: 1px solid var(--gf-border); +} + // Clickable table rows .clickable-table { .q-table tbody tr { diff --git a/apps/client/src/data/catalog.js b/apps/client/src/data/catalog.js index ddcbd15..2fe88ba 100644 --- a/apps/client/src/data/catalog.js +++ b/apps/client/src/data/catalog.js @@ -11,6 +11,7 @@ export const CATALOG = [ { item_code: 'EQ-ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', rate: 149.99, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Routeur haute performance, couverture optimale' }, { item_code: 'EQ-MESH-NODE', item_name: 'Noeud Wi-Fi Mesh', rate: 99.99, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Étend la couverture Wi-Fi dans les grandes maisons' }, { item_code: 'EQ-ONT', item_name: 'Terminal fibre optique (ONT)', rate: 0, billing_type: 'Unique', service_category: 'Équipement', requires_visit: true, project_template_id: 'fiber_install', description: 'Inclus avec tout abonnement Internet fibre' }, + { item_code: 'SVC-TEST', item_name: 'Frais de test', rate: 1, billing_type: 'Unique', service_category: 'Équipement', requires_visit: false, description: 'Produit de test — 1$ frais unique' }, ] export const CATEGORY_COLORS = { Internet: 'indigo', 'Téléphonie': 'teal', Bundle: 'purple', 'Équipement': 'blue-grey' } diff --git a/apps/client/src/layouts/PortalLayout.vue b/apps/client/src/layouts/PortalLayout.vue index d5bc820..904c869 100644 --- a/apps/client/src/layouts/PortalLayout.vue +++ b/apps/client/src/layouts/PortalLayout.vue @@ -67,7 +67,7 @@ const cartStore = useCartStore() const drawer = ref(true) // Pages that work without authentication -const publicRoutes = ['catalog', 'cart', 'order-success'] +const publicRoutes = ['catalog', 'cart', 'order-success', 'payment-success', 'payment-cancel', 'payment-card-added'] const guestMode = computed(() => store.error && !store.customerId) const requiresAuth = computed(() => !publicRoutes.includes(route.name)) diff --git a/apps/client/src/pages/AccountPage.vue b/apps/client/src/pages/AccountPage.vue index 792201b..e2b764d 100644 --- a/apps/client/src/pages/AccountPage.vue +++ b/apps/client/src/pages/AccountPage.vue @@ -16,26 +16,145 @@ - +
Adresses de service
-
Aucune adresse enregistrée
- - - - {{ addr.address_title || addr.address_line1 }} - - {{ addr.address_line1 }} - , {{ addr.address_line2 }} -
{{ addr.city }} {{ addr.state }} {{ addr.pincode }} -
-
- - - -
-
+ + +
+
+
+
Total mensuel estimé
+
+ {{ formatMoney(grandMonthlyTotal) }} /mois +
+
+
+ {{ serviceLocations.length }} adresses · {{ subscriptionCount }} services +
+
+
+ +
+ +
+
Aucune adresse enregistrée
+ + +
+
+ +
+ +
{{ loc.location_name || loc.address_line }}
+ + + +
+
+ {{ loc.address_line }}, {{ loc.city }} {{ loc.postal_code }} +
+ + +
+
+
+ +
+
{{ sub.plan_name }}
+
+ {{ sub.speed_down }} / {{ sub.speed_up }} Mbps +
+
+
+
+ {{ formatMoney(sub.monthly_price) }} +
+
/mois
+
+
+
+ + +
+
+ Sous-total: + {{ formatMoney(loc.monthly_total) }}/mois +
+
+
+
Aucun service actif
+
+
+
+
+ + +
+
+
+ +
Paiement
+ + + +
+ + +
+
+ Solde a payer: + + {{ formatMoney(balance) }} + + +
+
+ + +
+ +
+
+
Cartes enregistrees
+ + + + + + + {{ cardBrandLabel(card.brand) }} **** {{ card.last4 }} + Exp. {{ card.exp_month }}/{{ card.exp_year }} + + + + + + +
+
+ Aucune carte enregistree. Ajoutez une carte pour activer le paiement automatique. +
+ + +
+
+
+
Paiement automatique (PPA)
+
+ Vos factures seront payees automatiquement avec votre carte par defaut. +
+
+
+ +
+
+
@@ -43,21 +162,144 @@ diff --git a/apps/client/src/pages/DashboardPage.vue b/apps/client/src/pages/DashboardPage.vue index a00b932..973c44a 100644 --- a/apps/client/src/pages/DashboardPage.vue +++ b/apps/client/src/pages/DashboardPage.vue @@ -6,12 +6,16 @@
-
Factures impayées
+
Factures impayees
{{ unpaidCount }}
- {{ formatMoney(unpaidTotal) }} à payer + {{ formatMoney(unpaidTotal) }} a payer +
+
+ +
-
@@ -57,15 +61,19 @@ diff --git a/apps/client/src/pages/PaymentCardAddedPage.vue b/apps/client/src/pages/PaymentCardAddedPage.vue new file mode 100644 index 0000000..cec92d7 --- /dev/null +++ b/apps/client/src/pages/PaymentCardAddedPage.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/client/src/pages/PaymentSuccessPage.vue b/apps/client/src/pages/PaymentSuccessPage.vue new file mode 100644 index 0000000..344d1a6 --- /dev/null +++ b/apps/client/src/pages/PaymentSuccessPage.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/client/src/router/index.js b/apps/client/src/router/index.js index aad24ac..3a35423 100644 --- a/apps/client/src/router/index.js +++ b/apps/client/src/router/index.js @@ -15,6 +15,9 @@ const routes = [ { path: 'catalogue', name: 'catalog', component: () => import('pages/CatalogPage.vue') }, { path: 'panier', name: 'cart', component: () => import('pages/CartPage.vue') }, { path: 'commande/confirmation', name: 'order-success', component: () => import('pages/OrderSuccessPage.vue') }, + { path: 'paiement/merci', name: 'payment-success', component: () => import('pages/PaymentSuccessPage.vue') }, + { path: 'paiement/annule', name: 'payment-cancel', component: () => import('pages/PaymentCancelPage.vue') }, + { path: 'paiement/carte-ajoutee', name: 'payment-card-added', component: () => import('pages/PaymentCardAddedPage.vue') }, ], }, ] diff --git a/apps/ops/package-lock.json b/apps/ops/package-lock.json index acfd27b..e71ee7c 100644 --- a/apps/ops/package-lock.json +++ b/apps/ops/package-lock.json @@ -10,11 +10,14 @@ "dependencies": { "@quasar/extras": "^1.16.12", "@twilio/voice-sdk": "^2.18.1", + "chart.js": "^4.5.1", + "cytoscape": "^3.33.2", "lucide-vue-next": "^1.0.0", "pinia": "^2.1.7", "quasar": "^2.16.10", "sip.js": "^0.21.2", "vue": "^3.4.21", + "vue-chartjs": "^5.3.3", "vue-router": "^4.3.0", "vuedraggable": "^4.1.0" }, @@ -1931,6 +1934,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3700,6 +3709,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4082,6 +4103,15 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz", + "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -9405,6 +9435,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz", + "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", diff --git a/apps/ops/package.json b/apps/ops/package.json index dcb66a6..4ddf60a 100644 --- a/apps/ops/package.json +++ b/apps/ops/package.json @@ -12,11 +12,14 @@ "dependencies": { "@quasar/extras": "^1.16.12", "@twilio/voice-sdk": "^2.18.1", + "chart.js": "^4.5.1", + "cytoscape": "^3.33.2", "lucide-vue-next": "^1.0.0", "pinia": "^2.1.7", "quasar": "^2.16.10", "sip.js": "^0.21.2", "vue": "^3.4.21", + "vue-chartjs": "^5.3.3", "vue-router": "^4.3.0", "vuedraggable": "^4.1.0" }, diff --git a/apps/ops/src/api/erp.js b/apps/ops/src/api/erp.js index 14494c4..44003ea 100644 --- a/apps/ops/src/api/erp.js +++ b/apps/ops/src/api/erp.js @@ -12,7 +12,7 @@ export async function listDocs (doctype, { filters = {}, or_filters, fields = [' order_by: orderBy, }) if (or_filters) params.set('or_filters', JSON.stringify(or_filters)) - const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '?' + params) + const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '?' + params) if (!res.ok) throw new Error('API error: ' + res.status) const data = await res.json() return data.data || [] @@ -20,7 +20,7 @@ export async function listDocs (doctype, { filters = {}, or_filters, fields = [' // Get single document export async function getDoc (doctype, name) { - const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name)) + const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name)) if (!res.ok) throw new Error('Not found: ' + name) const data = await res.json() return data.data @@ -42,7 +42,7 @@ export async function searchDocs (doctype, text, { filters = {}, fields = ['name // Create a new document export async function createDoc (doctype, data) { - const res = await authFetch(BASE_URL + '/api/resource/' + doctype, { + const res = await authFetch(BASE_URL + '/api/resource/' + encodeURIComponent(doctype), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -57,7 +57,7 @@ export async function createDoc (doctype, data) { // Update a document (partial update) export async function updateDoc (doctype, name, data) { - const url = BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name) + const url = BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name) const res = await authFetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -70,9 +70,16 @@ export async function updateDoc (doctype, name, data) { // Delete a document export async function deleteDoc (doctype, name) { - const url = BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name) + const url = BASE_URL + '/api/resource/' + encodeURIComponent(doctype) + '/' + encodeURIComponent(name) const res = await authFetch(url, { method: 'DELETE' }) - if (!res.ok) throw new Error('Delete failed: ' + res.status) + if (!res.ok) { + let msg = 'Delete failed: ' + res.status + try { + const json = await res.json() + msg = json?.exception?.split('\n')[0] || json?._server_messages || json?.message || msg + } catch (_) {} + throw new Error(msg) + } return true } diff --git a/apps/ops/src/api/reports.js b/apps/ops/src/api/reports.js new file mode 100644 index 0000000..1536038 --- /dev/null +++ b/apps/ops/src/api/reports.js @@ -0,0 +1,51 @@ +/** + * Report API — calls targo-hub /reports/* endpoints + * (hub proxies to ERPNext GL Entry / Sales Invoice) + */ +import { HUB_URL as HUB } from 'src/config/hub' + +async function hubFetch (path) { + const res = await fetch(HUB + path) + if (!res.ok) throw new Error('Report API error: ' + res.status) + return res.json() +} + +/** + * Revenue report — GL entries grouped by Income account and month + * @param {string} start YYYY-MM-DD + * @param {string} end YYYY-MM-DD + */ +export function fetchRevenueReport (start, end, { mode = 'gl', filter = '' } = {}) { + let url = `/reports/revenue?start=${start}&end=${end}&mode=${mode}` + if (filter) url += `&filter=${encodeURIComponent(filter)}` + return hubFetch(url) +} + +/** + * Sales report — Sales Invoices with TPS/TVQ breakdown + */ +export function fetchSalesReport (start, end) { + return hubFetch(`/reports/sales?start=${start}&end=${end}`) +} + +/** + * Tax report — TPS/TVQ collected vs paid per period + * @param {string} period 'monthly' | 'quarterly' + */ +export function fetchTaxReport (start, end, period = 'monthly') { + return hubFetch(`/reports/taxes?start=${start}&end=${end}&period=${period}`) +} + +/** + * Accounts Receivable aging report + */ +export function fetchARReport (asOf) { + return hubFetch(`/reports/ar?as_of=${asOf}`) +} + +/** + * Fetch accounts list + */ +export function fetchAccounts (type = 'Income') { + return hubFetch(`/reports/accounts?type=${type}`) +} diff --git a/apps/ops/src/api/traccar.js b/apps/ops/src/api/traccar.js index c3e3f65..e157d5b 100644 --- a/apps/ops/src/api/traccar.js +++ b/apps/ops/src/api/traccar.js @@ -3,9 +3,7 @@ // No credentials in the frontend. // ───────────────────────────────────────────────────────────────────────────── -const HUB_URL = window.location.hostname === 'localhost' - ? 'http://localhost:3300' - : 'https://msg.gigafibre.ca' +import { HUB_URL } from 'src/config/hub' let _devices = [] diff --git a/apps/ops/src/components/dispatch/NlpInput.vue b/apps/ops/src/components/dispatch/NlpInput.vue new file mode 100644 index 0000000..6ed3a2f --- /dev/null +++ b/apps/ops/src/components/dispatch/NlpInput.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/apps/ops/src/components/shared/CreateInvoiceModal.vue b/apps/ops/src/components/shared/CreateInvoiceModal.vue new file mode 100644 index 0000000..eb0d7a5 --- /dev/null +++ b/apps/ops/src/components/shared/CreateInvoiceModal.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/apps/ops/src/components/shared/DetailModal.vue b/apps/ops/src/components/shared/DetailModal.vue index 1143f66..d606fa7 100644 --- a/apps/ops/src/components/shared/DetailModal.vue +++ b/apps/ops/src/components/shared/DetailModal.vue @@ -14,10 +14,6 @@ - - Voir PDF - @@ -41,6 +37,7 @@ @dispatch-deleted="(...a) => $emit('dispatch-deleted', ...a)" @dispatch-updated="(...a) => $emit('dispatch-updated', ...a)" @deleted="onDeleted" + @open-pdf="(...a) => $emit('open-pdf', ...a)" /> diff --git a/apps/ops/src/components/shared/OutageAlertsPanel.vue b/apps/ops/src/components/shared/OutageAlertsPanel.vue new file mode 100644 index 0000000..c7f0349 --- /dev/null +++ b/apps/ops/src/components/shared/OutageAlertsPanel.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue index 876178b..d30336d 100644 --- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue @@ -170,6 +170,151 @@ Chargement diagnostic ACS... + +
+
+ + Diagnostic WiFi avance + + {{ wifiDiag.durationMs }}ms +
+ +
+ Connexion au modem via Playwright (~15s)... +
+
{{ wifiDiagError }}
+ + +
+
Information OLT
+ + +
+
+ + Voisinage port + Panne secteur + Alerte + Normal +
+
+
+
+
+
+ {{ portCtx.online }} en ligne + {{ portCtx.offline }} + hors ligne + / {{ portCtx.total }} sur le port +
+
+
+ + {{ n.serial_prefix }} — {{ n.cause || 'inconnu' }} + {{ n.distance_m }}m +
+
+
Acces distant
Utilisateur @@ -213,15 +387,56 @@ import { ref, computed, onMounted, watch } from 'vue' import { useQuasar } from 'quasar' import InlineField from 'src/components/shared/InlineField.vue' import { useDeviceStatus } from 'src/composables/useDeviceStatus' +import { useWifiDiagnostic } from 'src/composables/useWifiDiagnostic' import { deleteDoc } from 'src/api/erp' const props = defineProps({ doc: { type: Object, required: true }, docName: String }) const emit = defineEmits(['deleted']) const $q = useQuasar() -const { fetchStatus, fetchOltStatus, getDevice, isOnline, combinedStatus, signalQuality, refreshDeviceParams, fetchHosts, loading: deviceLoading } = useDeviceStatus() +const { fetchStatus, fetchOltStatus, fetchPortContext, getDevice, isOnline, combinedStatus, signalQuality, refreshDeviceParams, fetchHosts, loading: deviceLoading } = useDeviceStatus() +const { fetchDiagnostic, loading: wifiDiagLoading, error: wifiDiagError, data: wifiDiag } = useWifiDiagnostic() const refreshing = ref(false) const deleting = ref(false) +const portCtx = ref(null) + +const managementIp = computed(() => { + const iface = device.value?.interfaces?.find(i => i.role === 'management') + return iface?.ip || null +}) + +function runWifiDiagnostic() { + if (!managementIp.value) return + $q.dialog({ + title: 'Mot de passe modem', + message: `Entrer le mot de passe superadmin pour ${managementIp.value}`, + prompt: { model: '', type: 'password', filled: true }, + cancel: { flat: true, label: 'Annuler' }, + ok: { label: 'Lancer', color: 'primary' }, + persistent: false, + }).onOk(pass => { + if (pass) fetchDiagnostic(managementIp.value, pass) + }) +} + +const wanRoleLabel = (role) => ROLE_LABELS[role] || role + +function maskToCidr(mask) { + if (!mask) return '?' + return mask.split('.').reduce((c, o) => c + (parseInt(o) >>> 0).toString(2).replace(/0/g, '').length, 0) +} + +function signalPercent(raw) { + if (!raw || raw <= 0) return 0 + return raw >= 0 ? Math.round((raw / 255) * 100) : Math.round(Math.max(0, Math.min(100, ((raw + 90) / 60) * 100))) +} + +function backhaulColor(signal) { + if (signal >= 100) return '#4ade80' + if (signal >= 80) return '#a3e635' + if (signal >= 60) return '#fbbf24' + return '#f87171' +} function confirmDelete () { $q.dialog({ @@ -247,7 +462,6 @@ const hosts = ref(null) const hostsLoading = ref(false) const expandedNodes = ref({}) -// Consolidated equipment fields for v-for const equipFields = [ { field: 'equipment_type', label: 'Type', props: { type: 'select', options: ['ONT', 'Router', 'Switch', 'AP', 'OLT', 'Décodeur', 'Modem', 'Autre'] } }, { field: 'status', label: 'Statut', props: { type: 'select', options: ['Active', 'Inactive', 'En stock', 'Défectueux', 'Retourné'] } }, @@ -265,7 +479,7 @@ const device = computed(() => props.doc.serial_number ? getDevice(props.doc.seri const online = computed(() => isOnline(props.doc.serial_number)) const status = computed(() => combinedStatus(props.doc.serial_number)) const statusBadgeColor = computed(() => { - if (status.value.source === 'unknown') return 'amber' // probing / loading + if (status.value.source === 'unknown') return 'amber' return status.value.online === true ? 'green' : status.value.online === false ? 'red' : 'grey' }) @@ -280,7 +494,7 @@ const sortedInterfaces = computed(() => { return [...device.value.interfaces].sort((a, b) => (order[a.role] ?? 9) - (order[b.role] ?? 9)) }) -const ROLE_LABELS = { internet: 'Internet', management: 'Gestion', service: 'Service', lan: 'LAN' } +const ROLE_LABELS = { internet: 'Internet', management: 'Gestion', service: 'Service', lan: 'LAN', unknown: '?' } const roleLabel = (role) => ROLE_LABELS[role] || role const wifiRadios = computed(() => { @@ -321,7 +535,6 @@ const groupedHosts = computed(() => { return result }) -// RSSI 0-255 or negative dBm -> color via hue interpolation const SIGNAL_STEPS = [ { pct: 0, color: 'hsl(0, 100%, 40%)' }, { pct: 10, color: 'hsl(6, 100%, 42%)' }, { pct: 20, color: 'hsl(12, 100%, 44%)' }, { pct: 30, color: 'hsl(20, 95%, 45%)' }, @@ -387,15 +600,16 @@ async function doRefresh () { refreshing.value = true try { await refreshDeviceParams(props.doc.serial_number) - fetchOltStatus(props.doc.serial_number) // refresh OLT in parallel + fetchOltStatus(props.doc.serial_number) setTimeout(() => { fetchStatus([{ serial_number: props.doc.serial_number }]); refreshing.value = false }, 3000) } catch { refreshing.value = false } } -onMounted(() => { +onMounted(async () => { if (props.doc.serial_number) { fetchStatus([{ serial_number: props.doc.serial_number }]) fetchOltStatus(props.doc.serial_number) + fetchPortContext(props.doc.serial_number).then(ctx => { portCtx.value = ctx }) } }) @@ -442,4 +656,31 @@ watch(() => props.doc.serial_number, sn => { .hosts-table th { text-align: left; font-size: 0.68rem; font-weight: 600; color: #9ca3af; padding: 3px 6px; border-bottom: 1px solid #f1f5f9; } .hosts-table td { padding: 3px 6px; border-bottom: 1px solid #f1f5f9; } .hosts-table code { font-size: 0.72rem; } +.port-ctx-bar { height: 6px; border-radius: 3px; background: #f1f5f9; display: flex; overflow: hidden; } +.port-ctx-fill--online { background: #4ade80; } +.port-ctx-fill--offline { background: #f87171; } +.port-ctx-legend { display: flex; align-items: center; } +.port-ctx-neighbors { display: flex; flex-direction: column; gap: 2px; } +.port-ctx-neighbor { padding: 2px 6px; background: #fef2f2; border-radius: 4px; border: 1px solid #fecaca; } +/* Advanced diagnostic */ +.adv-issues { display: flex; flex-direction: column; gap: 6px; } +.adv-issue { padding: 8px 10px; border-radius: 6px; font-size: 0.82rem; } +.adv-issue--critical { background: #fef2f2; border: 1px solid #fecaca; } +.adv-issue--warning { background: #fffbeb; border: 1px solid #fde68a; } +.adv-issue--info { background: #eff6ff; border: 1px solid #bfdbfe; } +.adv-issue-header { display: flex; align-items: center; gap: 6px; } +.adv-issue--critical .adv-issue-header { color: #991b1b; } +.adv-issue--warning .adv-issue-header { color: #92400e; } +.adv-issue--info .adv-issue-header { color: #1e40af; } +.adv-issue-detail { font-size: 0.76rem; color: #6b7280; margin-top: 2px; padding-left: 22px; } +.adv-issue-action { font-size: 0.76rem; color: #4b5563; margin-top: 3px; padding-left: 22px; font-style: italic; } +.adv-ok { padding: 8px 10px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; font-size: 0.82rem; color: #166534; display: flex; align-items: center; } +.adv-mesh-nodes { display: flex; flex-direction: column; gap: 6px; } +.adv-mesh-node { padding: 8px 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; } +.adv-mesh-header { display: flex; align-items: center; font-size: 0.84rem; } +.adv-mesh-stats { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 4px; font-size: 0.78rem; } +.adv-mesh-stat { display: flex; align-items: baseline; gap: 4px; } +.adv-signal-bar { width: 40px; height: 6px; background: #e5e7eb; border-radius: 3px; display: inline-block; vertical-align: middle; margin-right: 4px; overflow: hidden; } +.adv-signal-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } +.adv-clients-table td:first-child { white-space: nowrap; } diff --git a/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue b/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue index ca55ff7..67c0b1d 100644 --- a/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/InvoiceDetail.vue @@ -1,8 +1,45 @@ diff --git a/apps/ops/src/composables/useClientData.js b/apps/ops/src/composables/useClientData.js index 4460162..f996e10 100644 --- a/apps/ops/src/composables/useClientData.js +++ b/apps/ops/src/composables/useClientData.js @@ -2,6 +2,7 @@ import { ref } from 'vue' import { listDocs, getDoc } from 'src/api/erp' import { authFetch } from 'src/api/auth' import { BASE_URL } from 'src/config/erpnext' +import { HUB_URL as _HUB_URL } from 'src/config/hub' export function useClientData (deps) { const { equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll, fetchStatus, fetchOltStatus } = deps @@ -14,6 +15,10 @@ export function useClientData (deps) { const tickets = ref([]) const invoices = ref([]) const payments = ref([]) + const voipLines = ref([]) + const paymentMethods = ref([]) + const arrangements = ref([]) + const quotations = ref([]) const comments = ref([]) const accountBalance = ref(null) @@ -30,6 +35,10 @@ export function useClientData (deps) { tickets.value = [] invoices.value = [] payments.value = [] + voipLines.value = [] + paymentMethods.value = [] + arrangements.value = [] + quotations.value = [] comments.value = [] contact.value = null modalOpen.value = false @@ -48,25 +57,84 @@ export function useClientData (deps) { }) } - function loadSubscriptions (custFilter) { - return listDocs('Service Subscription', { - filters: custFilter, - fields: ['name', 'status', 'start_date', 'end_date', 'service_location', - 'monthly_price', 'plan_name', 'service_category', 'billing_cycle', - 'speed_down', 'speed_up', 'cancellation_date', 'cancellation_reason', 'notes'], - limit: 200, orderBy: 'start_date desc', - }).then(subs => subs.map(s => ({ - ...s, - actual_price: s.monthly_price, - custom_description: s.plan_name, - item_name: s.plan_name, - item_code: s.name, - item_group: s.service_category || '', - billing_frequency: s.billing_cycle === 'Annuel' ? 'A' : 'M', - cancel_at_period_end: 0, - cancelation_date: s.cancellation_date, - status: ({ Actif: 'Active', Annulé: 'Cancelled', Suspendu: 'Cancelled', 'En attente': 'Active' })[s.status] || s.status, - }))) + async function loadSubscriptions (custFilter) { + // Load native ERPNext Subscriptions and flatten plan detail rows into UI-compatible format + const subs = await listDocs('Subscription', { + filters: { party_type: 'Customer', party: custFilter.customer }, + fields: ['name', 'status', 'start_date', 'end_date', 'cancelation_date', + 'cancel_at_period_end', 'current_invoice_start', 'current_invoice_end', + 'additional_discount_amount'], + limit: 50, orderBy: 'start_date desc', + }) + + // Fetch full docs to get plans child table with custom fields + const fullDocs = await Promise.all(subs.map(s => getDoc('Subscription', s.name).catch(() => null))) + + // Collect unique plan names to resolve item info in one batch + const planNames = new Set() + for (const doc of fullDocs) { + if (!doc) continue + for (const p of (doc.plans || [])) { if (p.plan) planNames.add(p.plan) } + } + + // Fetch Subscription Plan docs for item_code, cost, item_group + const planDocs = {} + if (planNames.size) { + const plans = await listDocs('Subscription Plan', { + filters: { name: ['in', [...planNames]] }, + fields: ['name', 'item', 'cost', 'plan_name'], + limit: planNames.size, + }) + // Resolve Item info for each plan + const itemCodes = [...new Set(plans.map(p => p.item).filter(Boolean))] + const itemMap = {} + if (itemCodes.length) { + const items = await listDocs('Item', { + filters: { name: ['in', itemCodes] }, + fields: ['name', 'item_name', 'item_group'], + limit: itemCodes.length, + }) + for (const it of items) itemMap[it.name] = it + } + for (const p of plans) { + const item = itemMap[p.item] || {} + planDocs[p.name] = { item_code: p.item, cost: p.cost, item_name: item.item_name || p.plan_name, item_group: item.item_group || '' } + } + } + + const rows = [] + for (const doc of fullDocs) { + if (!doc) continue + + for (const plan of (doc.plans || [])) { + const pd = planDocs[plan.plan] || {} + rows.push({ + name: plan.name, // child table row name (unique key) + subscription: doc.name, // parent Subscription + plan_name: plan.plan || '', + item_code: pd.item_code || plan.plan || '', + item_name: plan.custom_description || pd.item_name || plan.plan || '', + item_group: pd.item_group || '', + custom_description: plan.custom_description || '', + actual_price: plan.actual_price || pd.cost || 0, + service_location: plan.service_location || '', + billing_frequency: doc.cancel_at_period_end ? 'A' : 'M', + status: doc.status === 'Active' ? 'Active' : doc.status === 'Cancelled' ? 'Cancelled' : doc.status, + start_date: doc.start_date, + end_date: doc.end_date, + cancel_at_period_end: doc.cancel_at_period_end || 0, + cancelation_date: doc.cancelation_date, + current_invoice_start: doc.current_invoice_start, + current_invoice_end: doc.current_invoice_end, + radius_user: plan.radius_user || '', + radius_pwd: plan.radius_pwd || '', + device: plan.device || '', + qty: plan.qty || 1, + }) + } + } + + return rows } function loadEquipment (custFilter) { @@ -98,11 +166,44 @@ export function useClientData (deps) { function loadPayments (id) { return listDocs('Payment Entry', { filters: { party_type: 'Customer', party: id }, - fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'], - limit: 5, orderBy: 'posting_date desc', + fields: ['name', 'posting_date', 'creation', 'paid_amount', 'mode_of_payment', 'reference_no'], + limit: 5, orderBy: 'creation desc', }) } + function loadVoipLines (custFilter) { + return listDocs('VoIP Line', { + filters: custFilter, + fields: ['name', 'did', 'status', 'service_location', 'sip_user', 'sip_host', + 'e911_civic_number', 'e911_street_name', 'e911_municipality', 'e911_synced', + 'ata_model', 'ata_mac'], + limit: 50, orderBy: 'status asc, did asc', + }).catch(() => []) + } + + function loadPaymentMethods (custId) { + // Fetch via targo-hub which enriches with live Stripe card data + return fetch(`${_HUB_URL}/payments/methods/${encodeURIComponent(custId)}`, { + headers: { 'Content-Type': 'application/json' }, + }).then(r => r.json()).then(d => d.methods || []).catch(() => []) + } + + function loadArrangements (custFilter) { + return listDocs('Payment Arrangement', { + filters: custFilter, + fields: ['name', 'status', 'total_amount', 'payment_method', 'date_agreed', 'date_due', 'date_cutoff', 'staff', 'note'], + limit: 50, orderBy: 'date_agreed desc', + }).catch(() => []) + } + + function loadQuotations (id) { + return listDocs('Quotation', { + filters: { party_name: id, custom_legacy_soumission_id: ['>', 0] }, + fields: ['name', 'transaction_date', 'grand_total', 'status', 'custom_legacy_soumission_id', 'custom_po_number'], + limit: 50, orderBy: 'transaction_date desc', + }).catch(() => []) + } + function loadComments (id) { return listDocs('Comment', { filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' }, @@ -119,7 +220,7 @@ export function useClientData (deps) { resetState() try { const custFilter = { customer: id } - const [cust, locs, subs, equip, tix, invs, pays, memos, balRes] = await Promise.all([ + const [cust, locs, subs, equip, tix, invs, pays, voip, pmethods, arrgs, quots, memos, balRes] = await Promise.all([ getDoc('Customer', id), loadLocations(custFilter), loadSubscriptions(custFilter), @@ -127,6 +228,10 @@ export function useClientData (deps) { loadTickets(custFilter), loadInvoices(custFilter), loadPayments(id), + loadVoipLines(custFilter), + loadPaymentMethods(id), + loadArrangements(custFilter), + loadQuotations(id), loadComments(id), loadBalance(id), ]) @@ -146,6 +251,10 @@ export function useClientData (deps) { tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || '')) invoices.value = invs payments.value = pays + voipLines.value = voip + paymentMethods.value = pmethods + arrangements.value = arrgs + quotations.value = quots contact.value = null comments.value = memos @@ -193,8 +302,8 @@ export function useClientData (deps) { try { payments.value = await listDocs('Payment Entry', { filters: { party_type: 'Customer', party: customer.value.name }, - fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'], - limit: 200, orderBy: 'posting_date desc', + fields: ['name', 'posting_date', 'creation', 'paid_amount', 'mode_of_payment', 'reference_no'], + limit: 200, orderBy: 'creation desc', }) paymentsExpanded.value = true } catch {} @@ -203,7 +312,8 @@ export function useClientData (deps) { return { loading, customer, contact, locations, subscriptions, tickets, - invoices, payments, comments, accountBalance, + invoices, payments, voipLines, paymentMethods, arrangements, quotations, + comments, accountBalance, loadingMoreTickets, loadingMoreInvoices, loadingMorePayments, loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments, } diff --git a/apps/ops/src/composables/useConversations.js b/apps/ops/src/composables/useConversations.js index b602406..f11bc3f 100644 --- a/apps/ops/src/composables/useConversations.js +++ b/apps/ops/src/composables/useConversations.js @@ -1,7 +1,7 @@ import { ref, computed } from 'vue' import { useAuthStore } from 'src/stores/auth' -const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' +import { HUB_URL } from 'src/config/hub' function agentHeaders (extra = {}) { try { diff --git a/apps/ops/src/composables/useDeviceStatus.js b/apps/ops/src/composables/useDeviceStatus.js index 53a3d41..c8eb49e 100644 --- a/apps/ops/src/composables/useDeviceStatus.js +++ b/apps/ops/src/composables/useDeviceStatus.js @@ -4,9 +4,7 @@ */ import { ref, readonly } from 'vue' -const HUB_URL = (window.location.hostname === 'localhost') - ? 'http://localhost:3300' - : 'https://msg.gigafibre.ca' +import { HUB_URL } from 'src/config/hub' // Singleton state — survives component remounts so cached data shows instantly const cache = new Map() // serial → { data, ts } @@ -250,6 +248,21 @@ export function useDeviceStatus () { return data } + /** + * Fetch port neighbor context for a serial — shows if other ONUs on the same port are affected. + * Returns { total, online, offline, neighbors: [{ serial, status, cause }], is_mass_outage } + */ + async function fetchPortContext (serial) { + if (!serial) return null + try { + const res = await fetch(`${HUB_URL}/ai/port-health-by-serial?serial=${encodeURIComponent(serial)}`) + if (!res.ok) return null + return await res.json() + } catch { + return null + } + } + return { deviceMap: readonly(deviceMap), oltMap: readonly(oltMap), @@ -257,6 +270,7 @@ export function useDeviceStatus () { error: readonly(error), fetchStatus, fetchOltStatus, + fetchPortContext, getDevice, getOltData, isOnline, diff --git a/apps/ops/src/composables/usePaymentActions.js b/apps/ops/src/composables/usePaymentActions.js new file mode 100644 index 0000000..1b7ee72 --- /dev/null +++ b/apps/ops/src/composables/usePaymentActions.js @@ -0,0 +1,176 @@ +/** + * Payment actions composable — agent-facing payment operations via targo-hub + */ +import { ref } from 'vue' +import { Notify } from 'quasar' +import { HUB_SSE_URL } from 'src/config/dispatch' + +const HUB = HUB_SSE_URL + +async function hubFetch (path, opts = {}) { + const res = await fetch(HUB + path, { + method: opts.method || 'GET', + headers: { 'Content-Type': 'application/json', ...opts.headers }, + ...(opts.body ? { body: JSON.stringify(opts.body) } : {}), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Request failed' })) + throw new Error(err.error || `HTTP ${res.status}`) + } + return res.json() +} + +export function usePaymentActions (customer) { + const loading = ref(false) + const sendingLink = ref(false) + const chargingCard = ref(false) + const togglingPpa = ref(false) + + async function fetchBalance () { + if (!customer.value?.name) return null + try { + return await hubFetch(`/payments/balance/${encodeURIComponent(customer.value.name)}`) + } catch (e) { + console.error('Balance fetch error:', e) + return null + } + } + + async function fetchMethods () { + if (!customer.value?.name) return [] + try { + const res = await hubFetch(`/payments/methods/${encodeURIComponent(customer.value.name)}`) + return res.methods || [] + } catch (e) { + console.error('Methods fetch error:', e) + return [] + } + } + + async function sendPaymentLink (channel = 'both') { + sendingLink.value = true + try { + const res = await hubFetch('/payments/send-link', { + method: 'POST', + body: { customer: customer.value.name, channel }, + }) + const sentVia = res.sent?.join(' + ') || 'aucun' + Notify.create({ type: 'positive', message: `Lien de paiement envoyé (${sentVia}) — ${res.amount}$`, position: 'top' }) + return res + } catch (e) { + Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' }) + } finally { + sendingLink.value = false + } + } + + async function chargeCard (amount) { + chargingCard.value = true + try { + const res = await hubFetch('/payments/charge', { + method: 'POST', + body: { customer: customer.value.name, ...(amount ? { amount } : {}) }, + }) + if (res.ok) { + Notify.create({ type: 'positive', message: `Paiement de ${res.amount}$ prélevé avec succès`, position: 'top' }) + } else { + Notify.create({ type: 'warning', message: `Statut: ${res.status} — vérifier Stripe`, position: 'top' }) + } + return res + } catch (e) { + Notify.create({ type: 'negative', message: `Erreur prélèvement: ${e.message}`, position: 'top' }) + } finally { + chargingCard.value = false + } + } + + async function togglePpa (enabled) { + togglingPpa.value = true + try { + await hubFetch('/payments/toggle-ppa', { + method: 'POST', + body: { customer: customer.value.name, enabled }, + }) + Notify.create({ + type: 'positive', + message: enabled ? 'PPA activé' : 'PPA désactivé', + position: 'top', + }) + return true + } catch (e) { + Notify.create({ type: 'negative', message: `Erreur PPA: ${e.message}`, position: 'top' }) + return false + } finally { + togglingPpa.value = false + } + } + + async function openPortal () { + loading.value = true + try { + const res = await hubFetch('/payments/portal', { + method: 'POST', + body: { customer: customer.value.name }, + }) + if (res.url) window.open(res.url, '_blank') + } catch (e) { + Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' }) + } finally { + loading.value = false + } + } + + async function createCheckout () { + loading.value = true + try { + const res = await hubFetch('/payments/checkout', { + method: 'POST', + body: { customer: customer.value.name }, + }) + // Copy URL to clipboard for the agent to share + if (res.url) { + await navigator.clipboard?.writeText(res.url) + Notify.create({ type: 'info', message: `Lien copié — ${res.amount}$. Ouvre dans un nouvel onglet.`, position: 'top', timeout: 4000 }) + window.open(res.url, '_blank') + } + return res + } catch (e) { + Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' }) + } finally { + loading.value = false + } + } + + const refunding = ref(false) + + async function refundPayment (paymentEntry, amount, reason) { + refunding.value = true + try { + const res = await hubFetch('/payments/refund', { + method: 'POST', + body: { payment_entry: paymentEntry, amount: amount || undefined, reason }, + }) + if (res.ok) { + const msg = res.stripe_refund + ? `Remboursement Stripe ${res.amount}$ (${res.stripe_refund.id})` + : `Remboursement ${res.amount}$ enregistré` + Notify.create({ type: 'positive', message: msg, position: 'top' }) + if (res.warning) { + Notify.create({ type: 'warning', message: res.warning, position: 'top', timeout: 8000 }) + } + } + return res + } catch (e) { + Notify.create({ type: 'negative', message: `Erreur remboursement: ${e.message}`, position: 'top' }) + } finally { + refunding.value = false + } + } + + return { + loading, sendingLink, chargingCard, togglingPpa, refunding, + fetchBalance, fetchMethods, + sendPaymentLink, chargeCard, togglePpa, openPortal, createCheckout, + refundPayment, + } +} diff --git a/apps/ops/src/composables/usePermissions.js b/apps/ops/src/composables/usePermissions.js index 9ab2aa1..c4fd4be 100644 --- a/apps/ops/src/composables/usePermissions.js +++ b/apps/ops/src/composables/usePermissions.js @@ -1,8 +1,6 @@ import { ref, computed } from 'vue' -const HUB_URL = (window.location.hostname === 'localhost') - ? 'http://localhost:3300' - : 'https://msg.gigafibre.ca' +import { HUB_URL } from 'src/config/hub' // Singleton state — shared across all components const permissions = ref(null) // { email, username, name, groups, is_superuser, capabilities, overrides } diff --git a/apps/ops/src/composables/useSSE.js b/apps/ops/src/composables/useSSE.js index 7988973..563521c 100644 --- a/apps/ops/src/composables/useSSE.js +++ b/apps/ops/src/composables/useSSE.js @@ -1,6 +1,6 @@ import { ref, onUnmounted } from 'vue' -const HUB_URL = 'https://msg.gigafibre.ca' +import { HUB_URL } from 'src/config/hub' /** * SSE composable for real-time Communication events from targo-hub. diff --git a/apps/ops/src/composables/useScanner.js b/apps/ops/src/composables/useScanner.js index 00976c2..3774e34 100644 --- a/apps/ops/src/composables/useScanner.js +++ b/apps/ops/src/composables/useScanner.js @@ -1,8 +1,6 @@ import { ref } from 'vue' -const HUB_BASE = window.location.hostname === 'localhost' - ? 'http://localhost:3300' - : 'https://msg.gigafibre.ca' +import { HUB_URL as HUB_BASE } from 'src/config/hub' export function useScanner () { const barcodes = ref([]) diff --git a/apps/ops/src/composables/useUnifiedCreate.js b/apps/ops/src/composables/useUnifiedCreate.js index 64e7fd9..d8a7a6a 100644 --- a/apps/ops/src/composables/useUnifiedCreate.js +++ b/apps/ops/src/composables/useUnifiedCreate.js @@ -3,7 +3,7 @@ import { Notify } from 'quasar' import { createDoc, listDocs } from 'src/api/erp' import { createJob, fetchTags, createTag, updateTag, renameTag, deleteTag } from 'src/api/dispatch' -const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' +import { HUB_URL } from 'src/config/hub' /** * Composable for unified ticket/task/work-order creation. diff --git a/apps/ops/src/composables/useWifiDiagnostic.js b/apps/ops/src/composables/useWifiDiagnostic.js new file mode 100644 index 0000000..37ff260 --- /dev/null +++ b/apps/ops/src/composables/useWifiDiagnostic.js @@ -0,0 +1,304 @@ +import { ref } from 'vue' +import { HUB_URL } from 'src/config/hub' + +const THRESHOLDS = { + meshSignal: { critical: 60, warning: 80 }, + clientSignal: { critical: 50, warning: 70 }, + packetLoss: { critical: 10, warning: 5 }, + backhaulUtil: 80, + cpu: 80, + preferred2gChannels: [1, 6, 11], +} + +const cache = new Map() +const CACHE_TTL = 120_000 + +const loading = ref(false) +const error = ref(null) +const data = ref(null) + +async function fetchDiagnostic(ip, pass, user = 'superadmin') { + if (!ip || !pass) { error.value = 'IP et mot de passe requis'; return null } + + const cached = cache.get(ip) + if (cached && (Date.now() - cached.ts) < CACHE_TTL) { + data.value = cached.data + return cached.data + } + + loading.value = true + error.value = null + + try { + const params = new URLSearchParams({ ip, user, pass }) + const res = await fetch(`${HUB_URL}/modem/diagnostic?${params}`) + const json = await res.json() + + if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`) + + const processed = processDiagnostic(json) + cache.set(ip, { data: processed, ts: Date.now() }) + data.value = processed + return processed + } catch (e) { + error.value = e.message + return null + } finally { + loading.value = false + } +} + +function processOnlineStatus(raw) { + if (!raw.onlineStatus || raw.onlineStatus.error) return null + return { + ipv4: raw.onlineStatus.onlineStatusV4 === 'online', + ipv6: raw.onlineStatus.onlineStatusV6 === 'online', + uptimeV4: parseInt(raw.onlineStatus.onlineTimeV4) || 0, + } +} + +function processWanIPs(raw) { + if (!Array.isArray(raw.wanIPs)) return [] + return raw.wanIPs + .filter(w => w.status === 'Enabled' && w.IPAddress && w.IPAddress !== '0.0.0.0') + .map(w => ({ ip: w.IPAddress, mask: w.subnetMask, type: w.addressingType, role: classifyIP(w.IPAddress) })) +} + +function processRadios(raw) { + if (!Array.isArray(raw.radios)) return [] + return raw.radios.map(r => ({ + band: r.operatingFrequencyBand, + channel: parseInt(r.channel) || 0, + bandwidth: r.currentOperatingChannelBandwidth, + standard: r.operatingStandards, + txPower: parseInt(r.transmitPower) || 0, + autoChannel: r.autoChannelEnable === '1', + status: r.status, + })) +} + +function processMeshNodes(raw, issues) { + if (!Array.isArray(raw.meshNodes)) return [] + return raw.meshNodes.map(n => { + const node = { + hostname: n.X_TP_HostName || 'unknown', + model: n.X_TP_ModelName || '', + ip: n.X_TP_IPAddress, + mac: n.MACAddress, + active: n.X_TP_Active === '1', + isController: n.X_TP_IsController === '1', + cpu: parseInt(n.X_TP_CPUUsage) || 0, + uptime: parseInt(n.X_TP_UpTime) || 0, + firmware: n.softwareVersion || '', + backhaul: { + type: n.backhaulLinkType || 'Ethernet', + signal: parseInt(n.backhaulSignalStrength) || 0, + utilization: parseInt(n.backhaulLinkUtilization) || 0, + linkRate: parseInt(n.X_TP_LinkRate) || 0, + }, + speedUp: parseInt(n.X_TP_UpSpeed) || 0, + speedDown: parseInt(n.X_TP_DownSpeed) || 0, + } + + if (!node.isController && node.active && node.backhaul.type === 'Wi-Fi') { + const sig = node.backhaul.signal + const name = node.hostname + if (sig < THRESHOLDS.meshSignal.critical) { + issues.push({ + severity: 'critical', + message: `${name}: signal mesh tres faible (${sig})`, + detail: `Le noeud "${name}" a un signal de backhaul de ${sig}/255. Lien a ${node.backhaul.linkRate} Mbps.`, + action: `Rapprocher le noeud "${name}" du routeur principal ou ajouter un noeud intermediaire.`, + }) + } else if (sig < THRESHOLDS.meshSignal.warning) { + issues.push({ + severity: 'warning', + message: `${name}: signal mesh faible (${sig})`, + detail: `Backhaul a ${node.backhaul.linkRate} Mbps, utilisation ${node.backhaul.utilization}%.`, + action: `Envisager de rapprocher "${name}" du routeur pour ameliorer la vitesse.`, + }) + } + if (node.backhaul.utilization > THRESHOLDS.backhaulUtil) { + issues.push({ + severity: 'warning', + message: `${name}: backhaul sature (${node.backhaul.utilization}%)`, + detail: `Le lien entre "${name}" et le routeur est utilise a ${node.backhaul.utilization}%.`, + action: `Reduire le nombre d'appareils sur ce noeud ou connecter "${name}" en Ethernet.`, + }) + } + } + if (node.cpu > THRESHOLDS.cpu) { + issues.push({ + severity: 'warning', + message: `${node.hostname}: CPU eleve (${node.cpu}%)`, + detail: `Le processeur du noeud est a ${node.cpu}% d'utilisation.`, + action: `Redemarrer le noeud "${node.hostname}" si le probleme persiste.`, + }) + } + return node + }) +} + +function processClients(raw, issues, meshNodes, nodeRadios) { + const clientMap = new Map() + + if (Array.isArray(raw.clients)) { + for (const c of raw.clients) { + clientMap.set(c.MACAddress, { + mac: c.MACAddress, + hostname: c.X_TP_HostName || '', + ip: c.X_TP_IPAddress || '', + signal: parseInt(c.signalStrength) || 0, + band: '', + standard: c.operatingStandard || '', + active: c.active === '1', + linkDown: parseInt(c.lastDataDownlinkRate) || 0, + linkUp: parseInt(c.lastDataUplinkRate) || 0, + maxLinkRate: parseInt(c.X_TP_MaxLinkRate) || 0, + signalLevel: parseInt(c.X_TP_SignalStrengthLevel) || 0, + connectedSince: c.associationTime || '', + apMac: c.X_TP_ApDeviceMac || '', + radioMac: c.X_TP_RadioMac || '', + retrans: 0, packetsSent: 0, bytesSent: 0, bytesReceived: 0, + }) + } + } + + if (Array.isArray(raw.clientStats)) { + for (const s of raw.clientStats) { + const c = clientMap.get(s.MACAddress) + if (c) { + c.retrans = parseInt(s.retransCount) || 0 + c.packetsSent = parseInt(s.packetsSent) || 0 + c.bytesSent = parseInt(s.bytesSent) || 0 + c.bytesReceived = parseInt(s.bytesReceived) || 0 + c.speedDown = parseInt(s.X_TP_DownSpeed) || 0 + c.speedUp = parseInt(s.X_TP_UpSpeed) || 0 + if (!c.hostname && s.X_TP_HostName) c.hostname = s.X_TP_HostName + if (!c.signal && s.signalStrength) c.signal = parseInt(s.signalStrength) || 0 + } else { + clientMap.set(s.MACAddress, { + mac: s.MACAddress, + hostname: s.X_TP_HostName || '', + ip: s.X_TP_IPAddress || '', + signal: parseInt(s.signalStrength) || 0, + standard: s.X_TP_OperatingStandard || '', + active: true, + retrans: parseInt(s.retransCount) || 0, + packetsSent: parseInt(s.packetsSent) || 0, + bytesSent: parseInt(s.bytesSent) || 0, + bytesReceived: parseInt(s.bytesReceived) || 0, + speedDown: parseInt(s.X_TP_DownSpeed) || 0, + speedUp: parseInt(s.X_TP_UpSpeed) || 0, + linkDown: parseInt(s.lastDataDownlinkRate) || 0, + linkUp: parseInt(s.lastDataUplinkRate) || 0, + maxLinkRate: 0, signalLevel: 0, connectedSince: '', apMac: '', radioMac: '', + }) + } + } + } + + // Assign band from radio MAC to mesh node radios + if (Array.isArray(nodeRadios)) { + const radioMap = new Map() + for (const r of nodeRadios) radioMap.set(r.MACAddress, r.operatingFrequencyBand) + for (const c of clientMap.values()) { + if (c.radioMac && radioMap.has(c.radioMac)) c.band = radioMap.get(c.radioMac) + } + } + + // Compute loss ratio and generate client issues + for (const c of clientMap.values()) { + c.lossPercent = (c.packetsSent > 100 && c.retrans > 0) + ? Math.round((c.retrans / c.packetsSent) * 1000) / 10 + : 0 + + if (c.apMac && meshNodes.length) { + const node = meshNodes.find(n => n.mac === c.apMac) + if (node) c.meshNode = node.hostname + } + + const label = c.hostname || c.mac + + if (c.active && c.signal > 0) { + if (c.signal < THRESHOLDS.clientSignal.critical) { + issues.push({ + severity: 'critical', + message: `${label}: signal tres faible (${c.signal}/255)`, + detail: `Appareil "${label}" sur ${c.band || '?'}, lien ${Math.round(c.linkDown / 1000)} Mbps.`, + action: `Rapprocher l'appareil du noeud mesh le plus proche ou verifier les obstacles.`, + }) + } else if (c.signal < THRESHOLDS.clientSignal.warning) { + issues.push({ + severity: 'warning', + message: `${label}: signal faible (${c.signal}/255)`, + detail: `Vitesse reduite a ${Math.round(c.linkDown / 1000)} Mbps (max ${Math.round(c.maxLinkRate / 1000)} Mbps).`, + action: `Verifier le placement de l'appareil par rapport au noeud "${c.meshNode || 'principal'}".`, + }) + } + } + + if (c.lossPercent > THRESHOLDS.packetLoss.critical) { + issues.push({ + severity: 'critical', + message: `${label}: ${c.lossPercent}% perte de paquets`, + detail: `${c.retrans} retransmissions sur ${c.packetsSent} paquets envoyes.`, + action: `Interference probable. Verifier le canal WiFi, les appareils voisins, ou changer la bande.`, + }) + } else if (c.lossPercent > THRESHOLDS.packetLoss.warning) { + issues.push({ + severity: 'warning', + message: `${label}: ${c.lossPercent}% perte de paquets`, + detail: `${c.retrans} retransmissions sur ${c.packetsSent} paquets.`, + action: `Performance reduite. Envisager de changer de canal ou rapprocher l'appareil.`, + }) + } + } + + return [...clientMap.values()].sort((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1 + return (b.signal || 0) - (a.signal || 0) + }) +} + +function checkRadioIssues(radios, issues) { + for (const r of radios) { + if (r.band === '2.4GHz' && !r.autoChannel && r.channel > 0 + && THRESHOLDS.preferred2gChannels.indexOf(r.channel) === -1) { + issues.push({ + severity: 'warning', + message: `Canal 2.4GHz non optimal (${r.channel})`, + detail: `Le canal ${r.channel} chevauche les canaux voisins. Les canaux 1, 6 ou 11 sont recommandes.`, + action: `Changer le canal 2.4GHz a 1, 6 ou 11, ou activer le canal automatique.`, + }) + } + } +} + +function processDiagnostic(raw) { + const issues = [] + const online = processOnlineStatus(raw) + const wanIPs = processWanIPs(raw) + const radios = processRadios(raw) + const meshNodes = processMeshNodes(raw, issues) + const clients = processClients(raw, issues, meshNodes, raw.nodeRadios) + checkRadioIssues(radios, issues) + + const severityOrder = { critical: 0, warning: 1, info: 2 } + issues.sort((a, b) => (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9)) + + return { fetchedAt: raw.fetchedAt, durationMs: raw.durationMs, issues, meshNodes, clients, radios, wanIPs, online } +} + +function classifyIP(ip) { + if (!ip) return 'unknown' + const p = ip.split('.').map(Number) + if (p[0] === 192 && p[1] === 168) return 'lan' + if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return 'management' + if (p[0] === 10) return 'service' + return 'internet' +} + +export function useWifiDiagnostic() { + return { fetchDiagnostic, loading, error, data } +} diff --git a/apps/ops/src/config/dispatch.js b/apps/ops/src/config/dispatch.js index 5aeb05e..05581c1 100644 --- a/apps/ops/src/config/dispatch.js +++ b/apps/ops/src/config/dispatch.js @@ -5,6 +5,4 @@ export const RES_ICONS = { 'Nacelle': '🏗️', 'Grue': '🏗️', 'Fusionneuse': '🔧', 'OTDR': '📡', } -export const HUB_SSE_URL = window.location.hostname === 'localhost' - ? 'http://localhost:3300' - : 'https://msg.gigafibre.ca' +export { HUB_URL as HUB_SSE_URL } from './hub' diff --git a/apps/ops/src/config/hub.js b/apps/ops/src/config/hub.js new file mode 100644 index 0000000..8ee5e92 --- /dev/null +++ b/apps/ops/src/config/hub.js @@ -0,0 +1,13 @@ +/** + * Hub URL — single source of truth for targo-hub API base URL. + * + * All components, composables, and pages should import from here: + * import { HUB_URL } from 'src/config/hub' + * + * In dev (localhost), points to local targo-hub instance. + * In production, points to msg.gigafibre.ca. + * + * Can also be overridden via VITE_HUB_URL env variable. + */ +export const HUB_URL = import.meta.env.VITE_HUB_URL + || (window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca') diff --git a/apps/ops/src/config/table-columns.js b/apps/ops/src/config/table-columns.js index b07249c..da61bad 100644 --- a/apps/ops/src/config/table-columns.js +++ b/apps/ops/src/config/table-columns.js @@ -11,12 +11,26 @@ export const invoiceCols = [ { name: 'status', label: 'Statut', field: 'status', align: 'center' }, ] +function fmtDateTime (row) { + if (!row.creation) return row.posting_date || '' + // ERPNext creation is UTC — display in Eastern time + const d = new Date(row.creation + 'Z') + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Toronto', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(d) + const g = Object.fromEntries(parts.filter(p => p.type !== 'literal').map(p => [p.type, p.value])) + return `${g.year}-${g.month}-${g.day} ${g.hour}:${g.minute}` +} + export const paymentCols = [ { name: 'name', label: 'N°', field: 'name', align: 'left' }, - { name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true }, + { name: 'posting_date', label: 'Date', field: fmtDateTime, align: 'left', sortable: true, sort: (a, b, rowA, rowB) => (rowA.creation || '').localeCompare(rowB.creation || '') }, { 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' }, + { name: 'actions', label: '', field: () => '', align: 'center', style: 'width:40px;padding:0' }, ] export const invItemCols = [ @@ -26,6 +40,51 @@ export const invItemCols = [ { name: 'amount', label: 'Montant', field: 'amount', align: 'right' }, ] +export const voipCols = [ + { name: 'did', label: 'DID', field: 'did', align: 'left' }, + { name: 'status', label: 'Statut', field: 'status', align: 'center' }, + { name: 'service_location', label: 'Adresse', field: 'service_location', align: 'left' }, + { name: 'e911_civic_number', label: '911 Adresse', field: r => r.e911_civic_number ? `${r.e911_civic_number} ${r.e911_street_name || ''}`.trim() : '', align: 'left' }, + { name: 'e911_synced', label: '911 Sync', field: 'e911_synced', align: 'center' }, + { name: 'sip_user', label: 'SIP', field: 'sip_user', align: 'left' }, +] + +export const paymentMethodCols = [ + { name: 'provider', label: 'Fournisseur', field: 'provider', align: 'left' }, + { name: 'is_auto_ppa', label: 'PPA', field: r => r.is_auto_ppa || r.stripe_ppa_enabled ? 'Oui' : 'Non', align: 'center' }, + { name: 'detail', label: 'Détail', field: r => { + if (r.provider === 'Stripe') return r.stripe_customer_id || '' + if (r.provider === 'Paysafe') return r.paysafe_profile_id || '' + if (r.provider === 'Bank Draft') return r.ppa_institution ? `${r.ppa_institution}-${r.ppa_branch}` : (r.ppa_name || '') + return '' + }, align: 'left' }, + { name: 'stripe_card', label: 'Carte', field: r => { + // stripe_cards is enriched by targo-hub from Stripe API + if (r.stripe_cards?.length) { + const c = r.stripe_cards[0] + return `${(c.brand || '').toUpperCase()} •••• ${c.last4} (${c.exp_month}/${c.exp_year})` + } + return '' + }, align: 'left' }, +] + +export const arrangementCols = [ + { name: 'name', label: 'N°', field: 'name', align: 'left' }, + { name: 'status', label: 'Statut', field: 'status', align: 'center' }, + { name: 'total_amount', label: 'Montant', field: 'total_amount', align: 'right' }, + { name: 'date_agreed', label: 'Convenu', field: 'date_agreed', align: 'left', sortable: true }, + { name: 'date_due', label: 'Échéance', field: 'date_due', align: 'left' }, + { name: 'note', label: 'Note', field: 'note', align: 'left' }, +] + +export const quotationCols = [ + { name: 'name', label: 'N°', field: 'name', align: 'left' }, + { name: 'transaction_date', label: 'Date', field: 'transaction_date', align: 'left', sortable: true }, + { name: 'grand_total', label: 'Total', field: 'grand_total', align: 'right' }, + { name: 'custom_po_number', label: 'PO', field: 'custom_po_number', align: 'left' }, + { name: 'status', label: 'Statut', field: 'status', align: 'center' }, +] + 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' }, diff --git a/apps/ops/src/data/client-constants.js b/apps/ops/src/data/client-constants.js index 72b6951..08d8cf9 100644 --- a/apps/ops/src/data/client-constants.js +++ b/apps/ops/src/data/client-constants.js @@ -14,7 +14,7 @@ export const equipScanTypeMap = { ont: 'ONT', onu: 'ONT', router: 'Router', mode export const phoneLabelMap = { cell_phone: 'Cell', tel_home: 'Maison', tel_office: 'Bureau' } -export const defaultSectionsOpen = { locations: true, tickets: true, invoices: false, payments: false, notes: false } +export const defaultSectionsOpen = { locations: true, tickets: true, invoices: false, payments: false, voip: false, paymentMethods: false, arrangements: false, quotations: false, notes: false } export const defaultNewEquip = () => ({ equipment_type: 'ONT', serial_number: '', brand: '', model: '', mac_address: '', ip_address: '', status: 'Actif', diff --git a/apps/ops/src/data/wizard-constants.js b/apps/ops/src/data/wizard-constants.js index f1a8377..a96facc 100644 --- a/apps/ops/src/data/wizard-constants.js +++ b/apps/ops/src/data/wizard-constants.js @@ -38,4 +38,4 @@ export function catIcon (cat) { return CAT_ICON_MAP[cat] || 'category' } -export const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' +export { HUB_URL } from 'src/config/hub' diff --git a/apps/ops/src/modules/tech/pages/TechTasksPage.vue b/apps/ops/src/modules/tech/pages/TechTasksPage.vue index 16e6daf..fdb200a 100644 --- a/apps/ops/src/modules/tech/pages/TechTasksPage.vue +++ b/apps/ops/src/modules/tech/pages/TechTasksPage.vue @@ -179,12 +179,20 @@ import { useRouter } from 'vue-router' import { Notify } from 'quasar' import { BASE_URL } from 'src/config/erpnext' +const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca' + +const props = defineProps({ + token: { type: String, default: '' }, +}) + const router = useRouter() const loading = ref(false) const statFilter = ref('all') const jobs = ref([]) const saving = ref(false) const isOnline = ref(navigator.onLine) +// Magic link: tech identity from token verification +const magicTechId = ref('') // Bottom sheet const sheetOpen = ref(false) @@ -243,30 +251,67 @@ async function apiUpdate (doctype, name, data) { if (!res.ok) throw new Error('Update failed') } +/** Verify magic link token via targo-hub and return tech_id */ +async function verifyMagicToken (token) { + try { + const res = await fetch(`${HUB_URL}/magic-link/verify?token=${encodeURIComponent(token)}`) + if (!res.ok) return null + const data = await res.json() + return data.ok ? data.tech_id : null + } catch { return null } +} + async function loadTasks () { loading.value = true try { - // Load tech profile to get name - try { - const me = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user') - if (me.ok) { - const u = await me.json() - const userName = u.message || '' - // Try to get full name from Dispatch Technician linked to user - if (userName && userName !== 'authenticated') { - try { - const techs = await apiFetch('/api/resource/Dispatch Technician?filters=[["user","=","' + userName + '"]]&fields=["name","full_name"]&limit_page_length=1') - if (techs.length && techs[0].full_name) techName.value = techs[0].full_name - else techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) - } catch { techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } - } - } - } catch {} + let techId = '' + // 1. If magic link token provided, verify it and get tech identity + if (props.token) { + techId = await verifyMagicToken(props.token) + if (!techId) { + Notify.create({ type: 'negative', message: 'Lien expiré ou invalide. Demandez un nouveau lien par SMS.', timeout: 6000 }) + loading.value = false + return + } + magicTechId.value = techId + // Load tech full name from ERPNext + try { + const techs = await apiFetch('/api/resource/Dispatch Technician/' + encodeURIComponent(techId) + '?fields=["name","full_name"]') + if (techs && techs.full_name) techName.value = techs.full_name + else techName.value = techId + } catch { techName.value = techId } + } else { + // 2. Fallback: identify tech from Authentik session (nginx-injected auth) + try { + const me = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user') + if (me.ok) { + const u = await me.json() + const userName = u.message || '' + if (userName && userName !== 'authenticated') { + try { + const techs = await apiFetch('/api/resource/Dispatch Technician?filters=[["user","=","' + userName + '"]]&fields=["name","full_name"]&limit_page_length=1') + if (techs.length) { + techId = techs[0].name + if (techs[0].full_name) techName.value = techs[0].full_name + else techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + } else { + techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + } + } catch { techName.value = userName.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } + } + } + } catch {} + } + + // Build filters: if we know the tech, filter by assigned_tech + date + const filters = techId + ? [['scheduled_date', '=', today], ['assigned_tech', '=', techId]] + : { scheduled_date: today } const params = new URLSearchParams({ fields: JSON.stringify(['name', 'subject', 'status', 'customer', 'customer_name', 'service_location', 'service_location_name', 'scheduled_time', 'description', 'job_type', 'duration_h', 'priority']), - filters: JSON.stringify({ scheduled_date: today }), + filters: JSON.stringify(filters), limit_page_length: 50, order_by: 'scheduled_time asc', }) diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue index 5b6c31a..5dced67 100644 --- a/apps/ops/src/pages/ClientDetailPage.vue +++ b/apps/ops/src/pages/ClientDetailPage.vue @@ -128,15 +128,50 @@
-
+
- Ajouter un equipement + Ajouter + + + + + Forfait / Service + + + + Rabais / Crédit + + + + + Équipement + + +
-
Abonnements ({{ locSubs(loc.name).length }})
+
+ Abonnements ({{ locSubs(loc.name).length }}) + + Ajouter un service + + + + + Forfait / Service + + + + Rabais / Crédit + + + + +
Aucun