refactor: reduce token count, DRY code, consolidate docs

Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-13 08:39:58 -04:00
parent 73691668d3
commit 607ea54b5c
174 changed files with 22613 additions and 2938 deletions

View File

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

254
README.md
View File

@ -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)
Internet
|
96.125.196.67 (Proxmox VM, Ubuntu 24.04, Docker)
|
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
+----------+----------+----------+----------+----------+
| | | | | |
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 <ERP_SERVICE_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 |

View File

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

View File

@ -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.
*/

View File

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

View File

@ -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 {

View File

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

View File

@ -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))

View File

@ -16,48 +16,290 @@
</div>
</div>
<!-- Addresses -->
<!-- Services & Locations -->
<div class="col-12 col-md-6">
<div class="portal-card">
<div class="text-subtitle1 text-weight-medium q-mb-md">Adresses de service</div>
<div v-if="!addresses.length" class="text-grey-6">Aucune adresse enregistrée</div>
<q-list v-else separator>
<q-item v-for="addr in addresses" :key="addr.name">
<q-item-section>
<q-item-label>{{ addr.address_title || addr.address_line1 }}</q-item-label>
<q-item-label caption>
{{ addr.address_line1 }}
<span v-if="addr.address_line2">, {{ addr.address_line2 }}</span>
<br>{{ addr.city }} {{ addr.state }} {{ addr.pincode }}
</q-item-label>
<!-- Monthly total banner (shown when multiple locations) -->
<div v-if="serviceLocations.length > 1 && grandMonthlyTotal !== null" class="monthly-total-banner q-mb-md">
<div class="row items-center justify-between">
<div>
<div class="text-caption text-grey-7">Total mensuel estimé</div>
<div class="text-h5 text-weight-bold" :class="grandMonthlyTotal >= 0 ? 'text-primary' : 'text-positive'">
{{ formatMoney(grandMonthlyTotal) }}<span class="text-caption text-grey-6"> /mois</span>
</div>
</div>
<div class="text-caption text-grey-6">
{{ serviceLocations.length }} adresses · {{ subscriptionCount }} services
</div>
</div>
</div>
<div v-if="loadingServices" class="q-pa-md text-center">
<q-spinner-dots size="28px" color="primary" />
</div>
<div v-else-if="!serviceLocations.length" class="text-grey-6">Aucune adresse enregistrée</div>
<!-- Location cards -->
<div v-else class="q-gutter-sm">
<div v-for="loc in serviceLocations" :key="loc.name" class="location-card">
<!-- Location header -->
<div class="row items-center q-mb-xs">
<q-icon name="place" size="18px" color="primary" class="q-mr-xs" />
<div class="text-weight-medium">{{ loc.location_name || loc.address_line }}</div>
<q-space />
<q-badge v-if="loc.connection_type" outline color="grey-7" :label="loc.connection_type" class="q-mr-xs" />
<q-badge :color="loc.status === 'Active' ? 'positive' : 'grey'" :label="loc.status" />
</div>
<div class="text-caption text-grey-6 q-ml-md q-mb-sm">
{{ loc.address_line }}, {{ loc.city }} {{ loc.postal_code }}
</div>
<!-- Subscriptions for this location -->
<div v-if="loc.subscriptions.length" class="q-ml-md">
<div v-for="sub in loc.subscriptions" :key="sub.name" class="subscription-row">
<div class="row items-center no-wrap">
<q-icon :name="categoryIcon(sub.service_category)" size="16px" :color="categoryColor(sub.service_category)" class="q-mr-sm" />
<div class="col">
<div class="text-body2">{{ sub.plan_name }}</div>
<div v-if="sub.speed_down" class="text-caption text-grey-6">
{{ sub.speed_down }} / {{ sub.speed_up }} Mbps
</div>
</div>
<div class="text-right">
<div class="text-body2 text-weight-medium" :class="sub.monthly_price < 0 ? 'text-positive' : ''">
{{ formatMoney(sub.monthly_price) }}
</div>
<div class="text-caption text-grey-6">/mois</div>
</div>
</div>
</div>
<!-- Location subtotal -->
<div class="location-subtotal q-mt-xs">
<div class="row items-center justify-end">
<span class="text-caption text-grey-7 q-mr-sm">Sous-total:</span>
<span class="text-body2 text-weight-bold">{{ formatMoney(loc.monthly_total) }}/mois</span>
</div>
</div>
</div>
<div v-else class="text-caption text-grey-5 q-ml-md">Aucun service actif</div>
</div>
</div>
</div>
</div>
<!-- Payment methods -->
<div class="col-12">
<div class="portal-card">
<div class="row items-center q-mb-md">
<q-icon name="credit_card" size="24px" color="indigo" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-medium">Paiement</div>
<q-space />
<q-btn outline color="indigo" label="Ajouter une carte" icon="add_card" no-caps size="sm"
:loading="addingCard" @click="addCard" />
<q-btn v-if="hasStripeCards" flat color="grey-7" label="Gerer les cartes" icon="settings" no-caps size="sm"
class="q-ml-sm" :loading="openingPortal" @click="openPortal" />
</div>
<!-- Balance -->
<div v-if="balance !== null" class="q-mb-md">
<div class="row items-center q-gutter-sm">
<span class="text-body2">Solde a payer:</span>
<span class="text-h6 text-weight-bold" :class="balance > 0 ? 'text-negative' : 'text-positive'">
{{ formatMoney(balance) }}
</span>
<q-btn v-if="balance > 0" color="indigo" unelevated label="Payer le solde" icon="payment"
no-caps size="sm" :loading="payingBalance" @click="payBalance" class="q-ml-md" />
</div>
</div>
<!-- Saved cards -->
<div v-if="loadingPayment" class="q-pa-sm">
<q-spinner-dots size="24px" color="indigo" />
</div>
<div v-else-if="cards.length" class="q-mb-md">
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Cartes enregistrees</div>
<q-list bordered separator class="rounded-borders">
<q-item v-for="card in cards" :key="card.id">
<q-item-section avatar>
<q-icon :name="cardIcon(card.brand)" size="28px" :color="card.is_default ? 'indigo' : 'grey-6'" />
</q-item-section>
<q-item-section side v-if="addr.is_primary_address">
<q-badge color="primary" label="Principal" />
<q-item-section>
<q-item-label>{{ cardBrandLabel(card.brand) }} **** {{ card.last4 }}</q-item-label>
<q-item-label caption>Exp. {{ card.exp_month }}/{{ card.exp_year }}</q-item-label>
</q-item-section>
<q-item-section side v-if="card.is_default">
<q-badge color="indigo" label="Par defaut" />
</q-item-section>
</q-item>
</q-list>
</div>
<div v-else class="text-grey-6 text-body2 q-mb-md">
Aucune carte enregistree. Ajoutez une carte pour activer le paiement automatique.
</div>
<!-- PPA toggle -->
<div class="q-pa-sm rounded-borders" style="background: #f8f9fc;">
<div class="row items-center">
<div class="col">
<div class="text-weight-medium">Paiement automatique (PPA)</div>
<div class="text-caption text-grey-7">
Vos factures seront payees automatiquement avec votre carte par defaut.
</div>
</div>
<div class="col-auto">
<q-toggle v-model="ppaEnabled" color="indigo" :disable="!hasStripeCards || togglingPPA"
@update:model-value="onTogglePPA" />
</div>
</div>
</div>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchProfile, fetchAddresses } from 'src/api/portal'
import { fetchProfile, fetchServiceLocations } from 'src/api/portal'
import { getBalance, getPaymentMethods, checkoutBalance, setupCard, openBillingPortal, togglePPA } from 'src/api/payments'
import { useFormatters } from 'src/composables/useFormatters'
const $q = useQuasar()
const store = useCustomerStore()
const { formatMoney } = useFormatters()
const profile = ref(null)
const addresses = ref([])
const serviceLocations = ref([])
const grandMonthlyTotal = ref(null)
const subscriptionCount = ref(0)
const loadingServices = ref(true)
const balance = ref(null)
const cards = ref([])
const ppaEnabled = ref(false)
const loadingPayment = ref(true)
const addingCard = ref(false)
const openingPortal = ref(false)
const payingBalance = ref(false)
const togglingPPA = ref(false)
const hasStripeCards = computed(() => cards.value.length > 0)
function cardIcon (brand) {
const icons = { visa: 'credit_card', mastercard: 'credit_card', amex: 'credit_card' }
return icons[brand] || 'credit_card'
}
function cardBrandLabel (brand) {
const labels = { visa: 'Visa', mastercard: 'Mastercard', amex: 'Amex', discover: 'Discover' }
return labels[brand] || (brand || 'Carte').charAt(0).toUpperCase() + (brand || 'carte').slice(1)
}
async function loadPaymentInfo () {
if (!store.customerId) return
loadingPayment.value = true
try {
const [balRes, methRes] = await Promise.all([
getBalance(store.customerId),
getPaymentMethods(store.customerId),
])
balance.value = balRes.balance || 0
// Extract cards from Stripe methods
const methods = methRes.methods || []
const stripeMethod = methods.find(m => m.provider === 'Stripe')
cards.value = stripeMethod?.stripe_cards || []
ppaEnabled.value = !!(stripeMethod?.is_auto_ppa)
} catch (e) {
console.error('Payment info load error:', e)
} finally {
loadingPayment.value = false
}
}
async function addCard () {
addingCard.value = true
try {
const result = await setupCard(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
addingCard.value = false
}
}
async function openPortal () {
openingPortal.value = true
try {
const result = await openBillingPortal(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
openingPortal.value = false
}
}
async function payBalance () {
payingBalance.value = true
try {
const result = await checkoutBalance(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
payingBalance.value = false
}
}
async function onTogglePPA (val) {
togglingPPA.value = true
try {
await togglePPA(store.customerId, val)
$q.notify({
message: val ? 'Paiement automatique active' : 'Paiement automatique desactive',
color: 'positive', icon: val ? 'check_circle' : 'info',
})
} catch (e) {
ppaEnabled.value = !val // revert
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
} finally {
togglingPPA.value = false
}
}
function categoryIcon (cat) {
const icons = {
Internet: 'wifi', IPTV: 'tv', VoIP: 'phone', Bundle: 'inventory_2',
'Hébergement': 'dns', Autre: 'category',
}
return icons[cat] || 'category'
}
function categoryColor (cat) {
const colors = {
Internet: 'blue', IPTV: 'purple', VoIP: 'green', Bundle: 'orange',
'Hébergement': 'cyan', Autre: 'grey',
}
return colors[cat] || 'grey'
}
onMounted(async () => {
if (!store.customerId) return
const [p, a] = await Promise.all([
const [p, svcResult] = await Promise.all([
fetchProfile(store.customerId),
fetchAddresses(store.customerId),
fetchServiceLocations(store.customerId).catch(() => ({ locations: [], grandTotal: 0, subscriptionCount: 0 })),
])
profile.value = p
addresses.value = a
serviceLocations.value = svcResult.locations
grandMonthlyTotal.value = svcResult.grandTotal
subscriptionCount.value = svcResult.subscriptionCount
loadingServices.value = false
loadPaymentInfo()
})
</script>

View File

@ -6,12 +6,16 @@
<!-- Unpaid invoices -->
<div class="col-12 col-sm-6 col-md-4">
<div class="portal-card">
<div class="text-caption text-grey-7">Factures impayées</div>
<div class="text-caption text-grey-7">Factures impayees</div>
<div class="summary-value">{{ unpaidCount }}</div>
<div v-if="unpaidTotal > 0" class="text-body2 text-grey-7">
{{ formatMoney(unpaidTotal) }} à payer
{{ formatMoney(unpaidTotal) }} a payer
</div>
<div class="row q-gutter-sm q-mt-sm">
<q-btn flat color="primary" label="Voir les factures" to="/invoices" no-caps />
<q-btn v-if="unpaidTotal > 0" unelevated color="indigo" label="Payer" icon="payment"
no-caps size="sm" :loading="payLoading" @click="payBalance" />
</div>
<q-btn flat color="primary" label="Voir les factures" to="/invoices" class="q-mt-sm" no-caps />
</div>
</div>
@ -57,15 +61,19 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchInvoices, fetchTickets } from 'src/api/portal'
import { checkoutBalance } from 'src/api/payments'
import { useFormatters } from 'src/composables/useFormatters'
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate, formatMoney } = useFormatters()
const recentInvoices = ref([])
const allTickets = ref([])
const payLoading = ref(false)
const unpaidCount = computed(() =>
recentInvoices.value.filter(i => i.outstanding_amount > 0).length,
@ -84,6 +92,18 @@ function statusClass (status) {
return ''
}
async function payBalance () {
payLoading.value = true
try {
const result = await checkoutBalance(store.customerId)
if (result.url) window.location.href = result.url
} catch (e) {
$q.notify({ message: 'Erreur: ' + (e.message || 'Paiement impossible'), color: 'negative' })
} finally {
payLoading.value = false
}
}
onMounted(async () => {
if (!store.customerId) return
const [invoices, tickets] = await Promise.all([

View File

@ -5,10 +5,32 @@
<q-btn flat round icon="arrow_back" @click="$router.push('/invoices')" />
<div class="page-title q-mb-none q-ml-sm">{{ invoiceName }}</div>
<q-space />
<q-btn outline color="primary" icon="picture_as_pdf" label="Télécharger PDF"
:loading="downloadingPDF" @click="downloadPDF" no-caps class="q-mr-sm" />
<q-btn outline color="primary" icon="picture_as_pdf" label="PDF"
:loading="downloadingPDF" @click="downloadPDF" no-caps class="q-mr-sm" size="sm" />
</div>
<!-- Payment CTA for unpaid invoices -->
<q-banner v-if="invoice && canPay" rounded class="bg-indigo-1 q-mb-md">
<template #avatar><q-icon name="payment" color="indigo" /></template>
<div class="row items-center">
<div class="col">
<div class="text-weight-medium">Solde a payer: <strong class="text-negative">{{ formatMoney(invoice.outstanding_amount) }}</strong></div>
<div class="text-caption text-grey-7">Echeance: {{ formatDate(invoice.due_date) }}</div>
</div>
<div class="col-auto q-gutter-sm">
<q-btn color="indigo" unelevated label="Payer maintenant" icon="credit_card"
:loading="payLoading" @click="payInvoice('card')" no-caps />
<q-btn outline color="pink-7" label="Payer avec Klarna" icon="account_balance"
:loading="payKlarnaLoading" @click="payInvoice('klarna')" no-caps size="sm" />
</div>
</div>
<div class="q-mt-xs">
<q-checkbox v-model="saveCard" dense size="sm">
<span class="text-caption">Sauvegarder ma carte pour les prochains paiements automatiques</span>
</q-checkbox>
</div>
</q-banner>
<!-- Summary bar -->
<div v-if="invoice" class="row q-col-gutter-sm q-mb-md">
<div class="col-6 col-sm-3">
@ -41,7 +63,7 @@
<!-- Line items table -->
<div v-if="invoice && invoice.items" class="q-mb-md">
<div class="text-subtitle1 text-weight-medium q-mb-sm">Détails</div>
<div class="text-subtitle1 text-weight-medium q-mb-sm">Details</div>
<q-table
:rows="invoice.items"
:columns="itemColumns"
@ -76,7 +98,7 @@
<!-- Jinja rendered preview -->
<div v-if="printHTML" class="q-mb-md">
<div class="text-subtitle1 text-weight-medium q-mb-sm">Aperçu de la facture</div>
<div class="text-subtitle1 text-weight-medium q-mb-sm">Apercu de la facture</div>
<div class="bg-white q-pa-md rounded-borders invoice-preview" style="border: 1px solid #e0e0e0;">
<div v-html="printHTML" />
</div>
@ -90,14 +112,17 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchInvoice, fetchInvoiceHTML, fetchInvoicePDF } from 'src/api/portal'
import { checkoutInvoice } from 'src/api/payments'
import { useFormatters } from 'src/composables/useFormatters'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate, formatMoney } = useFormatters()
@ -106,10 +131,18 @@ const invoice = ref(null)
const printHTML = ref('')
const loading = ref(true)
const downloadingPDF = ref(false)
const payLoading = ref(false)
const payKlarnaLoading = ref(false)
const saveCard = ref(true)
const canPay = computed(() => {
if (!invoice.value) return false
return invoice.value.docstatus === 1 && invoice.value.outstanding_amount > 0
})
const itemColumns = [
{ name: 'item_name', label: 'Description', field: 'item_name', align: 'left' },
{ name: 'qty', label: 'Qté', field: 'qty', align: 'center' },
{ name: 'qty', label: 'Qte', field: 'qty', align: 'center' },
{ name: 'rate', label: 'Prix unitaire', field: 'rate', align: 'right' },
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
]
@ -122,10 +155,29 @@ function statusColor (s) {
}
function statusLabel (s) {
const map = { Paid: 'Payée', Unpaid: 'Impayée', Overdue: 'En retard', 'Partly Paid': 'Partielle' }
const map = { Paid: 'Payee', Unpaid: 'Impayee', Overdue: 'En retard', 'Partly Paid': 'Partielle' }
return map[s] || s
}
async function payInvoice (method) {
const loadRef = method === 'klarna' ? payKlarnaLoading : payLoading
loadRef.value = true
try {
const result = await checkoutInvoice(store.customerId, invoiceName, {
save_card: saveCard.value,
payment_method: method,
})
if (result.url) {
// Redirect to Stripe Checkout
window.location.href = result.url
}
} catch (e) {
$q.notify({ message: 'Erreur: ' + (e.message || 'Paiement impossible'), color: 'negative', icon: 'error' })
} finally {
loadRef.value = false
}
}
async function downloadPDF () {
downloadingPDF.value = true
try {

View File

@ -0,0 +1,46 @@
<template>
<q-page padding class="flex flex-center">
<div style="max-width:520px;text-align:center">
<q-icon name="cancel" color="warning" size="80px" />
<div class="text-h5 text-weight-bold q-mt-lg">Paiement annule</div>
<div class="text-body1 text-grey-7 q-mt-sm">
Votre paiement n'a pas ete complete. Aucun montant n'a ete debite.
</div>
<div v-if="invoiceName" class="text-body2 text-grey-6 q-mt-sm">
Facture: <strong>{{ invoiceName }}</strong>
</div>
<!-- Authenticated -->
<div v-if="auth.authenticated" class="q-mt-xl q-gutter-sm">
<q-btn v-if="invoiceName" color="primary" unelevated label="Reessayer" icon="refresh"
@click="$router.push(`/invoices/${invoiceName}`)" />
<q-btn outline color="primary" label="Mes factures" icon="list" @click="$router.push('/invoices')" />
<q-btn flat color="grey-7" label="Accueil" icon="home" @click="$router.push('/')" />
</div>
<!-- Not authenticated -->
<div v-else class="q-mt-xl">
<q-banner rounded class="bg-blue-1 q-mb-md">
<template #avatar><q-icon name="info" color="primary" /></template>
<div class="text-body2">
Connectez-vous pour reessayer le paiement ou voir vos factures.
</div>
</q-banner>
<q-btn color="primary" unelevated label="Se connecter" icon="login" @click="goToLogin" />
</div>
</div>
</q-page>
</template>
<script setup>
import { useRoute } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const route = useRoute()
const auth = useMagicToken()
const invoiceName = route.query.invoice || ''
function goToLogin () {
window.location.href = 'https://id.gigafibre.ca/'
}
</script>

View File

@ -0,0 +1,39 @@
<template>
<q-page padding class="flex flex-center">
<div style="max-width:520px;text-align:center">
<q-icon name="credit_card" color="positive" size="80px" />
<div class="text-h5 text-weight-bold q-mt-lg">Carte ajoutee!</div>
<div class="text-body1 text-grey-7 q-mt-sm">
Votre carte de paiement a ete enregistree avec succes.
Vous pouvez maintenant activer le paiement automatique.
</div>
<!-- Authenticated -->
<div v-if="auth.authenticated" class="q-mt-xl q-gutter-sm">
<q-btn color="primary" unelevated label="Mon compte" icon="person" @click="$router.push('/me')" />
<q-btn flat color="grey-7" label="Accueil" icon="home" @click="$router.push('/')" />
</div>
<!-- Not authenticated -->
<div v-else class="q-mt-xl">
<q-banner rounded class="bg-blue-1 q-mb-md">
<template #avatar><q-icon name="info" color="primary" /></template>
<div class="text-body2">
Connectez-vous pour gerer vos cartes et activer le paiement automatique.
</div>
</q-banner>
<q-btn color="primary" unelevated label="Se connecter" icon="login" @click="goToLogin" />
</div>
</div>
</q-page>
</template>
<script setup>
import { useMagicToken } from 'src/composables/useMagicToken'
const auth = useMagicToken()
function goToLogin () {
window.location.href = 'https://id.gigafibre.ca/'
}
</script>

View File

@ -0,0 +1,62 @@
<template>
<q-page padding class="flex flex-center">
<div style="max-width:520px;text-align:center">
<q-icon name="check_circle" color="positive" size="80px" />
<div class="text-h5 text-weight-bold q-mt-lg">Paiement recu!</div>
<div class="text-body1 text-grey-7 q-mt-sm">
Merci pour votre paiement. Votre facture sera mise a jour sous peu.
</div>
<div v-if="invoiceName" class="text-body2 text-grey-6 q-mt-sm">
Facture: <strong>{{ invoiceName }}</strong>
</div>
<!-- Authenticated: show full navigation -->
<div v-if="auth.authenticated" class="q-mt-xl q-gutter-sm">
<q-btn v-if="invoiceName" color="primary" unelevated label="Voir la facture" icon="receipt"
@click="$router.push(`/invoices/${invoiceName}`)" />
<q-btn outline color="primary" label="Mes factures" icon="list" @click="$router.push('/invoices')" />
<q-btn flat color="grey-7" label="Accueil" icon="home" @click="$router.push('/')" />
</div>
<!-- Expired token or no auth: show login prompt -->
<div v-else class="q-mt-xl">
<q-banner rounded class="bg-blue-1 q-mb-md">
<template #avatar><q-icon name="info" color="primary" /></template>
<div class="text-body2">
Pour acceder a votre compte et voir vos factures, connectez-vous ou demandez un lien d'acces.
</div>
</q-banner>
<div class="q-gutter-sm">
<q-btn color="primary" unelevated label="Se connecter" icon="login"
@click="goToLogin" />
<q-btn outline color="primary" label="Envoyer un lien d'acces" icon="link"
:loading="sendingLink" @click="requestMagicLink" />
</div>
<div v-if="linkSent" class="text-positive text-body2 q-mt-sm">
<q-icon name="check" /> Lien envoye! Verifiez vos SMS ou courriels.
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { useMagicToken } from 'src/composables/useMagicToken'
const route = useRoute()
const auth = useMagicToken()
const invoiceName = route.query.invoice || ''
const sendingLink = ref(false)
const linkSent = ref(false)
function goToLogin () {
window.location.href = 'https://id.gigafibre.ca/'
}
async function requestMagicLink () {
// Redirect to the OTP/login flow on the portal
window.location.href = 'https://portal.gigafibre.ca/#/'
}
</script>

View File

@ -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') },
],
},
]

View File

@ -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",

View File

@ -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"
},

View File

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

View File

@ -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}`)
}

View File

@ -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 = []

View File

@ -0,0 +1,166 @@
<template>
<div class="nlp-input-bar" :class="{ 'nlp-expanded': showResult }">
<div class="nlp-row">
<q-icon name="auto_awesome" size="18px" color="indigo-5" class="q-mr-xs" />
<input
ref="inputRef"
v-model="text"
class="nlp-text"
:placeholder="placeholder"
@keydown.enter="submit"
@keydown.escape="clear"
:disabled="loading"
/>
<q-spinner v-if="loading" size="16px" color="indigo-5" class="q-mx-xs" />
<q-btn v-else-if="text.trim()" flat dense round size="sm" icon="send" color="indigo-6" @click="submit" />
<q-btn v-if="showResult" flat dense round size="xs" icon="close" color="grey-6" @click="clear" />
</div>
<transition name="nlp-slide">
<div v-if="showResult && result" class="nlp-result">
<div class="nlp-result-action">
<q-icon :name="actionIcon" :color="actionColor" size="16px" class="q-mr-xs" />
<span class="text-weight-bold">{{ result.action_label || result.action }}</span>
<q-badge v-if="result.confidence" :color="result.confidence > 0.8 ? 'green' : result.confidence > 0.5 ? 'orange' : 'red'" class="q-ml-sm">
{{ Math.round(result.confidence * 100) }}%
</q-badge>
</div>
<div v-if="result.explanation" class="text-caption text-grey-7 q-mt-xs">{{ result.explanation }}</div>
<!-- Action-specific details -->
<div v-if="result.action === 'create_job'" class="nlp-details q-mt-sm">
<div v-if="result.subject" class="nlp-detail"><span class="nlp-label">Sujet:</span> {{ result.subject }}</div>
<div v-if="result.tech" class="nlp-detail"><span class="nlp-label">Tech:</span> {{ result.tech }}</div>
<div v-if="result.date" class="nlp-detail"><span class="nlp-label">Date:</span> {{ result.date }}</div>
<div v-if="result.time" class="nlp-detail"><span class="nlp-label">Heure:</span> {{ result.time }}</div>
<div v-if="result.duration" class="nlp-detail"><span class="nlp-label">Durée:</span> {{ result.duration }}h</div>
<div v-if="result.address" class="nlp-detail"><span class="nlp-label">Adresse:</span> {{ result.address }}</div>
</div>
<div v-if="result.action === 'move_job'" class="nlp-details q-mt-sm">
<div v-if="result.from_tech" class="nlp-detail"><span class="nlp-label">De:</span> {{ result.from_tech }}</div>
<div v-if="result.to_tech" class="nlp-detail"><span class="nlp-label">Vers:</span> {{ result.to_tech }}</div>
</div>
<div v-if="result.action === 'redistribute'" class="nlp-details q-mt-sm">
<div v-if="result.absent_tech" class="nlp-detail"><span class="nlp-label">Absent:</span> {{ result.absent_tech }}</div>
</div>
<div class="nlp-actions q-mt-sm">
<q-btn v-if="result.action !== 'query' && result.action !== 'unknown'" dense no-caps unelevated
color="indigo-6" size="sm" icon="check" label="Appliquer" @click="apply" :loading="applying" />
<q-btn flat dense no-caps size="sm" label="Ignorer" color="grey-6" @click="clear" />
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
const props = defineProps({
techNames: { type: Array, default: () => [] },
})
const emit = defineEmits(['action', 'applied'])
const $q = useQuasar()
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const inputRef = ref(null)
const text = ref('')
const loading = ref(false)
const applying = ref(false)
const result = ref(null)
const showResult = computed(() => !!result.value)
const placeholder = 'Ex: "Mets une install chez 123 rue Laval demain 9h pour Marc" ou "Marc est malade, redistribue"...'
const ACTION_ICONS = {
create_job: 'add_task',
move_job: 'swap_horiz',
redistribute: 'group_work',
cancel_job: 'cancel',
query: 'search',
unknown: 'help_outline',
}
const ACTION_COLORS = {
create_job: 'indigo-6',
move_job: 'blue-6',
redistribute: 'orange-8',
cancel_job: 'red',
query: 'grey-7',
unknown: 'grey-5',
}
const actionIcon = computed(() => ACTION_ICONS[result.value?.action] || 'help_outline')
const actionColor = computed(() => ACTION_COLORS[result.value?.action] || 'grey-5')
async function submit () {
const input = text.value.trim()
if (!input || loading.value) return
loading.value = true
result.value = null
try {
const res = await fetch(`${HUB_URL}/ai/dispatch-nlp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: input, tech_names: props.techNames }),
})
if (!res.ok) throw new Error('Erreur AI')
const data = await res.json()
result.value = data
emit('action', data)
} catch (e) {
$q.notify({ type: 'negative', message: `NLP: ${e.message}` })
} finally {
loading.value = false
}
}
function apply () {
if (!result.value) return
applying.value = true
emit('applied', result.value)
// Parent handles the actual dispatch action
setTimeout(() => {
applying.value = false
clear()
}, 500)
}
function clear () {
result.value = null
text.value = ''
inputRef.value?.focus()
}
defineExpose({ focus: () => inputRef.value?.focus() })
</script>
<style scoped>
.nlp-input-bar {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
transition: all 0.2s ease;
}
.nlp-input-bar:focus-within { border-color: #818cf8; box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.15); }
.nlp-expanded { background: #fff; }
.nlp-row { display: flex; align-items: center; padding: 6px 10px; }
.nlp-text {
flex: 1; border: none; outline: none; background: transparent;
font-size: 0.84rem; color: #1e293b; font-family: inherit;
}
.nlp-text::placeholder { color: #94a3b8; }
.nlp-result { padding: 8px 12px; border-top: 1px solid #e2e8f0; }
.nlp-result-action { display: flex; align-items: center; }
.nlp-details { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.nlp-detail { font-size: 0.78rem; color: #475569; }
.nlp-label { font-weight: 600; color: #94a3b8; font-size: 0.72rem; }
.nlp-actions { display: flex; gap: 8px; }
.nlp-slide-enter-active, .nlp-slide-leave-active { transition: max-height 0.2s ease, opacity 0.15s ease; overflow: hidden; }
.nlp-slide-enter-from, .nlp-slide-leave-to { max-height: 0; opacity: 0; }
.nlp-slide-enter-to, .nlp-slide-leave-from { max-height: 300px; opacity: 1; }
</style>

View File

@ -0,0 +1,257 @@
<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent>
<q-card style="width:680px;max-width:95vw">
<!-- Header -->
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col">
<div class="text-subtitle1 text-weight-bold">Nouvelle facture</div>
<div v-if="customer?.customer_name" class="text-caption text-grey-6">{{ customer.customer_name }} ({{ customer.name }})</div>
</div>
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
</q-card-section>
<!-- Form -->
<q-card-section class="q-pt-md q-gutter-sm">
<!-- Dates -->
<div class="row q-gutter-sm">
<q-input v-model="form.posting_date" label="Date de facturation" type="date" dense outlined class="col" />
<q-input v-model="form.due_date" label="Date d'echeance" type="date" dense outlined class="col" />
</div>
<!-- Tax template -->
<q-select v-model="form.taxes_and_charges" :options="taxOptions" label="Taxes"
dense outlined emit-value map-options />
<!-- Income account -->
<q-select v-model="form.income_account" :options="incomeAccountOptions" label="Compte de revenu"
dense outlined emit-value map-options />
<!-- Line items -->
<div class="text-caption text-weight-bold q-mt-sm q-mb-xs">Lignes</div>
<div v-for="(line, idx) in form.items" :key="idx" class="row items-start q-gutter-xs q-mb-xs">
<q-input v-model="line.description" label="Description" dense outlined class="col"
:rules="[v => !!v?.trim() || 'Requis']" />
<q-input v-model.number="line.qty" label="Qte" type="number" dense outlined
style="width:70px" min="1" step="1" />
<q-input v-model.number="line.rate" label="Taux" type="number" dense outlined
style="width:110px" min="0" step="0.01" prefix="$" />
<div style="width:90px" class="q-pt-sm text-right text-weight-medium">
{{ formatMoney(line.qty * line.rate) }}
</div>
<q-btn flat round dense icon="delete" color="red-5" size="sm" class="q-mt-xs"
:disable="form.items.length === 1" @click="removeLine(idx)">
<q-tooltip>Supprimer la ligne</q-tooltip>
</q-btn>
</div>
<q-btn flat dense no-caps color="indigo-6" icon="add" label="Ajouter une ligne"
@click="addLine" class="q-mt-xs" />
<!-- Totals -->
<q-separator class="q-my-sm" />
<div class="totals-grid">
<div class="row justify-between">
<span class="text-grey-7">Sous-total</span>
<span class="text-weight-medium">{{ formatMoney(subtotal) }}</span>
</div>
<div v-if="taxRate > 0" class="row justify-between">
<span class="text-grey-7">Taxes ({{ taxLabel }})</span>
<span class="text-weight-medium">{{ formatMoney(taxAmount) }}</span>
</div>
<div v-if="taxRate > 0 && isTwoPartTax" class="text-caption text-grey-5 q-ml-sm">
TPS 5%: {{ formatMoney(subtotal * 0.05) }} / TVQ 9.975%: {{ formatMoney(subtotal * 0.09975) }}
</div>
<q-separator class="q-my-xs" />
<div class="row justify-between text-subtitle2">
<span class="text-weight-bold">Total</span>
<span class="text-weight-bold">{{ formatMoney(grandTotal) }}</span>
</div>
</div>
</q-card-section>
<!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat label="Annuler" color="grey-7" @click="$emit('update:modelValue', false)" />
<q-btn unelevated label="Creer brouillon" color="indigo-6" icon="save"
:loading="submitting" :disable="!canSubmit" @click="onSubmit" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Notify } from 'quasar'
import { createDoc } from 'src/api/erp'
import { formatMoney } from 'src/composables/useFormatters'
const props = defineProps({
modelValue: { type: Boolean, default: false },
customer: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue', 'created'])
const TAX_QC = 'QC TPS 5% + TVQ 9.975% - T'
const TAX_GST = 'Canada GST 5% - T'
const TAX_NONE = ''
const taxOptions = [
{ label: 'QC TPS 5% + TVQ 9.975%', value: TAX_QC },
{ label: 'Canada GST 5%', value: TAX_GST },
{ label: 'Aucune taxe (Exempt)', value: TAX_NONE },
]
const incomeAccountOptions = [
{ label: '4020 - Mensualite fibre', value: '4020 - Mensualité fibre - T' },
{ label: '4017 - Installation et equipement fibre', value: '4017 - Installation et équipement fibre - T' },
]
const submitting = ref(false)
function defaultLine () {
return { description: '', qty: 1, rate: 0 }
}
function todayStr () {
return new Date().toISOString().slice(0, 10)
}
function addDays (dateStr, days) {
const d = new Date(dateStr)
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const form = ref({
posting_date: todayStr(),
due_date: addDays(todayStr(), 30),
taxes_and_charges: TAX_QC,
income_account: '4020 - Mensualité fibre - T',
items: [defaultLine()],
})
// Reset form when dialog opens
watch(() => props.modelValue, (open) => {
if (open) {
const isExempt = props.customer?.tax_category_legacy === 'Exempt'
form.value = {
posting_date: todayStr(),
due_date: addDays(todayStr(), 30),
taxes_and_charges: isExempt ? TAX_NONE : TAX_QC,
income_account: '4020 - Mensualité fibre - T',
items: [defaultLine()],
}
}
})
function addLine () {
form.value.items.push(defaultLine())
}
function removeLine (idx) {
if (form.value.items.length > 1) {
form.value.items.splice(idx, 1)
}
}
// Computed totals
const subtotal = computed(() =>
form.value.items.reduce((sum, l) => sum + (l.qty || 0) * (l.rate || 0), 0)
)
const isTwoPartTax = computed(() => form.value.taxes_and_charges === TAX_QC)
const taxRate = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return 0.14975
if (form.value.taxes_and_charges === TAX_GST) return 0.05
return 0
})
const taxLabel = computed(() => {
if (form.value.taxes_and_charges === TAX_QC) return '14.975%'
if (form.value.taxes_and_charges === TAX_GST) return '5%'
return ''
})
const taxAmount = computed(() => subtotal.value * taxRate.value)
const grandTotal = computed(() => subtotal.value + taxAmount.value)
const canSubmit = computed(() =>
form.value.items.some(l => l.description?.trim() && l.rate > 0)
)
async function onSubmit () {
if (!canSubmit.value) return
submitting.value = true
try {
const items = form.value.items
.filter(l => l.description?.trim() && l.rate > 0)
.map(l => ({
item_code: 'SVC',
item_name: l.description.trim(),
description: l.description.trim(),
qty: l.qty || 1,
rate: l.rate,
income_account: form.value.income_account,
}))
const payload = {
doctype: 'Sales Invoice',
customer: props.customer?.name,
posting_date: form.value.posting_date,
due_date: form.value.due_date,
company: 'TARGO',
debit_to: 'Comptes clients - T',
currency: 'CAD',
items,
}
// Include tax rows directly (ERPNext tax templates are empty shells)
const taxSel = form.value.taxes_and_charges
if (taxSel === TAX_QC) {
payload.taxes_and_charges = TAX_QC
payload.taxes = [
{
charge_type: 'On Net Total',
account_head: '2300 - TPS perçue - T',
description: 'TPS 5%',
rate: 5,
},
{
charge_type: 'On Net Total',
account_head: '2350 - TVQ perçue - T',
description: 'TVQ 9.975%',
rate: 9.975,
},
]
} else if (taxSel === TAX_GST) {
payload.taxes_and_charges = TAX_GST
payload.taxes = [
{
charge_type: 'On Net Total',
account_head: '2300 - TPS perçue - T',
description: 'TPS 5%',
rate: 5,
},
]
}
const doc = await createDoc('Sales Invoice', payload)
Notify.create({ type: 'positive', message: `Facture ${doc.name} creee (brouillon)`, position: 'top' })
emit('created', doc)
emit('update:modelValue', false)
} catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}`, position: 'top' })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.totals-grid {
max-width: 320px;
margin-left: auto;
}
</style>

View File

@ -14,10 +14,6 @@
</div>
</div>
<slot name="header-actions" />
<q-btn v-if="doctype === 'Sales Invoice'" flat round dense icon="picture_as_pdf"
@click="$emit('open-pdf', docName)" class="q-mr-xs" color="red-7">
<q-tooltip>Voir PDF</q-tooltip>
</q-btn>
<q-btn flat round dense icon="open_in_new" @click="openExternal(erpLinkUrl)" class="q-mr-xs" />
<q-btn flat round dense icon="close" @click="$emit('update:open', false)" />
</q-card-section>
@ -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)"
/>
<!-- Generic fallback -->

View File

@ -0,0 +1,162 @@
<template>
<div v-if="alerts.length" class="outage-alerts-panel">
<div class="outage-alerts-header" @click="expanded = !expanded">
<q-icon name="warning_amber" color="orange-8" size="18px" />
<span class="text-weight-bold">{{ alerts.length }} alerte{{ alerts.length > 1 ? 's' : '' }} réseau</span>
<q-icon :name="expanded ? 'expand_less' : 'expand_more'" size="18px" class="q-ml-auto" />
</div>
<transition name="slide">
<div v-show="expanded" class="outage-alerts-list">
<div v-for="a in alerts" :key="a.id" class="outage-alert-item" :class="'outage-alert--' + a.severity">
<div class="outage-alert-top">
<q-icon :name="a.type === 'olt' ? 'cell_tower' : a.severity === 'critical' ? 'error' : 'warning'"
:color="a.severity === 'critical' ? 'red' : a.severity === 'warning' ? 'orange-8' : 'blue-6'" size="16px" />
<span class="text-weight-medium">
{{ a.type === 'olt' ? '🔴 OLT ' : '' }}{{ a.oltName }}
<span v-if="a.port && a.port !== 'ALL' && a.port !== '—'"> : {{ a.port }}</span>
</span>
<q-badge :color="a.type === 'olt' ? 'red-10' : a.severity === 'critical' ? 'red' : 'orange'" class="q-ml-xs">
<template v-if="a.type === 'olt'">~{{ a.affected_count }} clients</template>
<template v-else>{{ a.affected_count }} client{{ a.affected_count > 1 ? 's' : '' }}</template>
</q-badge>
<q-badge v-if="a.source === 'kuma'" outline color="purple" class="q-ml-xs" style="font-size:0.6rem">Kuma</q-badge>
<span class="text-caption text-grey-6 q-ml-auto">{{ timeAgo(a.ts) }}</span>
</div>
<div v-if="a.type === 'olt'" class="text-caption text-grey-7 q-mt-xs">
OLT complet hors service tous les clients de cet OLT sont potentiellement affectés
</div>
<div v-if="a.ticketId" class="text-caption q-mt-xs">
Ticket: <span class="text-primary cursor-pointer" @click="$emit('open-ticket', a.ticketId)">{{ a.ticketId }}</span>
<q-badge v-if="a.notified" color="green" outline class="q-ml-sm" style="font-size:0.6rem">SMS envoyé</q-badge>
</div>
<div v-if="a.analysis" class="text-caption text-grey-7 q-mt-xs">
{{ a.analysis.outage_type }} {{ a.analysis.action_required }}
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
defineEmits(['open-ticket'])
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const alerts = ref([])
const expanded = ref(true)
let es = null
let pollTimer = null
function timeAgo (ts) {
if (!ts) return ''
const mins = Math.floor((Date.now() - new Date(ts).getTime()) / 60000)
if (mins < 1) return "à l'instant"
if (mins < 60) return `il y a ${mins}m`
const hrs = Math.floor(mins / 60)
return hrs < 24 ? `il y a ${hrs}h` : `il y a ${Math.floor(hrs / 24)}j`
}
function addOrUpdateAlert (data) {
const id = `${data.oltName}:${data.port}`
const idx = alerts.value.findIndex(a => a.id === id)
const alert = { id, ...data, ts: data.ts || new Date().toISOString() }
if (idx >= 0) {
alerts.value[idx] = alert
} else {
alerts.value.unshift(alert)
}
}
function removeAlert (data) {
const id = data.type === 'olt' ? `olt:${data.oltName}` : `${data.oltName}:${data.port}`
alerts.value = alerts.value.filter(a => a.id !== id)
}
// Fetch existing incidents on mount
async function fetchActive () {
try {
const res = await fetch(`${HUB_URL}/ai/active-outages`)
if (res.ok) {
const { incidents } = await res.json()
for (const inc of incidents) {
const id = inc.type === 'olt' ? `olt:${inc.oltName}` : `${inc.oltName}:${inc.port || 'all'}`
const severity = inc.type === 'olt' ? 'critical' : (inc.affected_count >= 3 ? 'critical' : 'warning')
addOrUpdateAlert({ ...inc, id, severity })
}
}
} catch {}
}
function connectSSE () {
if (es) es.close()
es = new EventSource(`${HUB_URL}/sse?topics=network`)
es.addEventListener('outage-alert', e => {
try { addOrUpdateAlert({ ...JSON.parse(e.data), severity: 'critical' }) } catch {}
})
es.addEventListener('port-alert', e => {
try { addOrUpdateAlert({ ...JSON.parse(e.data), severity: 'warning' }) } catch {}
})
es.addEventListener('outage-resolved', e => {
try { removeAlert(JSON.parse(e.data)) } catch {}
})
// OLT-level events (entire OLT down/up Kuma or SNMP)
es.addEventListener('olt-down', e => {
try {
const d = JSON.parse(e.data)
addOrUpdateAlert({ ...d, id: `olt:${d.oltName}`, type: 'olt', port: 'ALL', severity: 'critical' })
} catch {}
})
es.addEventListener('olt-up', e => {
try {
const d = JSON.parse(e.data)
removeAlert({ type: 'olt', oltName: d.oltName })
} catch {}
})
es.addEventListener('kuma-alert', e => {
try {
const d = JSON.parse(e.data)
if (d.status === 0) {
addOrUpdateAlert({ id: `kuma:${d.monitor}`, oltName: d.monitor, port: '—', affected_count: '?', severity: 'warning', ts: new Date().toISOString(), source: 'kuma' })
} else {
alerts.value = alerts.value.filter(a => a.id !== `kuma:${d.monitor}`)
}
} catch {}
})
es.onerror = () => {
// Fallback: poll every 30s
if (!pollTimer) {
pollTimer = setInterval(fetchActive, 30000)
}
}
}
onMounted(() => {
fetchActive()
connectSSE()
})
onUnmounted(() => {
if (es) es.close()
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.outage-alerts-panel { border: 1px solid #fed7aa; background: #fffbeb; border-radius: 8px; overflow: hidden; }
.outage-alerts-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; user-select: none; }
.outage-alerts-header:hover { background: #fef3c7; }
.outage-alerts-list { border-top: 1px solid #fed7aa; }
.outage-alert-item { padding: 8px 12px; border-bottom: 1px solid #fef3c7; }
.outage-alert-item:last-child { border-bottom: none; }
.outage-alert-top { display: flex; align-items: center; gap: 6px; }
.outage-alert--critical { background: #fef2f2; }
.outage-alert--warning { background: #fffbeb; }
.slide-enter-active, .slide-leave-active { transition: max-height 0.2s ease, opacity 0.2s ease; overflow: hidden; }
.slide-enter-from, .slide-leave-to { max-height: 0; opacity: 0; }
.slide-enter-to, .slide-leave-from { max-height: 500px; opacity: 1; }
</style>

View File

@ -170,6 +170,151 @@
<q-spinner size="14px" class="q-mr-xs" v-if="deviceLoading" /> Chargement diagnostic ACS...
</div>
<!-- Advanced WiFi Diagnostic Panel -->
<div v-if="managementIp" class="q-mt-md">
<div class="info-block-title">
<q-icon name="wifi_find" size="16px" class="q-mr-xs" />
Diagnostic WiFi avance
<q-btn flat dense size="sm" :icon="wifiDiag ? 'refresh' : 'play_arrow'" class="q-ml-sm"
:label="wifiDiag ? 'Relancer' : 'Lancer'" :loading="wifiDiagLoading"
@click="runWifiDiagnostic" color="primary" />
<span v-if="wifiDiag" class="text-caption text-grey-5 q-ml-sm">{{ wifiDiag.durationMs }}ms</span>
</div>
<div v-if="wifiDiagLoading && !wifiDiag" class="text-caption text-grey-5 q-py-sm">
<q-spinner size="14px" class="q-mr-xs" /> Connexion au modem via Playwright (~15s)...
</div>
<div v-if="wifiDiagError" class="text-caption text-negative q-py-xs">{{ wifiDiagError }}</div>
<template v-if="wifiDiag">
<!-- Issues panel -->
<div v-if="wifiDiag.issues.length" class="adv-issues q-mb-sm">
<div v-for="(issue, i) in wifiDiag.issues" :key="i" class="adv-issue" :class="'adv-issue--' + issue.severity">
<div class="adv-issue-header">
<q-icon :name="issue.severity === 'critical' ? 'error' : issue.severity === 'warning' ? 'warning' : 'info'" size="16px" />
<span class="text-weight-bold">{{ issue.message }}</span>
</div>
<div class="adv-issue-detail">{{ issue.detail }}</div>
<div class="adv-issue-action"><q-icon name="lightbulb" size="12px" class="q-mr-xs" />{{ issue.action }}</div>
</div>
</div>
<div v-else class="adv-ok q-mb-sm">
<q-icon name="check_circle" size="16px" color="positive" class="q-mr-xs" />
Aucun probleme detecte reseau en bon etat.
</div>
<!-- WAN IPs -->
<div v-if="wifiDiag.wanIPs.length" class="diag-section">
<div class="diag-section-title"><q-icon name="public" size="16px" class="q-mr-xs" />Adresses WAN (ONU)</div>
<div class="ip-grid">
<div v-for="w in wifiDiag.wanIPs" :key="w.ip" class="ip-item">
<span class="ip-role" :class="'ip-role--' + w.role">{{ wanRoleLabel(w.role) }}</span>
<code class="ip-addr">{{ w.ip }}</code>
<span class="text-caption text-grey-5">/{{ maskToCidr(w.mask) }} {{ w.type }}</span>
</div>
</div>
</div>
<!-- Mesh topology -->
<div v-if="wifiDiag.meshNodes.length > 1" class="diag-section">
<div class="diag-section-title"><q-icon name="hub" size="16px" class="q-mr-xs" />Topologie Mesh ({{ wifiDiag.meshNodes.length }} noeuds)</div>
<div class="adv-mesh-nodes">
<div v-for="node in wifiDiag.meshNodes" :key="node.mac" class="adv-mesh-node">
<div class="adv-mesh-header">
<q-badge :color="node.active ? 'green' : 'red'" style="padding: 2px 5px;" />
<span class="text-weight-bold q-ml-xs">{{ node.hostname }}</span>
<span class="text-caption text-grey-6 q-ml-xs">{{ node.model }}</span>
<q-badge v-if="node.isController" outline color="blue-8" class="q-ml-xs" style="font-size:0.6rem;">Routeur</q-badge>
</div>
<div class="adv-mesh-stats">
<span v-if="!node.isController" class="adv-mesh-stat">
<span class="diag-label">Backhaul</span>
<span :style="{ color: backhaulColor(node.backhaul.signal) }">
<q-icon name="signal_cellular_alt" size="12px" /> {{ node.backhaul.signal }}/255
</span>
&middot; {{ node.backhaul.linkRate }} Mbps
&middot; {{ node.backhaul.utilization }}% util.
</span>
<span class="adv-mesh-stat"><span class="diag-label">CPU</span> {{ node.cpu }}%</span>
<span class="adv-mesh-stat"><span class="diag-label">Uptime</span> {{ formatUptime(node.uptime) }}</span>
<span v-if="node.speedDown || node.speedUp" class="adv-mesh-stat">
<span class="diag-label">Debit</span> {{ node.speedDown }} {{ node.speedUp }} Mbps
</span>
</div>
</div>
</div>
</div>
<!-- Radios -->
<div v-if="wifiDiag.radios.length" class="diag-section">
<div class="diag-section-title"><q-icon name="settings_input_antenna" size="16px" class="q-mr-xs" />Radios</div>
<div class="diag-grid">
<template v-for="r in wifiDiag.radios" :key="r.band">
<div class="diag-item">
<span class="diag-label">{{ r.band }}</span>
<span :class="r.status === 'Up' ? 'text-positive' : 'text-negative'">
Ch {{ r.channel }} &middot; {{ r.bandwidth }} &middot; {{ r.standard }}
</span>
</div>
<div class="diag-item">
<span class="diag-label">{{ r.band }} config</span>
<span>Puissance {{ r.txPower }}% &middot; Canal {{ r.autoChannel ? 'auto' : 'fixe' }}</span>
</div>
</template>
</div>
</div>
<!-- Connected clients -->
<div v-if="wifiDiag.clients.length" class="diag-section">
<div class="diag-section-title">
<q-icon name="devices" size="16px" class="q-mr-xs" />
Clients WiFi detailles ({{ wifiDiag.clients.length }})
</div>
<table class="hosts-table adv-clients-table">
<thead>
<tr>
<th>Signal</th>
<th>Nom</th>
<th>Noeud</th>
<th>Bande</th>
<th>Debit /</th>
<th>Perte</th>
</tr>
</thead>
<tbody>
<tr v-for="c in wifiDiag.clients" :key="c.mac" :class="{ 'text-grey-5': !c.active }">
<td>
<div class="adv-signal-bar">
<div class="adv-signal-fill" :style="{ width: signalPercent(c.signal) + '%', background: signalColor(c.signal) }" />
</div>
<span class="text-caption" :style="{ color: signalColor(c.signal) }">{{ c.signal > 0 ? c.signal : '?' }}</span>
</td>
<td>
<span class="text-weight-medium">{{ c.hostname || '—' }}</span>
<div class="text-caption text-grey-5">{{ c.ip }}</div>
</td>
<td class="text-caption">{{ c.meshNode || '—' }}</td>
<td class="text-caption">
<q-badge :color="c.band === '5GHz' ? 'green-2' : c.band === '2.4GHz' ? 'blue-2' : 'grey-3'"
:text-color="c.band === '5GHz' ? 'green-9' : c.band === '2.4GHz' ? 'blue-9' : 'grey-7'"
style="font-size: 0.65rem;">{{ c.band || c.standard || '?' }}</q-badge>
</td>
<td class="text-caption">
{{ Math.round(c.linkDown / 1000) }}/{{ Math.round(c.linkUp / 1000) }} Mbps
</td>
<td>
<span v-if="c.lossPercent > 10" class="text-negative text-weight-bold">{{ c.lossPercent }}%</span>
<span v-else-if="c.lossPercent > 5" class="text-warning text-weight-bold">{{ c.lossPercent }}%</span>
<span v-else-if="c.lossPercent > 0" class="text-caption text-grey-6">{{ c.lossPercent }}%</span>
<span v-else class="text-caption text-grey-5"></span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="doc.olt_name" class="q-mt-md">
<div class="info-block-title">Information OLT</div>
<div class="modal-field-grid">
@ -183,6 +328,35 @@
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
</div>
<!-- Port Neighbor Context shows health of the same OLT port -->
<div v-if="portCtx && portCtx.total > 0" class="q-mt-md">
<div class="info-block-title">
<q-icon name="hub" size="16px" class="q-mr-xs" />
Voisinage port
<q-badge v-if="portCtx.is_mass_outage" color="red" class="q-ml-sm">Panne secteur</q-badge>
<q-badge v-else-if="portCtx.is_warning" color="orange" class="q-ml-sm">Alerte</q-badge>
<q-badge v-else color="green" class="q-ml-sm">Normal</q-badge>
</div>
<div class="port-ctx-bar q-mt-xs">
<div class="port-ctx-fill port-ctx-fill--online" :style="{ width: (portCtx.online / portCtx.total * 100) + '%' }" />
<div class="port-ctx-fill port-ctx-fill--offline" :style="{ width: (portCtx.offline / portCtx.total * 100) + '%' }" />
</div>
<div class="port-ctx-legend text-caption q-mt-xs">
<span class="text-positive text-weight-bold">{{ portCtx.online }}</span> en ligne
<span v-if="portCtx.offline > 0" class="q-ml-md text-negative text-weight-bold">{{ portCtx.offline }}</span>
<span v-if="portCtx.offline > 0"> hors ligne</span>
<span class="text-grey-6 q-ml-md">/ {{ portCtx.total }} sur le port</span>
</div>
<div v-if="portCtx.offline > 0" class="port-ctx-neighbors q-mt-xs">
<div v-for="n in portCtx.neighbors.filter(x => !x.is_self && x.status !== 'online')" :key="n.serial_prefix"
class="port-ctx-neighbor text-caption">
<q-icon name="circle" size="8px" color="red" class="q-mr-xs" />
{{ n.serial_prefix }} {{ n.cause || 'inconnu' }}
<span v-if="n.distance_m" class="text-grey-5 q-ml-xs">{{ n.distance_m }}m</span>
</div>
</div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Acces distant</div>
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span>
@ -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; }
</style>

View File

@ -1,8 +1,45 @@
<template>
<!-- Action bar -->
<div class="row q-gutter-sm q-mb-md q-pa-sm" style="background:#f8fafc;border-radius:8px">
<!-- Draft: Submit -->
<q-btn v-if="isDraft" outline dense no-caps color="green-7" icon="check_circle" label="Soumettre"
size="sm" :loading="actionLoading" @click="submitInvoice">
<q-tooltip>Valider et soumettre cette facture</q-tooltip>
</q-btn>
<!-- Draft: Delete -->
<q-btn v-if="isDraft" outline dense no-caps color="red-6" icon="delete" label="Supprimer"
size="sm" :loading="actionLoading" @click="deleteInvoice">
<q-tooltip>Supprimer ce brouillon</q-tooltip>
</q-btn>
<!-- Submitted: Cancel -->
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="orange-8" icon="cancel" label="Annuler"
size="sm" :loading="actionLoading" @click="cancelInvoice">
<q-tooltip>Annuler cette facture</q-tooltip>
</q-btn>
<!-- Submitted: Credit Note -->
<q-btn v-if="isSubmitted && !doc.is_return" outline dense no-caps color="red-7" icon="replay" label="Note de credit"
size="sm" :loading="actionLoading" @click="createCreditNote">
<q-tooltip>Creer une note de credit (renversement)</q-tooltip>
</q-btn>
<!-- Submitted unpaid: Send payment link -->
<q-btn v-if="isUnpaid" outline dense no-caps color="indigo-6" icon="send" label="Lien paiement"
size="sm" :loading="sendingLink" @click="sendPaymentLink">
<q-tooltip>Envoyer un lien de paiement par SMS et courriel</q-tooltip>
</q-btn>
<!-- Submitted unpaid: Charge saved card -->
<q-btn v-if="isUnpaid" outline dense no-caps color="teal-7" icon="credit_card" label="Charger carte"
size="sm" :loading="chargingCard" @click="chargeSavedCard">
<q-tooltip>Debiter la carte enregistree du client</q-tooltip>
</q-btn>
<!-- PDF -->
<q-btn outline dense no-caps color="red-5" icon="picture_as_pdf" label="PDF"
size="sm" @click="$emit('open-pdf', docName)" />
</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
<div class="mf"><span class="mf-label">Echeance</span>{{ doc.due_date || '---' }}</div>
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="invStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="invStatusClass(doc.status)">{{ localStatus || doc.status }}</span></div>
<div class="mf"><span class="mf-label">Total HT</span>{{ formatMoney(doc.net_total) }}</div>
<div class="mf"><span class="mf-label">Taxes</span>{{ formatMoney(doc.total_taxes_and_charges) }}</div>
<div class="mf"><span class="mf-label">Total TTC</span><strong>{{ formatMoney(doc.grand_total) }}</strong></div>
@ -48,15 +85,234 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { Notify, useQuasar } from 'quasar'
import { formatMoney, formatDateShort } from 'src/composables/useFormatters'
import { invStatusClass } from 'src/composables/useStatusClasses'
import { invItemCols } from 'src/config/table-columns'
import { authFetch } from 'src/api/auth'
import { deleteDoc, getDoc, updateDoc } from 'src/api/erp'
import { BASE_URL } from 'src/config/erpnext'
import InlineField from 'src/components/shared/InlineField.vue'
defineProps({
const HUB_URL = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const props = defineProps({
doc: { type: Object, required: true },
docName: String,
comments: { type: Array, default: () => [] },
})
defineEmits(['navigate'])
const emit = defineEmits(['navigate', 'open-pdf', 'deleted'])
const $q = useQuasar()
const actionLoading = ref(false)
const localStatus = ref(null)
const sendingLink = ref(false)
const chargingCard = ref(false)
const isDraft = computed(() => props.doc.docstatus === 0)
const isSubmitted = computed(() => props.doc.docstatus === 1)
const isUnpaid = computed(() => isSubmitted.value && !props.doc.is_return && props.doc.outstanding_amount > 0)
// ERPNext workflow calls
async function erpCall (method, args) {
const res = await authFetch(BASE_URL + '/api/method/' + method, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
const json = await res.json()
if (!res.ok) {
const msg = json?.exception?.split('\n')[0] || json?._server_messages || json?.message || 'Erreur ' + res.status
throw new Error(msg)
}
return json
}
// Hub API calls (for payment operations)
async function hubPost (path, body) {
const res = await fetch(HUB_URL + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'Hub error ' + res.status)
return data
}
async function submitInvoice () {
$q.dialog({
title: 'Soumettre cette facture ?',
message: `${props.docName}${formatMoney(props.doc.grand_total)}\nLa facture sera validee et le solde sera exigible.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'green-7', label: 'Soumettre', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
// Fetch the full doc, set docstatus=1, then amend via frappe.client.submit
const fullDoc = await getDoc('Sales Invoice', props.docName)
fullDoc.docstatus = 1
await erpCall('frappe.client.submit', { doc: fullDoc })
props.doc.docstatus = 1
localStatus.value = 'Unpaid'
props.doc.status = 'Unpaid'
Notify.create({ type: 'positive', message: `Facture ${props.docName} soumise`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function cancelInvoice () {
$q.dialog({
title: 'Annuler cette facture ?',
message: `${props.docName}${formatMoney(props.doc.grand_total)}\nLa facture sera annulee et le solde remis a zero.`,
cancel: { flat: true, label: 'Non' },
ok: { color: 'orange-8', label: 'Annuler la facture', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
await erpCall('frappe.client.cancel', {
doctype: 'Sales Invoice',
name: props.docName,
})
props.doc.docstatus = 2
localStatus.value = 'Cancelled'
props.doc.status = 'Cancelled'
Notify.create({ type: 'positive', message: `Facture ${props.docName} annulee`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function deleteInvoice () {
$q.dialog({
title: 'Supprimer ce brouillon ?',
message: `${props.docName} sera supprime definitivement.`,
cancel: { flat: true, label: 'Non' },
ok: { color: 'red-6', label: 'Supprimer', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
await deleteDoc('Sales Invoice', props.docName)
Notify.create({ type: 'positive', message: `Facture ${props.docName} supprimee`, position: 'top' })
emit('deleted', props.docName)
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function createCreditNote () {
$q.dialog({
title: 'Creer une note de credit ?',
message: `Un renversement complet de ${props.docName} (${formatMoney(props.doc.grand_total)}) sera cree.\nCela annule le solde de cette facture.`,
cancel: { flat: true, label: 'Non' },
ok: { color: 'red-7', label: 'Creer note de credit', flat: true },
persistent: true,
}).onOk(async () => {
actionLoading.value = true
try {
const result = await erpCall('erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return', {
source_name: props.docName,
})
// result.message is the new Credit Note doc (unsaved)
const creditDoc = result.message
if (!creditDoc) throw new Error('No credit note returned')
// Save the credit note
const saveRes = await authFetch(BASE_URL + '/api/resource/Sales%20Invoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creditDoc),
})
const saved = await saveRes.json()
if (!saveRes.ok) throw new Error(saved?.exception?.split('\n')[0] || 'Save failed')
const cnName = saved.data?.name
if (!cnName) throw new Error('Credit note save returned no name')
// Submit the credit note fetch the full saved doc first
const cnDoc = await getDoc('Sales Invoice', cnName)
cnDoc.docstatus = 1
await erpCall('frappe.client.submit', { doc: cnDoc })
Notify.create({ type: 'positive', message: `Note de credit ${cnName} creee et soumise`, position: 'top', timeout: 5000 })
emit('navigate', 'Sales Invoice', cnName, 'Note de credit ' + cnName)
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
actionLoading.value = false
}
})
}
async function sendPaymentLink () {
$q.dialog({
title: 'Envoyer un lien de paiement',
message: `Le client recevra un lien par SMS et courriel pour payer la facture ${props.docName} (${formatMoney(props.doc.outstanding_amount)}).`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'indigo-6', label: 'Envoyer', flat: true },
persistent: true,
}).onOk(async () => {
sendingLink.value = true
try {
const result = await hubPost('/payments/send-link', {
customer: props.doc.customer,
invoice: props.docName,
channel: 'both',
})
const sentVia = result.sent?.join(' et ') || 'envoi'
Notify.create({ type: 'positive', message: `Lien de paiement envoye via ${sentVia}`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
sendingLink.value = false
}
})
}
async function chargeSavedCard () {
$q.dialog({
title: 'Charger la carte du client ?',
message: `Debiter ${formatMoney(props.doc.outstanding_amount)} de la carte enregistree du client pour la facture ${props.docName}.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'teal-7', label: 'Charger', flat: true },
persistent: true,
}).onOk(async () => {
chargingCard.value = true
try {
const result = await hubPost('/payments/charge', {
customer: props.doc.customer,
invoice: props.docName,
})
if (result.ok && result.status === 'succeeded') {
Notify.create({ type: 'positive', message: `Paiement de ${formatMoney(result.amount)} recu (${result.payment_intent})`, position: 'top' })
// Refresh the invoice doc
try {
const fresh = await getDoc('Sales Invoice', props.docName)
Object.assign(props.doc, { outstanding_amount: fresh.outstanding_amount, status: fresh.status })
localStatus.value = fresh.status
} catch {}
} else {
Notify.create({ type: 'warning', message: `Paiement en attente: ${result.status}`, position: 'top' })
}
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
} finally {
chargingCard.value = false
}
})
}
</script>

View File

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

View File

@ -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 {

View File

@ -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,

View File

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

View File

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

View File

@ -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.

View File

@ -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([])

View File

@ -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.

View File

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

View File

@ -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'

View File

@ -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')

View File

@ -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' },

View File

@ -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',

View File

@ -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'

View File

@ -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
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 || ''
// 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
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',
})

View File

@ -128,15 +128,50 @@
</q-list>
</q-menu>
</div>
<div class="device-icon-chip device-add-chip" @click="openAddEquipment(loc)">
<div class="device-icon-chip device-add-chip">
<q-icon name="add" size="20px" />
<q-tooltip>Ajouter un equipement</q-tooltip>
<q-tooltip>Ajouter</q-tooltip>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<q-list dense style="min-width:220px">
<q-item clickable v-close-popup @click="openAddService(loc)">
<q-item-section avatar><q-icon name="wifi" size="18px" color="blue-6" /></q-item-section>
<q-item-section>Forfait / Service</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openAddService(loc, 'rabais')">
<q-item-section avatar><q-icon name="sell" size="18px" color="red-5" /></q-item-section>
<q-item-section>Rabais / Crédit</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="openAddEquipment(loc)">
<q-item-section avatar><q-icon name="router" size="18px" color="teal-6" /></q-item-section>
<q-item-section>Équipement</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
</div>
</div>
<div class="info-block subs-block">
<div class="info-block-title">Abonnements ({{ locSubs(loc.name).length }})</div>
<div class="info-block-title" style="display:flex;align-items:center">
Abonnements ({{ locSubs(loc.name).length }})
<q-btn flat dense round size="xs" icon="add" color="indigo-6" class="q-ml-xs"
style="margin-top:-2px">
<q-tooltip>Ajouter un service</q-tooltip>
<q-menu anchor="bottom left" self="top left" :offset="[0, 4]">
<q-list dense style="min-width:200px">
<q-item clickable v-close-popup @click="openAddService(loc)">
<q-item-section avatar><q-icon name="wifi" size="18px" color="blue-6" /></q-item-section>
<q-item-section>Forfait / Service</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="openAddService(loc, 'rabais')">
<q-item-section avatar><q-icon name="sell" size="18px" color="red-5" /></q-item-section>
<q-item-section>Rabais / Crédit</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<div v-if="!locSubs(loc.name).length" class="text-caption text-grey-5">Aucun</div>
<template v-if="locSubsMonthly(loc.name).length">
@ -369,10 +404,17 @@
<q-expansion-item v-model="sectionsOpen.invoices" header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%">
<div class="section-title row items-center" style="font-size:1rem;width:100%">
<q-icon name="receipt_long" size="20px" class="q-mr-xs" />
Factures ({{ invoices.length }}{{ !invoicesExpanded ? '+' : '' }})
<span v-if="totalOutstanding > 0" class="text-caption text-red q-ml-sm">Solde: {{ formatMoney(totalOutstanding) }}</span>
<q-space />
<div @click.stop>
<q-btn flat dense size="sm" icon="add" color="indigo-6" no-caps
label="Facture" @click="newInvoiceOpen = true">
<q-tooltip>Creer une facture</q-tooltip>
</q-btn>
</div>
</div>
</template>
<div v-if="!invoices.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune facture</div>
@ -422,6 +464,15 @@
<template #body-cell-paid_amount="props">
<q-td :props="props" class="text-right">{{ formatMoney(props.row.paid_amount) }}</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" @click.stop>
<q-btn v-if="props.row.reference_no && props.row.reference_no.startsWith('pi_')"
flat round dense size="xs" icon="replay" color="orange-7" :loading="refunding"
@click="confirmRefund(props.row)">
<q-tooltip>Rembourser via Stripe</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<div v-if="!paymentsExpanded && payments.length >= 5" class="text-center q-pa-xs">
<q-btn flat dense no-caps color="indigo-6" :loading="loadingMorePayments"
@ -430,6 +481,154 @@
</div>
</q-expansion-item>
<!-- VoIP Lines -->
<q-expansion-item v-model="sectionsOpen.voip" header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%">
<q-icon name="phone" size="20px" class="q-mr-xs" />
Lignes VoIP ({{ voipLines.length }})
<span v-if="voipLines.filter(v => !v.e911_synced).length" class="text-caption text-orange q-ml-sm">
{{ voipLines.filter(v => !v.e911_synced).length }} sans 911
</span>
</div>
</template>
<div v-if="!voipLines.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune ligne VoIP</div>
<div v-else class="ops-card q-mb-md">
<q-table :rows="voipLines" :columns="voipCols" row-key="name"
flat dense class="ops-table clickable-table"
:pagination="{ rowsPerPage: 20 }"
@row-click="(_, row) => openModal('VoIP Line', row.name, 'VoIP ' + row.did)">
<template #body-cell-status="props">
<q-td :props="props">
<span class="ops-badge" :class="props.row.status === 'Active' ? 'open' : 'inactive'">{{ props.row.status }}</span>
</q-td>
</template>
<template #body-cell-e911_synced="props">
<q-td :props="props">
<q-icon :name="props.row.e911_synced ? 'check_circle' : 'warning'" size="16px"
:color="props.row.e911_synced ? 'green-6' : 'orange-7'" />
</q-td>
</template>
</q-table>
</div>
</q-expansion-item>
<!-- Payment Methods + Actions -->
<q-expansion-item v-model="sectionsOpen.paymentMethods" header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%">
<q-icon name="credit_card" size="20px" class="q-mr-xs" />
Paiement ({{ paymentMethods.length }})
<span v-if="paymentMethods.some(p => p.is_auto_ppa)" class="ops-badge open q-ml-sm" style="font-size:10px">PPA</span>
<q-space />
<div class="q-gutter-x-xs" @click.stop>
<q-btn flat dense size="sm" icon="send" color="indigo-6" no-caps :loading="sendingLink"
@click="sendPaymentLink('both')">
<q-tooltip>Envoyer lien de paiement (SMS + Email)</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="bolt" color="orange-8" no-caps :loading="chargingCard"
@click="chargeCard()">
<q-tooltip>Prélever le solde maintenant</q-tooltip>
</q-btn>
</div>
</div>
</template>
<div class="ops-card q-mb-md">
<!-- Action buttons row -->
<div class="row q-gutter-sm q-mb-md q-pa-sm" style="background:#f8fafc;border-radius:8px">
<q-btn outline dense no-caps color="indigo-6" icon="open_in_new" label="Portail Stripe"
size="sm" @click="openPortal">
<q-tooltip>Ouvrir le portail Stripe pour gérer la carte</q-tooltip>
</q-btn>
<q-btn outline dense no-caps color="green-7" icon="add_card" label="Ajouter carte"
size="sm" @click="createCheckout">
<q-tooltip>Créer un lien Stripe Checkout (copié au presse-papier)</q-tooltip>
</q-btn>
<q-btn outline dense no-caps icon="sms" label="Lien SMS" size="sm"
color="blue-7" :loading="sendingLink" @click="sendPaymentLink('sms')">
<q-tooltip>Envoyer lien de paiement par SMS</q-tooltip>
</q-btn>
<q-btn outline dense no-caps icon="email" label="Lien Email" size="sm"
color="purple-6" :loading="sendingLink" @click="sendPaymentLink('email')">
<q-tooltip>Envoyer lien de paiement par Email</q-tooltip>
</q-btn>
</div>
<div v-if="!paymentMethods.length" class="text-center text-grey-6 q-pa-md">Aucune méthode de paiement</div>
<q-table v-else :rows="paymentMethods" :columns="paymentMethodCols" row-key="name"
flat dense class="ops-table clickable-table"
:pagination="{ rowsPerPage: 10 }"
@row-click="(_, row) => openModal('Payment Method', row.name, row.provider)">
<template #body-cell-is_auto_ppa="props">
<q-td :props="props" @click.stop>
<q-toggle v-if="props.row.provider === 'Stripe'" dense size="sm"
:model-value="!!props.row.is_auto_ppa" :loading="togglingPpa"
@update:model-value="v => togglePpa(v).then(ok => { if (ok) props.row.is_auto_ppa = v ? 1 : 0 })" />
<q-icon v-else-if="props.row.is_auto_ppa" name="autorenew" size="16px" color="green-6" />
</q-td>
</template>
<template #body-cell-provider="props">
<q-td :props="props">
<q-icon :name="props.row.provider === 'Stripe' ? 'credit_card' : props.row.provider === 'Bank Draft' ? 'account_balance' : 'payment'"
size="14px" class="q-mr-xs" />
{{ props.row.provider }}
</q-td>
</template>
</q-table>
</div>
</q-expansion-item>
<!-- Payment Arrangements -->
<q-expansion-item v-model="sectionsOpen.arrangements" header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%">
<q-icon name="handshake" size="20px" class="q-mr-xs" />
Ententes de paiement ({{ arrangements.length }})
<span v-if="arrangements.filter(a => a.status === 'Open').length" class="text-caption text-blue q-ml-sm">
{{ arrangements.filter(a => a.status === 'Open').length }} actives
</span>
</div>
</template>
<div v-if="!arrangements.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune entente</div>
<div v-else class="ops-card q-mb-md">
<q-table :rows="arrangements" :columns="arrangementCols" row-key="name"
flat dense class="ops-table clickable-table"
:pagination="{ rowsPerPage: 10, sortBy: 'date_agreed', descending: true }"
@row-click="(_, row) => openModal('Payment Arrangement', row.name, 'Entente ' + row.name)">
<template #body-cell-status="props">
<q-td :props="props">
<span class="ops-badge" :class="props.row.status === 'Open' ? 'open' : props.row.status === 'Completed' ? 'paid' : 'inactive'">{{ props.row.status }}</span>
</q-td>
</template>
<template #body-cell-total_amount="props">
<q-td :props="props" class="text-right">{{ formatMoney(props.row.total_amount) }}</q-td>
</template>
</q-table>
</div>
</q-expansion-item>
<!-- Soumissions / Quotations -->
<q-expansion-item v-model="sectionsOpen.quotations" header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%">
<q-icon name="request_quote" size="20px" class="q-mr-xs" />
Soumissions ({{ quotations.length }})
</div>
</template>
<div v-if="!quotations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune soumission</div>
<div v-else class="ops-card q-mb-md">
<q-table :rows="quotations" :columns="quotationCols" row-key="name"
flat dense class="ops-table clickable-table"
:pagination="{ rowsPerPage: 10, sortBy: 'transaction_date', descending: true }"
@row-click="(_, row) => openModal('Quotation', row.name, 'Soumission ' + row.name)">
<template #body-cell-grand_total="props">
<q-td :props="props" class="text-right">{{ formatMoney(props.row.grand_total) }}</q-td>
</template>
</q-table>
</div>
</q-expansion-item>
</div>
<div class="col-12 col-lg-4">
@ -455,6 +654,84 @@
<q-btn flat color="indigo-6" label="Retour" icon="arrow_back" @click="$router.push('/clients')" class="q-mt-md" />
</div>
<!-- Add Service / Subscription dialog -->
<q-dialog v-model="addServiceOpen" persistent>
<q-card style="width:560px;max-width:95vw">
<q-card-section class="row items-center q-pb-sm">
<q-icon :name="addServiceMode === 'rabais' ? 'sell' : 'wifi'" size="22px"
:color="addServiceMode === 'rabais' ? 'red-5' : 'indigo-6'" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">
{{ addServiceMode === 'rabais' ? 'Ajouter un rabais / crédit' : 'Ajouter un forfait / service' }}
</div>
<q-space />
<q-badge v-if="addServiceLoc" color="indigo-1" text-color="indigo-8" class="q-pa-xs">
{{ addServiceLoc.address_line || addServiceLoc.location_name }}
</q-badge>
</q-card-section>
<q-separator />
<q-card-section>
<div class="row q-col-gutter-sm">
<!-- Plan search (searches Subscription Plans linked to Items) -->
<div class="col-12">
<q-select v-model="newService.plan" label="Forfait / Plan (recherche catalogue)"
outlined dense use-input input-debounce="300" emit-value map-options
:options="planSearchResults" option-label="label" option-value="value"
@filter="searchPlans" clearable
@update:model-value="onPlanSelected">
<template #no-option>
<q-item>
<q-item-section class="text-grey">Tapez pour rechercher un forfait...</q-item-section>
</q-item>
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>{{ scope.opt.item_name }}</q-item-label>
<q-item-label caption>{{ scope.opt.item_code }} · {{ scope.opt.item_group || '' }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label v-if="scope.opt.cost" class="text-weight-bold">{{ formatMoney(scope.opt.cost) }}/m</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</div>
<!-- Description override -->
<div class="col-12">
<q-input v-model="newService.description" label="Description (override)" outlined dense
:placeholder="newService.item_name || 'Laisser vide pour utiliser le nom du plan'" />
</div>
<!-- Price override + start date -->
<div class="col-6">
<q-input v-model.number="newService.price" :label="addServiceMode === 'rabais' ? 'Montant du rabais' : 'Prix mensuel'"
outlined dense type="number" step="0.01"
:prefix="addServiceMode === 'rabais' ? '-' : ''" suffix="$">
<template #hint>
<span v-if="addServiceMode === 'rabais'" class="text-red">Le montant sera négatif (crédit)</span>
</template>
</q-input>
</div>
<div class="col-6">
<q-input v-model="newService.start_date" label="Date de début" outlined dense type="date" />
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" @click="addServiceOpen = false" />
<q-btn unelevated :color="addServiceMode === 'rabais' ? 'red-5' : 'indigo-6'"
:icon="addServiceMode === 'rabais' ? 'sell' : 'add'"
:label="addServiceMode === 'rabais' ? 'Ajouter le rabais' : 'Ajouter le service'"
:loading="addingService" :disable="!newService.plan && !newService.description"
@click="createService" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="addEquipOpen" persistent>
<q-card style="width:520px;max-width:95vw">
<q-card-section class="row items-center q-pb-sm">
@ -557,6 +834,8 @@
:context="ticketContext" :locations="locations"
@created="onTicketCreated" />
<CreateInvoiceModal v-model="newInvoiceOpen" :customer="customer" @created="onInvoiceCreated" />
<DetailModal
v-model:open="modalOpen" :loading="modalLoading" :doctype="modalDoctype"
:doc-name="modalDocName" :title="modalTitle" :doc="modalDoc"
@ -574,7 +853,7 @@
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable'
import { deleteDoc } from 'src/api/erp'
import { deleteDoc, createDoc, listDocs, getDoc, updateDoc } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
@ -582,7 +861,7 @@ import { useDetailModal } from 'src/composables/useDetailModal'
import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
import { useCustomerNotes } from 'src/composables/useCustomerNotes'
import { invoiceCols, paymentCols, ticketCols } from 'src/config/table-columns'
import { invoiceCols, paymentCols, ticketCols, voipCols, paymentMethodCols, arrangementCols, quotationCols } from 'src/config/table-columns'
import { deviceLucideIcon } from 'src/config/device-icons'
import DetailModal from 'src/components/shared/DetailModal.vue'
import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
@ -591,8 +870,10 @@ import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
import InlineField from 'src/components/shared/InlineField.vue'
import ChatterPanel from 'src/components/customer/ChatterPanel.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
import { usePermissions } from 'src/composables/usePermissions'
import { usePaymentActions } from 'src/composables/usePaymentActions'
import { useClientData } from 'src/composables/useClientData'
import { useEquipmentActions } from 'src/composables/useEquipmentActions'
import { locInlineFields, equipTypeOptions, equipStatusOptions, defaultSectionsOpen, phoneLabelMap } from 'src/data/client-constants'
@ -617,7 +898,8 @@ const {
const {
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,
} = useClientData({ equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll: () => invalidateAll(), fetchStatus, fetchOltStatus })
@ -635,6 +917,12 @@ const {
const { onNoteAdded } = useCustomerNotes(comments, customer)
const {
sendingLink, chargingCard, togglingPpa, refunding,
sendPaymentLink, chargeCard, togglePpa, openPortal, createCheckout,
refundPayment,
} = usePaymentActions(customer)
const {
scannerState, addEquipOpen, addEquipLoc, addingEquip,
equipLookupResult, equipLookingUp, newEquip,
@ -642,6 +930,178 @@ const {
applyScannedCode, createEquipment, linkExistingEquipment,
} = useEquipmentActions(customer, equipment)
// Add Service / Subscription (native ERPNext Subscription)
const addServiceOpen = ref(false)
const addServiceLoc = ref(null)
const addServiceMode = ref('service') // 'service' | 'rabais'
const addingService = ref(false)
const planSearchResults = ref([])
const newService = reactive({
plan: null, // Subscription Plan name
item_code: '',
item_name: '',
item_group: '',
description: '',
price: 0,
start_date: new Date().toISOString().slice(0, 10),
})
function openAddService (loc, mode = 'service') {
addServiceLoc.value = loc
addServiceMode.value = mode
Object.assign(newService, {
plan: null, item_code: '', item_name: '', item_group: '',
description: '',
price: 0,
start_date: new Date().toISOString().slice(0, 10),
})
addServiceOpen.value = true
}
async function searchPlans (val, update) {
if (!val || val.length < 2) { update(() => { planSearchResults.value = [] }); return }
try {
// Search Subscription Plans (which link to Items with prices)
const plans = await listDocs('Subscription Plan', {
filters: {},
or_filters: [
['name', 'like', `%${val}%`],
['item', 'like', `%${val}%`],
['plan_name', 'like', `%${val}%`],
],
fields: ['name', 'item', 'cost', 'plan_name'],
limit: 20,
orderBy: 'plan_name asc',
})
// Enrich with Item info
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
}
update(() => {
planSearchResults.value = plans.map(p => {
const item = itemMap[p.item] || {}
return {
label: `${item.item_name || p.plan_name || p.item} (${p.item})`,
value: p.name,
item_code: p.item,
item_name: item.item_name || p.plan_name || p.item,
item_group: item.item_group || '',
cost: parseFloat(p.cost || 0),
}
})
})
} catch {
update(() => { planSearchResults.value = [] })
}
}
function onPlanSelected (val) {
if (!val) return
const plan = planSearchResults.value.find(p => p.value === val)
if (plan) {
newService.item_code = plan.item_code
newService.item_name = plan.item_name
newService.item_group = plan.item_group
if (!newService.description) newService.description = plan.item_name
if (!newService.price) {
newService.price = addServiceMode.value === 'rabais' ? Math.abs(plan.cost) : plan.cost
}
}
}
async function createService () {
if (!addServiceLoc.value || (!newService.plan && !newService.description)) return
addingService.value = true
try {
const price = addServiceMode.value === 'rabais'
? -Math.abs(newService.price || 0)
: (newService.price || 0)
const custId = customer.value.name
const locName = addServiceLoc.value.name
const today = newService.start_date
// Find or create the customer's active Subscription
const existing = await listDocs('Subscription', {
filters: { party_type: 'Customer', party: custId, status: 'Active' },
fields: ['name'],
limit: 1,
})
let subDoc
if (existing.length) {
// Add plan row to existing Subscription
subDoc = await getDoc('Subscription', existing[0].name)
const newPlan = {
plan: newService.plan || '',
qty: 1,
service_location: locName,
custom_description: newService.description || newService.item_name || '',
actual_price: price || 0,
}
subDoc.plans.push(newPlan)
subDoc = await updateDoc('Subscription', subDoc.name, { plans: subDoc.plans })
} else {
// Create new Subscription for this customer
subDoc = await createDoc('Subscription', {
party_type: 'Customer',
party: custId,
company: 'TARGO',
start_date: today,
end_date: new Date(new Date(today).setFullYear(new Date(today).getFullYear() + 5)).toISOString().slice(0, 10),
follow_calendar_months: 1,
generate_invoice_at: 'Beginning of the current subscription period',
days_until_due: 30,
submit_invoice: 0,
sales_tax_template: 'QC TPS 5% + TVQ 9.975% - T',
plans: [{
plan: newService.plan || '',
qty: 1,
service_location: locName,
custom_description: newService.description || newService.item_name || '',
actual_price: price || 0,
}],
})
}
// Add to local subscriptions list for immediate UI update
const lastPlan = (subDoc.plans || []).at(-1) || {}
subscriptions.value.push({
name: lastPlan.name || subDoc.name,
subscription: subDoc.name,
plan_name: newService.plan || '',
item_code: newService.item_code || '',
item_name: newService.description || newService.item_name || '',
item_group: newService.item_group || '',
custom_description: newService.description || '',
actual_price: price,
service_location: locName,
billing_frequency: 'M',
status: 'Active',
start_date: today,
cancel_at_period_end: 0,
qty: 1,
})
invalidateCache(locName)
invalidateAll()
const label = newService.description || newService.item_name || 'Service'
Notify.create({ type: 'positive', message: `${label} ajouté (${formatMoney(price)}/m)`, position: 'top', timeout: 3000 })
addServiceOpen.value = false
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Création impossible'), position: 'top', timeout: 4000 })
} finally {
addingService.value = false
}
}
function onDispatchCreated (job) { modalDispatchJobs.value.push(job) }
function formatTimeAgo (dateStr) {
@ -712,6 +1172,7 @@ function confirmDeleteLocation (loc) {
function onEntityDeleted (docName) {
equipment.value = equipment.value.filter(e => e.name !== docName)
invoices.value = invoices.value.filter(i => i.name !== docName)
}
const sectionsOpen = ref({ ...defaultSectionsOpen })
@ -767,6 +1228,38 @@ function onTicketCreated (doc) {
openModal('Issue', doc.name, doc.subject)
}
const newInvoiceOpen = ref(false)
function onInvoiceCreated (doc) {
invoices.value.unshift({
name: doc.name,
posting_date: doc.posting_date,
due_date: doc.due_date,
grand_total: doc.grand_total || 0,
outstanding_amount: doc.outstanding_amount || doc.grand_total || 0,
status: doc.status || 'Draft',
is_return: doc.is_return || 0,
return_against: doc.return_against || '',
})
openModal('Sales Invoice', doc.name, 'Facture ' + doc.name)
}
function confirmRefund (pe) {
$q.dialog({
title: 'Rembourser ce paiement ?',
message: `${pe.name}${formatMoney(pe.paid_amount)} (${pe.reference_no})\nCe remboursement sera envoyé via Stripe et enregistré dans ERPNext.`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'orange-8', label: 'Rembourser', flat: true },
persistent: true,
}).onOk(async () => {
const res = await refundPayment(pe.name)
if (res?.ok) {
// Refresh payments list
loadCustomer(props.id)
}
})
}
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
onMounted(() => loadCustomer(props.id))
</script>

View File

@ -1,5 +1,8 @@
<template>
<q-page padding>
<!-- Real-time outage alerts -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
<!-- KPI cards -->
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
@ -38,21 +41,67 @@
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Facturation récurrente</div>
<div class="row items-center q-gutter-md">
<!-- Step indicators -->
<div class="row items-center q-gutter-x-sm q-mb-sm">
<q-chip :color="billingStep >= 1 ? 'primary' : 'grey-4'" text-color="white" dense size="sm" icon="receipt_long">1. Générer</q-chip>
<q-icon name="chevron_right" size="16px" color="grey-5" />
<q-chip :color="billingStep >= 2 ? 'amber-8' : 'grey-4'" text-color="white" dense size="sm" icon="publish">2. Soumettre</q-chip>
<q-icon name="chevron_right" size="16px" color="grey-5" />
<q-chip :color="billingStep >= 3 ? 'teal-7' : 'grey-4'" text-color="white" dense size="sm" icon="credit_score">3. PPA</q-chip>
</div>
<!-- Draft count indicator -->
<div v-if="draftInvoiceCount > 0" class="q-mb-sm">
<q-banner dense rounded class="bg-amber-1 text-amber-9" style="font-size:0.8rem">
<template #avatar><q-icon name="pending_actions" color="amber-8" /></template>
{{ draftInvoiceCount }} facture(s) en brouillon à soumettre
</q-banner>
</div>
<div class="row items-center q-gutter-sm">
<q-btn
label="Générer les factures"
label="Générer"
color="primary"
icon="receipt_long"
:loading="runningBilling"
dense no-caps
@click="runBilling"
/>
>
<q-tooltip>Génère les factures récurrentes pour les abonnements actifs</q-tooltip>
</q-btn>
<q-btn
label="Soumettre"
color="amber-8"
icon="publish"
:loading="submittingDrafts"
:disable="draftInvoiceCount === 0"
dense no-caps
@click="submitDrafts"
>
<q-tooltip>Soumet les {{ draftInvoiceCount }} facture(s) en brouillon</q-tooltip>
</q-btn>
<q-btn
label="PPA"
color="teal-7"
icon="credit_score"
:loading="runningPPA"
dense no-caps
@click="runPPA"
>
<q-tooltip>Prélève automatiquement les cartes enregistrées</q-tooltip>
</q-btn>
</div>
<div class="q-mt-xs q-gutter-xs" style="min-height:20px">
<span v-if="billingResult" class="text-caption" :class="billingResult.ok ? 'text-positive' : 'text-negative'">
{{ billingResult.message }}
</span>
</div>
<div class="text-caption text-grey-6 q-mt-xs">
Déclenche manuellement la création des factures récurrentes dues
<span v-if="submitResult" class="text-caption" :class="submitResult.ok ? 'text-positive' : 'text-negative'">
{{ submitResult.message }}
</span>
<span v-if="ppaResult" class="text-caption" :class="ppaResult.ok ? 'text-positive' : 'text-negative'">
{{ ppaResult.message }}
</span>
</div>
</div>
</div>
@ -125,6 +174,8 @@ import { ref, onMounted } from 'vue'
import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_SSE_URL } from 'src/config/dispatch'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
@ -163,30 +214,165 @@ async function toggleScheduler () {
togglingScheduler.value = false
}
// Manual billing
// Billing pipeline: Generate Submit PPA
const billingStep = ref(0) // 0=idle, 1=generated, 2=submitted, 3=ppa done
const runningBilling = ref(false)
const billingResult = ref(null)
const submittingDrafts = ref(false)
const submitResult = ref(null)
const runningPPA = ref(false)
const ppaResult = ref(null)
const draftInvoiceCount = ref(0)
async function refreshDraftCount () {
try {
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?doctype=Sales+Invoice&filters=' +
encodeURIComponent(JSON.stringify({ docstatus: 0, is_return: 0 })))
if (res.ok) {
const data = await res.json()
draftInvoiceCount.value = data.message || 0
}
} catch {}
}
async function runBilling () {
runningBilling.value = true
billingResult.value = null
submitResult.value = null
ppaResult.value = null
billingStep.value = 0
try {
const res = await authFetch(BASE_URL + '/api/method/erpnext.accounts.doctype.subscription.subscription.process_all', {
// Create a Process Subscription doc (submitting it enqueues the background job)
const today = new Date().toISOString().slice(0, 10)
const res = await authFetch(BASE_URL + '/api/resource/Process Subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ posting_date: today }),
})
if (res.ok) {
billingResult.value = { ok: true, message: 'Factures générées avec succès' }
if (!res.ok) throw new Error('Erreur création: ' + res.status)
const doc = (await res.json()).data
// Submit it to trigger the background job (must pass full doc with modified timestamp)
const submitRes = await authFetch(BASE_URL + '/api/method/frappe.client.submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doc: doc }),
})
if (!submitRes.ok) throw new Error('Erreur soumission: ' + submitRes.status)
// Poll background jobs until complete (max 60s)
billingResult.value = { ok: true, message: 'Génération en cours...' }
const startDrafts = draftInvoiceCount.value
let attempts = 0
while (attempts < 30) {
await new Promise(r => setTimeout(r, 2000))
await refreshDraftCount()
attempts++
// Check if background job finished by looking at job queue
const jobRes = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + new URLSearchParams({
doctype: 'RQ Job',
filters: JSON.stringify({ job_name: ['like', '%process_all%'], status: ['in', ['queued', 'started']] }),
limit_page_length: 1,
}))
if (jobRes.ok) {
const jobData = await jobRes.json()
const jobs = jobData.data || jobData.message || []
if (!jobs.length) break // no more running jobs
} else {
billingResult.value = { ok: false, message: 'Erreur ' + res.status }
break // can't check, assume done
}
}
await refreshDraftCount()
const newDrafts = draftInvoiceCount.value - startDrafts
billingResult.value = { ok: true, message: `Génération terminée. ${newDrafts > 0 ? newDrafts + ' nouvelle(s) facture(s)' : 'Aucune nouvelle facture'}.` }
billingStep.value = 1
} catch (e) {
billingResult.value = { ok: false, message: e.message }
}
runningBilling.value = false
}
async function submitDrafts () {
submittingDrafts.value = true
submitResult.value = null
try {
// Get all draft invoices (docstatus=0, not credit notes)
const draftsRes = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + new URLSearchParams({
doctype: 'Sales Invoice',
filters: JSON.stringify({ docstatus: 0, is_return: 0 }),
fields: JSON.stringify(['name']),
limit_page_length: 500,
}))
if (!draftsRes.ok) throw new Error('Erreur lecture drafts: ' + draftsRes.status)
const drafts = (await draftsRes.json()).message || []
if (!drafts.length) {
submitResult.value = { ok: true, message: 'Aucune facture en brouillon.' }
submittingDrafts.value = false
return
}
// Submit each draft (must fetch full doc for modified timestamp)
let submitted = 0
let errors = 0
for (const d of drafts) {
try {
const docRes = await authFetch(BASE_URL + '/api/resource/Sales%20Invoice/' + encodeURIComponent(d.name))
if (!docRes.ok) { errors++; continue }
const fullDoc = (await docRes.json()).data
const r = await authFetch(BASE_URL + '/api/method/frappe.client.submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doc: fullDoc }),
})
if (r.ok) submitted++
else errors++
} catch {
errors++
}
}
await refreshDraftCount()
submitResult.value = {
ok: errors === 0,
message: `${submitted} facture(s) soumise(s)${errors ? ', ' + errors + ' erreur(s)' : ''}.`,
}
billingStep.value = 2
} catch (e) {
submitResult.value = { ok: false, message: e.message }
}
submittingDrafts.value = false
}
async function runPPA () {
runningPPA.value = true
ppaResult.value = null
try {
const res = await fetch(HUB_SSE_URL + '/payments/ppa-run', { method: 'POST' })
if (res.ok) {
const data = await res.json()
const charged = data.charged?.length || 0
const failed = data.failed?.length || 0
const skipped = data.skipped?.length || 0
ppaResult.value = {
ok: true,
message: `PPA: ${charged} prélevé(s), ${failed} échoué(s), ${skipped} ignoré(s)`,
}
billingStep.value = 3
} else {
const err = await res.json().catch(() => ({}))
ppaResult.value = { ok: false, message: err.error || 'Erreur ' + res.status }
}
} catch (e) {
ppaResult.value = { ok: false, message: e.message }
}
runningPPA.value = false
}
onMounted(async () => {
fetchSchedulerStatus()
refreshDraftCount()
const today = new Date().toISOString().slice(0, 10)

View File

@ -19,6 +19,8 @@ import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue'
import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
import SbModal from 'src/modules/dispatch/components/SbModal.vue'
import SbContextMenu from 'src/modules/dispatch/components/SbContextMenu.vue'
import NlpInput from 'src/components/dispatch/NlpInput.vue'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import {
localDateStr, fmtDate, timeToH, hToTime, fmtDur,
@ -364,7 +366,22 @@ const bookingOverlay = ref(null)
const woModalOpen = ref(false)
const woModalCtx = ref({})
const publishModalOpen = ref(false)
const nlpVisible = ref(true) // NLP bar always visible
const draftCount = computed(() => store.jobs.filter(j => !j.published && j.status !== 'completed' && j.status !== 'cancelled').length)
function onNlpAction (result) {
if (!result) return
// Handle the parsed NLP action
if (result.action === 'create_job') {
// Pre-fill WO modal with NLP-extracted data
openWoModal()
Notify.create({ type: 'info', message: `IA: Création suggérée — "${result.subject || ''}"`, timeout: 3000 })
} else if (result.action === 'redistribute') {
Notify.create({ type: 'warning', message: `IA: Redistribution suggérée pour ${result.absent_tech || '?'}`, timeout: 4000 })
} else if (result.action === 'move_job') {
Notify.create({ type: 'info', message: `IA: Déplacer job de ${result.from_tech} vers ${result.to_tech}`, timeout: 4000 })
}
}
const periodEndStr = computed(() => {
const ps = periodStart.value
if (!ps || isNaN(ps.getTime())) return ''
@ -783,7 +800,7 @@ function removePausePeriod (job, idx) {
}
async function copyIcalUrl (tech) {
const hubBase = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
const { HUB_URL: hubBase } = await import('src/config/hub')
try {
const r = await fetch(`${hubBase}/dispatch/ical-token/${tech.id}`)
const data = await r.json()
@ -899,7 +916,68 @@ provide('selectAddr', selectAddr)
let dispatchSse = null
function connectDispatchSSE () {
if (dispatchSse) dispatchSse.close()
dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch`)
dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch,network`)
// Network outage alerts toast for dispatchers
dispatchSse.addEventListener('outage-alert', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'warning', icon: 'warning',
message: `⚠ Panne: ${d.affected_count} clients — ${d.oltName} port ${d.port}`,
caption: d.analysis?.outage_type || '',
timeout: 10000,
actions: [{ icon: 'close', color: 'white', round: true }],
})
} catch {}
})
dispatchSse.addEventListener('dispatch-suggestion', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'info', icon: 'engineering',
message: `Dispatch suggéré: ${d.tech_skill_needed || 'technicien'}`,
caption: d.subject,
timeout: 15000,
actions: [{ icon: 'close', color: 'white', round: true }],
})
} catch {}
})
dispatchSse.addEventListener('outage-resolved', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'positive', icon: 'check_circle',
message: `✓ Panne résolue: ${d.oltName} port ${d.port}`,
timeout: 5000,
})
} catch {}
})
// OLT-level events
dispatchSse.addEventListener('olt-down', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'negative', icon: 'cell_tower',
message: `🔴 OLT hors service: ${d.oltName}`,
caption: `~${d.customerCount} clients affectés (${d.source})`,
timeout: 0, // persistent must dismiss manually
actions: [{ icon: 'close', color: 'white', round: true }],
})
} catch {}
})
dispatchSse.addEventListener('olt-up', (e) => {
try {
const d = JSON.parse(e.data)
Notify.create({
type: 'positive', icon: 'cell_tower',
message: `✅ OLT rétabli: ${d.oltName}`,
caption: d.downtime_min ? `Panne: ${d.downtime_min} min` : '',
timeout: 8000,
})
} catch {}
})
dispatchSse.addEventListener('tech-absence', (e) => {
try {
const data = JSON.parse(e.data)
@ -920,6 +998,8 @@ function connectDispatchSSE () {
tech.absenceEndTime = null
tech.absenceReason = ''
}
// Bust scheduler cache so timeline re-renders with absence change
store.jobVersion++
} catch {}
})
}
@ -1071,6 +1151,7 @@ onUnmounted(() => {
</button>
<button v-if="currentView==='day'" class="sb-icon-btn" :class="{ active: mapVisible }" @click="mapVisible=!mapVisible" title="Carte">🗺 Carte</button>
<button class="sb-icon-btn" @click="refreshData()" title="Actualiser"></button>
<button class="sb-icon-btn" :class="{ active: nlpVisible }" @click="nlpVisible=!nlpVisible" title="Assistant IA"></button>
<button class="sb-icon-btn" @click="gpsSettingsOpen=true" title="GPS Tracking">📡</button>
<button class="sb-wo-btn" style="background:#7c3aed" @click="publishModalOpen=true" title="Publier & envoyer l'horaire">
Publier <span v-if="draftCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#ef4444">{{ draftCount }}</span>
@ -1081,6 +1162,11 @@ onUnmounted(() => {
</div>
</header>
<!-- AI Natural Language Input -->
<div class="sb-nlp-row" v-show="nlpVisible">
<NlpInput :tech-names="store.technicians.map(t => t.fullName)" @applied="onNlpAction" />
</div>
<transition name="sb-slide-down">
<div v-if="filterPanelOpen" class="sb-toolbar-panel" @click.stop>
<div class="sb-toolbar-panel-inner">

View File

@ -1,6 +1,33 @@
<template>
<q-page padding>
<!-- Header -->
<!-- Tabs -->
<div class="row items-center q-mb-md">
<q-tabs v-model="activeTab" dense no-caps class="text-grey-7" active-color="indigo-6" indicator-color="indigo-6">
<q-tab name="gpon" icon="lan" label="Réseau GPON" />
<q-tab name="map" icon="map" label="Carte réseau" />
<q-tab name="topology" icon="hub" label="Topologie" />
<q-tab name="analysis" icon="psychology" label="Analyse IA" />
</q-tabs>
<q-space />
<template v-if="activeTab === 'gpon'">
<q-btn unelevated dense no-caps icon="add" label="Ajouter OLT" color="indigo-6" @click="showOltModal = true" />
<div class="text-caption text-grey-6 q-ml-md">{{ totalLocations }} locations fibre</div>
</template>
<template v-if="activeTab === 'map'">
<q-btn flat dense no-caps icon="refresh" label="Rafraîchir" color="grey-7" :loading="mapLoading" @click="loadNetworkMap" />
</template>
<template v-if="activeTab === 'topology'">
<q-btn flat dense no-caps icon="refresh" label="Rafraîchir" color="grey-7" :loading="topoLoading" @click="loadTopology" />
</template>
<template v-if="activeTab === 'analysis'">
<q-btn unelevated dense no-caps icon="play_arrow" label="Lancer analyse" color="indigo-6" :loading="analysisLoading" @click="runAnalysis" />
</template>
</div>
<!-- TAB: GPON -->
<div v-show="activeTab === 'gpon'">
<!-- Search bar (GPON tab) -->
<div class="row items-center q-mb-md q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
@ -14,9 +41,6 @@
</template>
</q-input>
</div>
<q-space />
<q-btn unelevated dense no-caps icon="add" label="Ajouter OLT" color="indigo-6" @click="showOltModal = true" />
<div class="text-caption text-grey-6 q-ml-md">{{ totalLocations }} locations fibre</div>
</div>
<!-- OLT Tree -->
@ -193,6 +217,365 @@
</div>
</div>
</div><!-- end GPON tab -->
<!-- TAB: CARTE RÉSEAU -->
<div v-show="activeTab === 'map'">
<div v-if="mapLoading && !mapData" class="text-center q-pa-xl">
<q-spinner size="32px" color="indigo-6" />
<div class="text-grey-6 q-mt-sm">Chargement de la carte réseau...</div>
</div>
<div v-else-if="mapData" class="netmap-container">
<!-- Summary bar -->
<div class="netmap-summary row q-col-gutter-sm q-mb-sm">
<div class="col-auto"><q-chip dense color="indigo-1" text-color="indigo-9" icon="location_on">{{ mapData.summary.totalSites }} sites</q-chip></div>
<div class="col-auto"><q-chip dense color="green-1" text-color="green-9" icon="check_circle">{{ mapData.summary.sitesUp }} UP</q-chip></div>
<div class="col-auto" v-if="mapData.summary.sitesDown"><q-chip dense color="red-1" text-color="red-9" icon="error">{{ mapData.summary.sitesDown }} dégradé</q-chip></div>
<div class="col-auto"><q-chip dense color="amber-1" text-color="amber-9" icon="router">{{ mapData.summary.totalOlts }} OLTs</q-chip></div>
<div class="col-auto" v-if="mapData.summary.totalOnus"><q-chip dense color="blue-1" text-color="blue-9" icon="people">{{ mapData.summary.totalOnusOnline }}/{{ mapData.summary.totalOnus }} ONUs</q-chip></div>
<div class="col-auto"><q-chip dense color="grey-2" text-color="grey-8" icon="cable">{{ mapData.summary.totalLinks }} liens fibre</q-chip></div>
</div>
<!-- Cytoscape Canvas -->
<div ref="cyContainer" class="netmap-canvas"></div>
<!-- Selected site detail panel -->
<div v-if="mapSelectedSite" class="netmap-detail q-mt-sm">
<q-card flat bordered>
<q-card-section class="q-pa-sm">
<div class="row items-center q-mb-xs">
<div class="text-subtitle1 text-weight-bold">{{ mapSelectedSite.name }}</div>
<q-space />
<q-chip dense :color="mapSelectedSite.status === 'up' ? 'green' : mapSelectedSite.status === 'degraded' ? 'red' : 'grey'" text-color="white" :label="mapSelectedSite.status" />
<q-btn flat dense round icon="close" size="sm" @click="mapSelectedSite = null" />
</div>
<div class="text-caption text-grey-6">{{ mapSelectedSite.city }} {{ mapSelectedSite.address }}</div>
<!-- Devices at this site -->
<div class="q-mt-sm">
<div v-for="dev in mapSelectedSite.devices" :key="dev.ip" class="netmap-device row items-center q-py-xs"
:class="{ 'netmap-device--down': dev.kuma?.status === 0 }">
<q-icon :name="dev.type === 'router' ? 'router' : dev.type === 'olt' ? 'device_hub' : 'dns'" size="16px" class="q-mr-xs" />
<span class="text-weight-medium" style="font-size:12px">{{ dev.hostname }}</span>
<span class="text-grey-5 q-mx-xs">|</span>
<code style="font-size:11px">{{ dev.ip }}</code>
<span class="text-grey-5 q-mx-xs">|</span>
<span style="font-size:11px" class="text-grey-6">{{ dev.make }} {{ dev.model || '' }}</span>
<q-space />
<q-badge v-if="dev.kuma" :color="dev.kuma.status === 1 ? 'green' : dev.kuma.status === 0 ? 'red' : 'orange'" :label="dev.kuma.status === 1 ? 'UP' : 'DOWN'" />
<q-badge v-else color="grey-4" text-color="grey-7" label="No Kuma" />
<q-badge v-if="dev.olt" color="blue-2" text-color="blue-9" :label="`${dev.olt.onlineCount}/${dev.olt.onuCount} ONUs`" class="q-ml-xs" />
<q-badge v-if="dev.status === 'planned'" color="amber-2" text-color="amber-9" label="Planifié" class="q-ml-xs" />
</div>
</div>
<!-- Connected links -->
<div class="q-mt-sm" v-if="mapSelectedSiteLinks.length">
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Liens fibre :</div>
<div v-for="link in mapSelectedSiteLinks" :key="link.id" class="row items-center q-py-xs" style="font-size:11px">
<q-icon name="cable" size="14px" class="q-mr-xs text-grey-5" />
<span> {{ link.from === mapSelectedSiteId ? link.to : link.from }}</span>
<span class="text-grey-5 q-mx-xs">|</span>
<span v-if="link.speed_gbps" class="text-weight-medium">{{ link.speed_gbps }}G</span>
<span v-if="link.distance_km" class="text-grey-6 q-ml-xs">{{ link.distance_km }}km</span>
<span v-if="link.note" class="text-grey-5 q-ml-xs">{{ link.note }}</span>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
<div v-else class="text-center q-pa-xl text-grey-5">
Cliquez sur Rafraîchir pour charger la carte réseau
</div>
</div>
<!-- TAB: TOPOLOGY -->
<div v-show="activeTab === 'topology'">
<div v-if="topoLoading && !topoData" class="text-center q-pa-xl">
<q-spinner size="32px" color="indigo-6" />
<div class="text-grey-6 q-mt-sm">Découverte de la topologie réseau...</div>
</div>
<div v-else-if="topoData" class="topo-container">
<!-- Summary Cards -->
<div class="topo-summary row q-col-gutter-sm q-mb-md">
<div class="col-auto" v-for="(count, type) in topoData.topology.summary" :key="type">
<q-card flat bordered class="topo-summary-card" :class="'topo-type-' + type">
<q-card-section class="q-pa-sm text-center">
<div class="topo-summary-count">{{ count }}</div>
<div class="topo-summary-label">{{ topoTypeLabel(type) }}</div>
</q-card-section>
</q-card>
</div>
<div class="col-auto">
<q-card flat bordered class="topo-summary-card">
<q-card-section class="q-pa-sm text-center">
<div class="topo-summary-count">{{ topoData.topology.links.length }}</div>
<div class="topo-summary-label">Liens</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- Network Hierarchy Visualization -->
<div class="topo-hierarchy">
<!-- Level 0: Core Switches -->
<div class="topo-level" v-if="topoByLevel[0]?.length">
<div class="topo-level-label">CORE</div>
<div class="topo-level-devices">
<div v-for="dev in topoByLevel[0]" :key="dev.host" class="topo-device topo-device--core"
:class="devClass(dev)"
@click="selectDevice(dev)">
<div class="topo-kuma-dot" :class="kumaClass(dev)" :title="kumaTitle(dev)"></div>
<q-icon :name="dev.icon" size="20px" />
<div class="topo-device-host">{{ dev.host }}</div>
<div v-if="dev.kuma" class="topo-kuma-label">{{ dev.kuma.name }}</div>
<div class="topo-device-health">
<div class="topo-health-bar">
<div class="topo-health-fill" :style="{ width: (dev.ifSummary?.health || 0) + '%' }"
:class="healthClass(dev.ifSummary?.health)"></div>
</div>
<span class="topo-health-text">{{ dev.ifSummary?.health ?? '?' }}%</span>
</div>
<div class="topo-device-stats">
{{ dev.ifSummary?.up || 0 }}<span class="text-green"></span>
{{ dev.ifSummary?.down || 0 }}<span class="text-red"></span>
/ {{ dev.ifSummary?.total || 0 }}
</div>
</div>
</div>
</div>
<!-- Level 1: Edge Routers + Public Infra -->
<div class="topo-level" v-if="topoByLevel[1]?.length">
<div class="topo-level-label">EDGE / TRANSIT</div>
<div class="topo-level-devices">
<div v-for="dev in topoByLevel[1]" :key="dev.host" class="topo-device topo-device--edge"
:class="devClass(dev)"
@click="selectDevice(dev)">
<div class="topo-kuma-dot" :class="kumaClass(dev)" :title="kumaTitle(dev)"></div>
<q-icon :name="dev.icon" size="18px" />
<div class="topo-device-host">{{ dev.host }}</div>
<div class="topo-device-type">{{ dev.label }}</div>
<div class="topo-device-health">
<div class="topo-health-bar">
<div class="topo-health-fill" :style="{ width: (dev.ifSummary?.health || 0) + '%' }"
:class="healthClass(dev.ifSummary?.health)"></div>
</div>
<span class="topo-health-text">{{ dev.ifSummary?.health ?? '?' }}%</span>
</div>
</div>
</div>
</div>
<!-- Level 2: OLT Gateways -->
<div class="topo-level" v-if="topoByLevel[2]?.length">
<div class="topo-level-label">OLT GATEWAYS</div>
<div class="topo-level-devices">
<div v-for="dev in topoByLevel[2]" :key="dev.host" class="topo-device topo-device--olt"
:class="devClass(dev)"
@click="selectDevice(dev)">
<div class="topo-kuma-dot" :class="kumaClass(dev)" :title="kumaTitle(dev)"></div>
<q-icon :name="dev.icon" size="18px" />
<div class="topo-device-host">{{ dev.host }}</div>
<div class="topo-device-health">
<div class="topo-health-bar">
<div class="topo-health-fill" :style="{ width: (dev.ifSummary?.health || 0) + '%' }"
:class="healthClass(dev.ifSummary?.health)"></div>
</div>
<span class="topo-health-text">{{ dev.ifSummary?.health ?? '?' }}%</span>
</div>
</div>
</div>
</div>
<!-- OLT chains (from dependency map) -->
<div class="topo-level" v-if="oltChains.length">
<div class="topo-level-label">OLTs (GPON)</div>
<div class="topo-level-devices">
<div v-for="chain in oltChains" :key="chain.leaf" class="topo-device topo-device--olt-leaf"
:class="{ 'topo-device--danger': chain.path[chain.path.length-1]?.health === 0 }">
<q-icon name="settings_input_antenna" size="16px" />
<div class="topo-device-host">{{ chain.oltName || chain.leaf }}</div>
<div v-if="chain.onuCount" class="topo-device-onus">
{{ chain.onlineCount }}/{{ chain.onuCount }} ONUs
<q-icon :name="chain.onlineCount === chain.onuCount ? 'check_circle' : 'warning'" size="12px"
:color="chain.onlineCount === chain.onuCount ? 'green' : 'orange'" />
</div>
</div>
</div>
</div>
</div>
<!-- Dependency Chains -->
<div class="topo-chains q-mt-lg" v-if="topoData.chains?.length">
<div class="text-subtitle2 q-mb-sm">Chaînes de dépendance</div>
<div v-for="(chain, i) in topoData.chains.slice(0, 20)" :key="i" class="topo-chain">
<template v-for="(node, ni) in chain.path" :key="ni">
<span class="topo-chain-node" :class="'topo-chain-' + node.type"
:title="`${node.host} — ${node.label} — Santé: ${node.health}%`">
<span class="topo-chain-health" :class="healthClass(node.health)"></span>
{{ node.host }}
<span v-if="node.onuCount" class="topo-chain-onus">({{ node.onlineCount }}/{{ node.onuCount }})</span>
</span>
<span v-if="ni < chain.path.length - 1" class="topo-chain-arrow"></span>
</template>
</div>
</div>
<!-- Selected device detail -->
<div v-if="selectedDevice && selectedDeviceData" class="topo-detail q-mt-lg">
<q-card flat bordered>
<q-card-section>
<div class="row items-center q-mb-sm">
<q-icon :name="selectedDeviceData.icon" size="24px" class="q-mr-sm" />
<div>
<div class="text-h6" style="line-height:1.2">{{ selectedDeviceData.host }}</div>
<div class="text-caption text-grey-6">{{ selectedDeviceData.label }} {{ selectedDeviceData.type }}</div>
</div>
<q-space />
<q-btn flat dense round icon="close" @click="selectedDevice = null" />
</div>
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-auto">
<q-badge color="green" :label="`${selectedDeviceData.ifSummary?.up || 0} up`" />
</div>
<div class="col-auto">
<q-badge color="red" :label="`${selectedDeviceData.ifSummary?.down || 0} down`" />
</div>
<div class="col-auto">
<q-badge color="grey" :label="`${selectedDeviceData.ifSummary?.total || 0} total`" />
</div>
<div v-if="selectedDeviceData.traffic" class="col-auto">
<q-badge color="blue" :label="`↓${selectedDeviceData.traffic.totalInGb}GB ↑${selectedDeviceData.traffic.totalOutGb}GB`" />
</div>
</div>
<!-- Connected links -->
<div class="text-caption text-grey-6 q-mb-xs">Connexions:</div>
<div v-for="link in deviceLinks" :key="link.from + link.to" class="topo-detail-link">
<q-icon :name="link.status === 'up' ? 'link' : link.status === 'down' ? 'link_off' : 'help'" size="14px"
:color="link.status === 'up' ? 'green' : link.status === 'down' ? 'red' : 'grey'" />
{{ link.from === selectedDevice ? link.to : link.from }}
<span class="text-grey-5">({{ link.type }}, via: {{ link.via }})</span>
<span v-if="link.oltName" class="text-indigo-4">{{ link.oltName }} {{ link.onlineCount }}/{{ link.onuCount }} ONUs</span>
</div>
<!-- Interface detail -->
<div v-if="deviceInterfaces.length" class="q-mt-sm">
<div class="text-caption text-grey-6 q-mb-xs">Interfaces ({{ deviceInterfaces.length }}):</div>
<q-virtual-scroll :items="deviceInterfaces" v-slot="{ item }" style="max-height:300px">
<div class="topo-detail-if" :class="{ 'text-red': item.status === 'down' && item.speed > 0 }">
<span class="topo-if-status" :class="item.status === 'up' ? 'bg-green' : item.status === 'down' ? 'bg-red' : 'bg-grey'"></span>
{{ item.descr || item.ifDescr || '—' }}
<span v-if="item.speed" class="text-grey-5 q-ml-xs">{{ formatSpeed(item.speed) }}</span>
<span v-if="item.inErrors > 0" class="text-orange q-ml-xs">err:{{ item.inErrors }}</span>
</div>
</q-virtual-scroll>
</div>
</q-card-section>
</q-card>
</div>
</div>
<div v-else class="text-center q-pa-xl text-grey-5">
Cliquez sur Rafraîchir pour découvrir la topologie réseau
</div>
</div><!-- end topology tab -->
<!-- TAB: AI ANALYSIS -->
<div v-show="activeTab === 'analysis'">
<div v-if="analysisLoading" class="text-center q-pa-xl">
<q-spinner size="32px" color="indigo-6" />
<div class="text-grey-6 q-mt-sm">Analyse IA en cours... (lecture des logs SNMP + statut OLTs)</div>
</div>
<div v-else-if="analysisResult" class="analysis-container">
<!-- Severity banner -->
<q-banner :class="'analysis-severity analysis-severity--' + (analysisResult.severity || 'unknown')" rounded>
<template #avatar>
<q-icon :name="severityIcon(analysisResult.severity)" size="28px" />
</template>
<div class="text-h6">{{ analysisResult.summary_fr || analysisResult.root_cause || 'Analyse terminée' }}</div>
<div class="text-caption">
Sévérité: {{ (analysisResult.severity || 'N/A').toUpperCase() }}
<span v-if="analysisResult.network_health_score != null"> Score réseau: {{ analysisResult.network_health_score }}/100</span>
<span v-if="analysisResult.trending"> Tendance: {{ analysisResult.trending }}</span>
<span v-if="analysisResult._logSummary" class="q-ml-sm text-grey-4">
({{ analysisResult._logSummary.totalTraps }} traps, {{ analysisResult._logSummary.oltCount }} OLTs, {{ analysisResult._logSummary.stuckOffline }} ONUs bloqués)
</span>
</div>
</q-banner>
<!-- OLT Health Grid -->
<div class="q-mt-md" v-if="analysisResult.olt_health?.length">
<div class="text-subtitle2 q-mb-sm">État des OLTs</div>
<div class="row q-col-gutter-sm">
<div v-for="olt in analysisResult.olt_health" :key="olt.name" class="col-auto">
<q-card flat bordered class="olt-health-card" :class="'olt-health--' + olt.status">
<q-card-section class="q-pa-sm">
<div class="row items-center q-gutter-xs">
<q-icon :name="olt.status === 'ok' ? 'check_circle' : olt.status === 'warning' ? 'warning' : olt.status === 'critical' ? 'error' : 'cloud_off'"
:color="olt.status === 'ok' ? 'green' : olt.status === 'warning' ? 'orange' : 'red'" size="18px" />
<div>
<div class="text-weight-bold" style="font-size:12px">{{ olt.name }}</div>
<div class="text-caption text-grey-6">{{ olt.online }}/{{ olt.total }} ONUs</div>
</div>
</div>
<div v-if="olt.issues_fr && olt.issues_fr !== 'OK'" class="text-caption text-orange-8 q-mt-xs">{{ olt.issues_fr }}</div>
</q-card-section>
</q-card>
</div>
</div>
</div>
<!-- Active Issues -->
<div class="q-mt-md" v-if="analysisResult.active_issues?.length">
<div class="text-subtitle2 q-mb-sm">Problèmes actifs</div>
<q-card v-for="(issue, i) in analysisResult.active_issues" :key="i" flat bordered class="q-mb-sm">
<q-card-section class="q-pa-sm">
<div class="row items-center q-gutter-sm q-mb-xs">
<q-badge :color="issue.urgency === 'immédiat' ? 'red' : issue.urgency === 'court-terme' ? 'orange' : issue.urgency === 'surveillance' ? 'blue' : 'grey'"
:label="issue.urgency || 'info'" />
<q-badge outline color="grey-7" :label="issue.type?.replace(/_/g, ' ')" />
<span class="text-weight-bold" style="font-size:13px">{{ issue.olt }}</span>
<span v-if="issue.affected_customers" class="text-caption text-grey-6">~{{ issue.affected_customers }} clients</span>
</div>
<div class="analysis-text" style="font-size:13px">{{ issue.description_fr }}</div>
<div v-if="issue.action_fr" class="text-caption text-indigo-6 q-mt-xs">
<q-icon name="play_arrow" size="14px" /> {{ issue.action_fr }}
</div>
<div v-if="issue.evidence?.length" class="q-mt-xs">
<span v-for="(ev, ei) in issue.evidence" :key="ei" class="analysis-evidence">{{ ev }}</span>
</div>
</q-card-section>
</q-card>
</div>
<!-- No issues -->
<q-banner v-if="!analysisResult.active_issues?.length && analysisResult.severity === 'normal'" class="q-mt-md bg-green-1 text-green-9" rounded>
<template #avatar><q-icon name="check_circle" color="green" /></template>
Aucun problème détecté. Le réseau fonctionne normalement.
</q-banner>
<!-- Next check recommendation -->
<div v-if="analysisResult.next_check_fr" class="text-caption text-grey-6 q-mt-md q-pa-sm" style="background:#f8fafc;border-radius:6px">
<q-icon name="schedule" size="14px" /> {{ analysisResult.next_check_fr }}
</div>
<!-- Raw JSON toggle -->
<q-expansion-item label="Données brutes (JSON)" dense class="q-mt-md text-grey-6">
<pre class="analysis-raw">{{ JSON.stringify(analysisResult, null, 2) }}</pre>
</q-expansion-item>
</div>
<div v-else class="text-center q-pa-xl text-grey-5">
Lancez une analyse IA pour obtenir un diagnostic réseau basé sur les logs.<br>
<span class="text-caption">L'IA analyse les SNMP traps des OLTs, le statut live des ONUs, et les incidents actifs.</span>
</div>
</div><!-- end analysis tab -->
<!-- Add / Edit OLT Modal -->
<q-dialog v-model="showOltModal" persistent>
<q-card style="min-width: 400px">
@ -230,13 +613,15 @@
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { ref, computed, onMounted, watch, nextTick, reactive } from 'vue'
import cytoscape from 'cytoscape'
import { useRouter } from 'vue-router'
import { listDocs, updateDoc, createDoc } from 'src/api/erp'
import { Notify } from 'quasar'
const router = useRouter()
const hubUrl = 'https://msg.gigafibre.ca'
import { HUB_URL as hubUrl } from 'src/config/hub'
const activeTab = ref('gpon')
const loading = ref(false)
const saving = ref(false)
const search = ref('')
@ -651,10 +1036,370 @@ async function loadLocations () {
}
}
// Network Map tab (Cytoscape canvas)
const mapLoading = ref(false)
const mapData = ref(null)
const mapSelectedSite = ref(null)
const mapSelectedSiteId = ref(null)
const cyContainer = ref(null)
let cyInstance = null
const mapSelectedSiteLinks = computed(() => {
if (!mapData.value || !mapSelectedSiteId.value) return []
return mapData.value.links.filter(l => l.from === mapSelectedSiteId.value || l.to === mapSelectedSiteId.value)
})
async function loadNetworkMap () {
mapLoading.value = true
try {
const resp = await fetch(`${hubUrl}/network/map`)
mapData.value = await resp.json()
await nextTick()
renderCytoscape()
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur carte: ${e.message}` })
} finally {
mapLoading.value = false
}
}
function renderCytoscape () {
if (!cyContainer.value || !mapData.value) return
if (cyInstance) { cyInstance.destroy(); cyInstance = null }
const elements = []
const data = mapData.value
// Add site nodes
for (const [id, site] of Object.entries(data.sites)) {
const isDatacenter = site.type === 'datacenter'
const olts = site.devices.filter(d => d.type === 'olt')
const routers = site.devices.filter(d => d.type === 'router')
const onuLabel = site.onuTotal > 0 ? `\n${site.onuOnline}/${site.onuTotal} ONUs` : ''
const oltLabel = olts.length > 0 ? `\n${olts.length} OLT${olts.length > 1 ? 's' : ''}` : ''
elements.push({
group: 'nodes',
data: {
id,
label: `${site.name}${oltLabel}${onuLabel}`,
status: site.status,
type: site.type,
onuTotal: site.onuTotal,
onuOnline: site.onuOnline,
oltCount: olts.length,
routerCount: routers.length,
isDatacenter,
},
// Use lat/lng for initial positioning (scaled to canvas)
position: site.lat && site.lng ? {
x: (site.lng + 74.5) * 2000,
y: (45.6 - site.lat) * 3000,
} : undefined,
})
}
// Add link edges
for (const link of data.links) {
const speedLabel = link.speed_gbps ? `${link.speed_gbps}G` : ''
const distLabel = link.distance_km ? `${link.distance_km}km` : ''
elements.push({
group: 'edges',
data: {
id: link.id || `${link.from}-${link.to}`,
source: link.from,
target: link.to,
label: [speedLabel, distLabel].filter(Boolean).join(' / '),
speed: link.speed_gbps || 0,
status: link.status,
linkType: link.type,
},
})
}
cyInstance = cytoscape({
container: cyContainer.value,
elements,
userZoomingEnabled: true,
userPanningEnabled: true,
boxSelectionEnabled: false,
style: [
{
selector: 'node',
style: {
'label': 'data(label)',
'text-valign': 'bottom',
'text-halign': 'center',
'font-size': '10px',
'font-family': 'Inter, system-ui, sans-serif',
'text-wrap': 'wrap',
'text-max-width': '100px',
'color': '#334155',
'width': 40,
'height': 40,
'shape': 'roundrectangle',
'background-color': '#e2e8f0',
'border-width': 2,
'border-color': '#94a3b8',
'text-margin-y': 6,
},
},
// Status colors
{
selector: 'node[status="up"]',
style: {
'background-color': '#dcfce7',
'border-color': '#22c55e',
},
},
{
selector: 'node[status="degraded"]',
style: {
'background-color': '#fef2f2',
'border-color': '#ef4444',
},
},
{
selector: 'node[status="unknown"]',
style: {
'background-color': '#f1f5f9',
'border-color': '#cbd5e1',
},
},
// Datacenter nodes bigger
{
selector: 'node[?isDatacenter]',
style: {
'width': 55,
'height': 55,
'font-size': '11px',
'font-weight': 'bold',
'shape': 'diamond',
'border-width': 3,
'background-color': '#e0e7ff',
'border-color': '#6366f1',
},
},
// OLT-heavy sites
{
selector: 'node[oltCount > 2]',
style: {
'width': 48,
'height': 48,
},
},
// Selected node
{
selector: 'node:selected',
style: {
'border-color': '#4f46e5',
'border-width': 3,
'overlay-color': '#4f46e5',
'overlay-padding': 4,
'overlay-opacity': 0.15,
},
},
// Edges
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#94a3b8',
'curve-style': 'bezier',
'label': 'data(label)',
'font-size': '8px',
'font-family': 'monospace',
'color': '#64748b',
'text-rotation': 'autorotate',
'text-background-color': '#ffffff',
'text-background-opacity': 0.85,
'text-background-padding': '2px',
},
},
// Speed-based edge width
{ selector: 'edge[speed >= 100]', style: { 'width': 5, 'line-color': '#6366f1' } },
{ selector: 'edge[speed >= 25]', style: { 'width': 4, 'line-color': '#818cf8' } },
{ selector: 'edge[speed >= 10]', style: { 'width': 3, 'line-color': '#a5b4fc' } },
// DWDM links dashed
{ selector: 'edge[linkType="dwdm"]', style: { 'line-style': 'dashed', 'line-dash-pattern': [8, 4] } },
// Link status
{ selector: 'edge[status="up"]', style: { 'line-color': '#22c55e' } },
{ selector: 'edge[status="degraded"]', style: { 'line-color': '#ef4444', 'line-style': 'dashed' } },
],
layout: {
name: 'preset', // Use geo coordinates
fit: true,
padding: 40,
},
})
// Click handler
cyInstance.on('tap', 'node', (evt) => {
const nodeId = evt.target.id()
mapSelectedSiteId.value = nodeId
mapSelectedSite.value = data.sites[nodeId]
})
// Click on background clears selection
cyInstance.on('tap', (evt) => {
if (evt.target === cyInstance) {
mapSelectedSite.value = null
mapSelectedSiteId.value = null
}
})
}
// Auto-load map when tab switches
watch(activeTab, (tab) => {
if (tab === 'map' && !mapData.value) loadNetworkMap()
})
// Topology tab
const topoLoading = ref(false)
const topoData = ref(null)
const selectedDevice = ref(null)
const deviceInterfaces = ref([])
const topoByLevel = computed(() => {
if (!topoData.value) return {}
const levels = {}
for (const dev of topoData.value.topology.devices) {
const lvl = dev.level ?? 4
if (!levels[lvl]) levels[lvl] = []
levels[lvl].push(dev)
}
// Sort each level by host
for (const lvl in levels) levels[lvl].sort((a, b) => a.host.localeCompare(b.host, undefined, { numeric: true }))
return levels
})
const oltChains = computed(() => {
if (!topoData.value) return []
return topoData.value.chains.filter(c => c.leafType === 'olt')
})
const selectedDeviceData = computed(() => {
if (!selectedDevice.value || !topoData.value) return null
return topoData.value.topology.devices.find(d => d.host === selectedDevice.value)
})
const deviceLinks = computed(() => {
if (!selectedDevice.value || !topoData.value) return []
return topoData.value.topology.links.filter(l => l.from === selectedDevice.value || l.to === selectedDevice.value)
})
function topoTypeLabel (type) {
const labels = { oltGateways: 'OLT Gateways', edgeRouters: 'Edge Routers', coreSwitches: 'Core Switches', servers: 'Serveurs', publicInfra: 'Infra Publique', unknown: 'Inconnu' }
return labels[type] || type
}
function healthClass (health) {
if (health == null) return 'topo-h-unknown'
if (health >= 90) return 'topo-h-good'
if (health >= 70) return 'topo-h-warn'
return 'topo-h-crit'
}
function devClass (dev) {
const hasKuma = dev.kuma != null
const kumaUp = dev.kuma?.status === 1
const kumaDown = dev.kuma?.status === 0
const noData = !hasKuma && (dev.ifSummary?.activeTotal || 0) === 0 // no Kuma + no SNMP = unknown
return {
'topo-device--selected': selectedDevice.value === dev.host,
'topo-device--danger': kumaDown, // only red if Kuma confirms DOWN
'topo-device--ok': kumaUp, // green if Kuma confirms UP
'topo-device--nodata': noData, // grey if no monitoring data at all
'topo-device--warning': !hasKuma && !noData && dev.ifSummary?.health < 50, // some SNMP but unhealthy
}
}
function kumaClass (dev) {
if (!dev.kuma) return 'topo-kuma--unknown'
if (dev.kuma.status === 1) return 'topo-kuma--up'
if (dev.kuma.status === 0) return 'topo-kuma--down'
return 'topo-kuma--pending'
}
function kumaTitle (dev) {
if (!dev.kuma) return 'Pas monitoré dans Kuma'
const st = dev.kuma.status === 1 ? 'UP' : dev.kuma.status === 0 ? 'DOWN' : 'Pending'
const rt = dev.kuma.responseTime ? ` (${Math.round(dev.kuma.responseTime)}ms)` : ''
return `${dev.kuma.name}: ${st}${rt}`
}
function formatSpeed (speed) {
if (!speed) return ''
if (speed >= 1e9) return (speed / 1e9).toFixed(0) + 'G'
if (speed >= 1e6) return (speed / 1e6).toFixed(0) + 'M'
return speed + ''
}
function selectDevice (dev) {
selectedDevice.value = selectedDevice.value === dev.host ? null : dev.host
if (selectedDevice.value) loadDeviceInterfaces(dev.host)
}
async function loadDeviceInterfaces (host) {
try {
const res = await fetch(`${hubUrl}/network/health/${encodeURIComponent(host)}`)
if (!res.ok) return
const data = await res.json()
deviceInterfaces.value = data.interfaces || []
} catch { deviceInterfaces.value = [] }
}
async function loadTopology () {
topoLoading.value = true
try {
const res = await fetch(`${hubUrl}/network/dependency-map`)
if (!res.ok) throw new Error('HTTP ' + res.status)
topoData.value = await res.json()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur topologie: ' + e.message })
} finally {
topoLoading.value = false
}
}
// AI Analysis tab
const analysisLoading = ref(false)
const analysisResult = ref(null)
function severityIcon (s) {
if (s === 'critical') return 'error'
if (s === 'high') return 'warning'
if (s === 'medium') return 'info'
return 'check_circle'
}
async function runAnalysis () {
analysisLoading.value = true
analysisResult.value = null
try {
const res = await fetch(`${hubUrl}/network/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: 'Analyse complète du réseau — identifie les pannes, risques et recommandations' }),
})
if (!res.ok) throw new Error('HTTP ' + res.status)
analysisResult.value = await res.json()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur analyse: ' + e.message })
} finally {
analysisLoading.value = false
}
}
onMounted(() => {
loadOltRegistry()
loadLocations()
fetchSnmpStats()
// Auto-load topology if on that tab
if (activeTab.value === 'topology') loadTopology()
})
</script>
@ -782,4 +1527,159 @@ onMounted(() => {
padding: 6px 10px; font-size: 12px; cursor: pointer;
&:hover, &.active { background: var(--ops-bg-hover); }
}
/* ── Topology tab ── */
.topo-container { max-width: 1400px; }
.topo-summary-card {
min-width: 100px; border-radius: 8px;
.topo-summary-count { font-size: 24px; font-weight: 700; color: var(--ops-text); line-height: 1.2; }
.topo-summary-label { font-size: 11px; color: var(--ops-text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
}
.topo-hierarchy { display: flex; flex-direction: column; gap: 16px; }
.topo-level {
display: flex; align-items: flex-start; gap: 12px;
}
.topo-level-label {
min-width: 110px; font-size: 10px; font-weight: 700; letter-spacing: 1px;
color: var(--ops-text-muted); text-transform: uppercase; padding-top: 10px;
border-right: 2px solid var(--ops-border); padding-right: 10px; text-align: right;
}
.topo-level-devices {
display: flex; flex-wrap: wrap; gap: 8px; flex: 1;
}
.topo-device {
display: flex; flex-direction: column; align-items: center; gap: 2px;
padding: 8px 12px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--ops-border); background: var(--ops-surface);
min-width: 110px; transition: all .15s; position: relative;
&:hover { border-color: #818cf8; box-shadow: 0 2px 8px rgba(99,102,241,.15); }
}
.topo-device--core { border-left: 3px solid #6366f1; }
.topo-device--edge { border-left: 3px solid #10b981; }
.topo-device--olt { border-left: 3px solid #f59e0b; }
.topo-device--olt-leaf { border-left: 3px solid #ef4444; min-width: 100px; padding: 6px 10px; }
.topo-device--selected { border-color: #6366f1 !important; background: #eef2ff; box-shadow: 0 2px 12px rgba(99,102,241,.2); }
.topo-device--ok { border-color: #22c55e; background: #f0fdf4; }
.topo-device--warning { border-color: #f59e0b; background: #fffbeb; }
.topo-device--danger { border-color: #ef4444; background: #fef2f2; }
.topo-device--nodata { border-color: #d1d5db; background: #f9fafb; opacity: .7; }
.topo-device-host { font-family: monospace; font-size: 11px; font-weight: 600; color: var(--ops-text); }
.topo-device-type { font-size: 10px; color: var(--ops-text-muted); }
.topo-device-stats { font-size: 10px; font-family: monospace; color: var(--ops-text-muted); }
.topo-kuma-label { font-size: 9px; color: var(--ops-text-muted); max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Kuma reachability dot (top-right corner of card) */
.topo-kuma-dot {
position: absolute; top: 4px; right: 4px;
width: 8px; height: 8px; border-radius: 50%;
}
.topo-kuma--up { background: #22c55e; box-shadow: 0 0 4px #22c55e88; }
.topo-kuma--down { background: #ef4444; box-shadow: 0 0 4px #ef444488; animation: kuma-pulse 1.5s infinite; }
.topo-kuma--pending { background: #f59e0b; }
.topo-kuma--unknown { background: #9ca3af; }
@keyframes kuma-pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
.topo-device-onus { font-size: 10px; color: var(--ops-text-muted); display: flex; align-items: center; gap: 3px; }
.topo-device-health { display: flex; align-items: center; gap: 4px; width: 100%; }
.topo-health-bar { flex: 1; height: 4px; background: #e5e7eb; border-radius: 2px; overflow: hidden; }
.topo-health-fill { height: 100%; border-radius: 2px; transition: width .3s; }
.topo-health-text { font-size: 9px; font-weight: 600; min-width: 28px; text-align: right; }
.topo-h-good { background: #22c55e; color: #22c55e; }
.topo-h-warn { background: #f59e0b; color: #f59e0b; }
.topo-h-crit { background: #ef4444; color: #ef4444; }
.topo-h-unknown { background: #9ca3af; color: #9ca3af; }
/* Dependency chains */
.topo-chains { max-width: 1200px; }
.topo-chain {
display: flex; align-items: center; flex-wrap: wrap; gap: 4px;
padding: 4px 0; border-bottom: 1px solid #f1f5f9;
font-size: 12px;
}
.topo-chain-node {
display: inline-flex; align-items: center; gap: 3px;
padding: 2px 8px; border-radius: 4px; background: #f8fafc;
font-family: monospace; font-size: 11px;
}
.topo-chain-core-switch { border-left: 3px solid #6366f1; }
.topo-chain-edge-router { border-left: 3px solid #10b981; }
.topo-chain-olt-gateway { border-left: 3px solid #f59e0b; }
.topo-chain-olt { border-left: 3px solid #8b5cf6; }
.topo-chain-health { font-size: 10px; }
.topo-chain-onus { font-size: 10px; color: #64748b; }
.topo-chain-arrow { color: #94a3b8; font-weight: 600; }
/* Device detail panel */
.topo-detail { max-width: 700px; }
.topo-detail-link {
display: flex; align-items: center; gap: 6px;
padding: 3px 0; font-size: 12px;
}
.topo-detail-if {
display: flex; align-items: center; gap: 6px;
padding: 2px 0; font-size: 11px; font-family: monospace;
}
.topo-if-status {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
}
/* ── Analysis tab ── */
.analysis-container { max-width: 900px; }
.analysis-severity {
color: white;
&--critical { background: #dc2626; }
&--high { background: #ea580c; }
&--medium { background: #d97706; }
&--low { background: #16a34a; }
&--unknown { background: #6b7280; }
}
.analysis-text { font-size: 14px; line-height: 1.6; color: var(--ops-text); white-space: pre-wrap; }
.analysis-chain {
font-family: monospace; font-size: 12px; padding: 4px 8px;
background: #f8fafc; border-radius: 4px; margin-bottom: 4px;
}
.analysis-action {
padding: 6px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px;
}
.analysis-device {
padding: 4px 0; font-size: 13px; border-bottom: 1px solid #f8fafc;
code { background: #f1f5f9; padding: 1px 6px; border-radius: 3px; font-size: 12px; }
}
.analysis-evidence {
display: inline-block; font-size: 11px; color: #64748b; background: #f1f5f9;
padding: 1px 8px; border-radius: 10px; margin: 2px 4px 2px 0;
}
.olt-health-card { min-width: 140px; border-radius: 8px; }
.olt-health--ok { border-left: 3px solid #22c55e; }
.olt-health--warning { border-left: 3px solid #f59e0b; }
.olt-health--critical { border-left: 3px solid #ef4444; }
.olt-health--unreachable { border-left: 3px solid #6b7280; background: #f9fafb; }
.analysis-raw {
font-size: 11px; background: #1e293b; color: #e2e8f0; padding: 12px;
border-radius: 6px; overflow-x: auto; max-height: 400px;
}
/* ── Network Map (Cytoscape) ── */
.netmap-container { display: flex; flex-direction: column; height: calc(100vh - 180px); }
.netmap-canvas {
flex: 1; min-height: 400px; border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 8px; background: #fafbfc;
}
.netmap-detail { max-height: 300px; overflow-y: auto; }
.netmap-device { border-bottom: 1px solid #f1f5f9; }
.netmap-device--down { background: #fef2f2; }
</style>

View File

@ -2,9 +2,11 @@
<q-page padding>
<div class="text-h6 text-weight-bold q-mb-md">Rapports</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4" v-for="report in reports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px" @click="report.action">
<!-- Comptabilité section -->
<div class="text-subtitle2 text-grey-7 text-weight-bold q-mb-sm">Comptabilité & Finances</div>
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6 col-lg-4" v-for="report in financeReports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px" @click="$router.push(report.route)">
<div class="row items-center q-mb-sm">
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
@ -13,23 +15,62 @@
</div>
</div>
</div>
<!-- Opérations section -->
<div class="text-subtitle2 text-grey-7 text-weight-bold q-mb-sm">Opérations</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
<div class="row items-center q-mb-sm">
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
<q-badge class="q-ml-sm" color="grey-4" text-color="grey-7" label="Bientôt" />
</div>
<div class="text-caption text-grey-6">{{ report.description }}</div>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
const reports = [
const financeReports = [
{
title: 'Revenus par compte',
description: 'Évolution mensuelle des revenus par compte GL (4000-5000). Graphique stacked + export CSV.',
icon: 'trending_up',
color: 'green-6',
route: '/rapports/revenus',
},
{
title: 'Ventes détaillées',
description: 'Liste des factures avec sous-total, TPS, TVQ et total. Inclut les notes de crédit.',
icon: 'receipt_long',
color: 'indigo-6',
route: '/rapports/ventes',
},
{
title: 'Rapport de taxes (TPS/TVQ)',
description: 'Taxes perçues vs payées par période mensuelle ou trimestrielle. Pour les déclarations fiscales.',
icon: 'account_balance',
color: 'purple-6',
route: '/rapports/taxes',
},
{
title: 'Âge des comptes',
description: 'Comptes à recevoir par tranche d\'ancienneté: courant, 30, 60, 90, 120+ jours.',
icon: 'account_balance_wallet',
color: 'red-6',
route: '/rapports/ar',
},
]
const opsReports = [
{
title: 'Clients par territoire',
description: 'Répartition des clients actifs par zone géographique.',
icon: 'map',
color: 'indigo-6',
action: () => {},
},
{
title: 'Revenus mensuels',
description: 'Évolution des revenus d\'abonnement sur les 12 derniers mois.',
icon: 'trending_up',
color: 'green-6',
color: 'teal-6',
action: () => {},
},
{

View File

@ -0,0 +1,171 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Âge des comptes à recevoir</div>
<q-space />
<q-btn flat dense icon="download" label="CSV" @click="downloadCSV" :disable="!rows.length" />
</div>
<!-- Filters -->
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input v-model="asOfDate" type="date" label="En date du" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
</div>
</div>
<!-- Summary buckets -->
<div v-if="summary" class="row q-col-gutter-sm q-mb-md">
<div v-for="b in bucketCards" :key="b.key" class="col-auto">
<div class="ops-card text-center" style="min-width:120px">
<div class="text-caption text-grey-6">{{ b.label }}</div>
<div class="text-h6 text-weight-bold" :class="b.color">{{ formatMoney(summary[b.key]) }}</div>
</div>
</div>
</div>
<!-- Chart -->
<div v-if="summary" class="ops-card q-mb-md" style="height:280px;position:relative">
<canvas ref="chartCanvas"></canvas>
</div>
<!-- Data table -->
<q-table
v-if="rows.length"
:rows="rows"
:columns="columns"
row-key="customer"
flat bordered
class="ops-table"
:pagination="{ rowsPerPage: 50, sortBy: 'total', descending: true }"
dense
:filter="search"
>
<template #top-right>
<q-input v-model="search" dense outlined placeholder="Filtrer..." clearable style="width:200px">
<template #prepend><q-icon name="search" /></template>
</q-input>
</template>
<template #body-cell-total="props">
<q-td :props="props">
<span class="text-weight-bold text-negative">{{ formatMoney(props.value) }}</span>
</q-td>
</template>
<template #body-cell-d120="props">
<q-td :props="props">
<span :class="props.value > 0 ? 'text-negative text-weight-bold' : ''">
{{ props.value > 0 ? formatMoney(props.value) : '—' }}
</span>
</q-td>
</template>
</q-table>
<!-- Empty state -->
<div v-if="!loading && !rows.length" class="text-center text-grey-5 q-pa-xl">
<q-icon name="account_balance_wallet" size="64px" class="q-mb-md" />
<div>Sélectionnez une date et cliquez <b>Générer</b></div>
</div>
</q-page>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { fetchARReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters'
import Chart from 'chart.js/auto'
const asOfDate = ref(new Date().toISOString().slice(0, 10))
const loading = ref(false)
const rows = ref([])
const summary = ref(null)
const search = ref('')
const chartCanvas = ref(null)
let chartInstance = null
const bucketCards = [
{ key: 'current', label: 'Courant', color: 'text-positive' },
{ key: 'd30', label: '30 jours', color: 'text-warning' },
{ key: 'd60', label: '60 jours', color: 'text-orange' },
{ key: 'd90', label: '90 jours', color: 'text-deep-orange' },
{ key: 'd120', label: '120+ jours', color: 'text-negative' },
{ key: 'total', label: 'Total dû', color: 'text-negative' },
]
const columns = [
{ name: 'customer', label: '#Client', field: 'customer', align: 'left', sortable: true },
{ name: 'customer_name', label: 'Nom', field: 'customer_name', align: 'left', sortable: true },
{ name: 'current', label: 'Courant', field: 'current', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
{ name: 'd30', label: '30 jours', field: 'd30', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
{ name: 'd60', label: '60 jours', field: 'd60', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
{ name: 'd90', label: '90 jours', field: 'd90', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
{ name: 'd120', label: '120+', field: 'd120', align: 'right', sortable: true },
{ name: 'total', label: 'Total', field: 'total', align: 'right', sortable: true },
]
async function loadReport () {
loading.value = true
try {
const res = await fetchARReport(asOfDate.value)
rows.value = res.rows || []
summary.value = res.summary || null
await nextTick()
renderChart()
} catch (e) {
console.error('AR report error:', e)
} finally {
loading.value = false
}
}
function renderChart () {
if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !summary.value) return
const s = summary.value
chartInstance = new Chart(chartCanvas.value, {
type: 'doughnut',
data: {
labels: ['Courant', '30 jours', '60 jours', '90 jours', '120+ jours'],
datasets: [{
data: [s.current, s.d30, s.d60, s.d90, s.d120],
backgroundColor: ['#10b981', '#f59e0b', '#f97316', '#ef4444', '#991b1b'],
borderWidth: 2,
borderColor: '#fff',
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { font: { size: 12 } } },
tooltip: { callbacks: { label: ctx => ctx.label + ': ' + formatMoney(ctx.parsed) } },
},
},
})
}
function downloadCSV () {
if (!rows.value.length) return
const header = ['#Client', 'Nom', 'Courant', '30 jours', '60 jours', '90 jours', '120+ jours', 'Total']
const csvRows = rows.value.map(r => [
r.customer, `"${r.customer_name}"`,
r.current.toFixed(2), r.d30.toFixed(2), r.d60.toFixed(2),
r.d90.toFixed(2), r.d120.toFixed(2), r.total.toFixed(2),
])
if (summary.value) {
const s = summary.value
csvRows.push(['', 'TOTAL', s.current.toFixed(2), s.d30.toFixed(2), s.d60.toFixed(2), s.d90.toFixed(2), s.d120.toFixed(2), s.total.toFixed(2)])
}
const csv = [header, ...csvRows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `age_comptes_${asOfDate.value}.csv`
a.click()
}
</script>

View File

@ -0,0 +1,317 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Revenus par compte</div>
<q-space />
<q-btn flat dense icon="download" label="CSV" @click="downloadCSV" :disable="!data.accounts?.length" />
</div>
<!-- Filters -->
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input v-model="startDate" type="date" label="Début" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-input v-model="endDate" type="date" label="Fin" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-btn-toggle
v-model="mode"
dense no-caps unelevated
toggle-color="primary"
:options="[{ label: 'Comptes GL', value: 'gl' }, { label: 'Groupes articles', value: 'items' }]"
/>
</div>
<div class="col-auto">
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
</div>
</div>
<!-- Account selector -->
<div v-if="allAccounts.length" class="q-mb-md">
<q-select
v-model="selectedAccounts"
:options="accountOptions"
label="Comptes à afficher (vide = tous)"
multiple
use-chips
dense outlined
emit-value map-options
option-value="value"
option-label="label"
clearable
style="max-width:800px"
>
<template #option="{ opt, selected, toggleOption }">
<q-item clickable @click="toggleOption(opt)" :active="selected" dense>
<q-item-section side>
<q-checkbox :model-value="selected" @update:model-value="toggleOption(opt)" dense />
</q-item-section>
<q-item-section>
<q-item-label>{{ opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
<template #after>
<q-btn flat dense icon="refresh" @click="loadReport" :loading="loading" />
</template>
</q-select>
</div>
<!-- Chart -->
<div v-if="chartAccounts.length" class="ops-card q-mb-md" style="height:400px;position:relative">
<canvas ref="chartCanvas"></canvas>
</div>
<!-- Summary cards -->
<div v-if="data.accounts?.length" class="row q-col-gutter-sm q-mb-md">
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Revenu total</div>
<div class="text-h6 text-weight-bold text-positive">{{ formatMoney(grandTotal) }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Comptes actifs</div>
<div class="text-h6 text-weight-bold">{{ data.accounts.length }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Mois</div>
<div class="text-h6 text-weight-bold">{{ data.months?.length || 0 }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:140px">
<div class="text-caption text-grey-6">Mode</div>
<div class="text-subtitle2 text-weight-bold">{{ data.mode === 'items' ? 'Articles' : 'GL' }}</div>
</div>
</div>
</div>
<!-- Data table -->
<q-table
v-if="data.accounts?.length"
:rows="tableRows"
:columns="tableColumns"
row-key="name"
flat bordered
class="ops-table"
:pagination="{ rowsPerPage: 50 }"
dense
>
<template #body-cell-total="props">
<q-td :props="props">
<span class="text-weight-bold">{{ formatMoney(props.value) }}</span>
</q-td>
</template>
</q-table>
<!-- Empty state -->
<div v-if="!loading && !data.accounts?.length" class="text-center text-grey-5 q-pa-xl">
<q-icon name="trending_up" size="64px" class="q-mb-md" />
<div>Sélectionnez une période et cliquez <b>Générer</b></div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { fetchRevenueReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters'
import Chart from 'chart.js/auto'
const now = new Date()
const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
const endDate = ref(now.toISOString().slice(0, 10))
const mode = ref('gl')
const loading = ref(false)
const data = ref({})
const chartCanvas = ref(null)
let chartInstance = null
const selectedAccounts = ref([])
const allAccounts = ref([])
const accountOptions = computed(() =>
allAccounts.value.map(a => ({
label: a.number ? `${a.number}${a.label}` : a.label,
value: a.name,
}))
)
const FR_SHORT = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc']
const grandTotal = computed(() => {
if (!data.value.accounts) return 0
return data.value.accounts.reduce((s, a) => s + a.total, 0)
})
// Accounts shown in chart selected or all if none selected
const chartAccounts = computed(() => {
if (!data.value.accounts?.length) return []
if (!selectedAccounts.value?.length) return data.value.accounts
return data.value.accounts.filter(a =>
selectedAccounts.value.includes(a.name)
)
})
const tableColumns = computed(() => {
const cols = [
{ name: 'number', label: '#', field: 'number', align: 'left', sortable: true, style: 'width:60px' },
{ name: 'label', label: 'Compte', field: 'label', align: 'left', sortable: true },
]
for (const m of (data.value.months || [])) {
const [y, mo] = m.split('-')
cols.push({
name: m,
label: FR_SHORT[parseInt(mo) - 1] + ' ' + y,
field: m,
align: 'right',
sortable: true,
format: v => v ? formatMoney(v) : '—',
})
}
cols.push({ name: 'total', label: 'Total', field: 'total', align: 'right', sortable: true })
return cols
})
const tableRows = computed(() => {
if (!data.value.accounts) return []
return data.value.accounts.map(a => {
const row = { name: a.name, number: a.number, label: a.label, total: a.total }
for (let i = 0; i < (data.value.months || []).length; i++) {
row[data.value.months[i]] = a.monthly[i] || 0
}
return row
})
})
async function loadReport () {
loading.value = true
try {
const filter = selectedAccounts.value?.length ? selectedAccounts.value.join(',') : ''
data.value = await fetchRevenueReport(startDate.value, endDate.value, { mode: mode.value, filter })
// Populate account selector from API response
if (data.value.all_accounts?.length) {
allAccounts.value = data.value.all_accounts
}
await nextTick()
renderChart()
} catch (e) {
console.error('Revenue report error:', e)
} finally {
loading.value = false
}
}
const CHART_COLORS = [
'#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#06b6d4', '#f97316', '#ec4899', '#14b8a6', '#84cc16',
'#a855f7', '#3b82f6', '#d946ef', '#0ea5e9', '#f43f5e',
]
function renderChart () {
if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !chartAccounts.value.length) return
const months = data.value.months || []
const labels = months.map(m => {
const [y, mo] = m.split('-')
return FR_SHORT[parseInt(mo) - 1] + ' ' + y.slice(2)
})
// Group small accounts into "Autres" if more than 8 shown
let accts = [...chartAccounts.value]
let datasets
if (accts.length > 8) {
const top = accts.slice(0, 7)
const others = accts.slice(7)
const othersMonthly = months.map((_, mi) =>
others.reduce((s, a) => s + (a.monthly[mi] || 0), 0)
)
datasets = [
...top.map((a, i) => makeDataset(a, i)),
{
label: 'Autres',
data: othersMonthly,
backgroundColor: 'rgba(156,163,175,0.4)',
borderColor: '#9ca3af',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHitRadius: 10,
},
]
} else {
datasets = accts.map((a, i) => makeDataset(a, i))
}
chartInstance = new Chart(chartCanvas.value, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
label: ctx => ctx.dataset.label + ': ' + formatMoney(ctx.parsed.y),
footer: items => 'Total: ' + formatMoney(items.reduce((s, i) => s + i.parsed.y, 0)),
},
},
},
scales: {
x: { grid: { display: false } },
y: {
stacked: true,
ticks: { callback: v => (v / 1000).toFixed(0) + 'k$' },
grid: { color: 'rgba(0,0,0,0.06)' },
},
},
},
})
}
function makeDataset (account, index) {
const color = CHART_COLORS[index % CHART_COLORS.length]
return {
label: account.number ? `${account.number} ${account.label}` : account.label,
data: account.monthly,
backgroundColor: color + '55',
borderColor: color,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHitRadius: 10,
}
}
function downloadCSV () {
if (!data.value.accounts?.length) return
const months = data.value.months || []
const header = ['#Compte', 'Nom', ...months, 'Total']
const rows = data.value.accounts.map(a =>
[a.number, a.label, ...a.monthly.map(v => v.toFixed(2)), a.total.toFixed(2)]
)
const totals = months.map((_, i) =>
data.value.accounts.reduce((s, a) => s + (a.monthly[i] || 0), 0).toFixed(2)
)
rows.push(['', 'TOTAL', ...totals, grandTotal.value.toFixed(2)])
const csv = [header, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `revenus_${startDate.value}_${endDate.value}.csv`
a.click()
}
</script>

View File

@ -0,0 +1,241 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Rapport de taxes (TPS / TVQ)</div>
<q-space />
<q-btn flat dense icon="download" label="CSV" @click="downloadCSV" :disable="!periods.length" />
</div>
<!-- Filters -->
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input v-model="startDate" type="date" label="Début" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-input v-model="endDate" type="date" label="Fin" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-btn-toggle
v-model="periodType"
:options="[{ label: 'Mensuel', value: 'monthly' }, { label: 'Trimestriel', value: 'quarterly' }]"
dense no-caps unelevated toggle-color="primary"
/>
</div>
<div class="col-auto">
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
</div>
</div>
<!-- Chart -->
<div v-if="periods.length" class="ops-card q-mb-md" style="height:340px;position:relative">
<canvas ref="chartCanvas"></canvas>
</div>
<!-- Data table -->
<q-table
v-if="periods.length"
:rows="periods"
:columns="columns"
row-key="label"
flat bordered
class="ops-table"
:pagination="{ rowsPerPage: 0 }"
hide-pagination
dense
>
<!-- Color cells for net amounts -->
<template #body-cell-tps_net="props">
<q-td :props="props">
<span class="text-weight-bold" :class="props.value >= 0 ? 'text-negative' : 'text-positive'">
{{ formatMoney(props.value) }}
</span>
</q-td>
</template>
<template #body-cell-tvq_net="props">
<q-td :props="props">
<span class="text-weight-bold" :class="props.value >= 0 ? 'text-negative' : 'text-positive'">
{{ formatMoney(props.value) }}
</span>
</q-td>
</template>
<template #body-cell-total_tax_net="props">
<q-td :props="props">
<span class="text-weight-bold" :class="props.value >= 0 ? 'text-negative' : 'text-positive'">
{{ formatMoney(props.value) }}
</span>
</q-td>
</template>
<template #body-cell-revenue="props">
<q-td :props="props">
<span class="text-weight-bold text-positive">{{ formatMoney(props.value) }}</span>
</q-td>
</template>
</q-table>
<!-- Summary for selected period -->
<div v-if="periods.length" class="q-mt-md">
<div class="ops-card">
<div class="text-subtitle2 text-weight-bold q-mb-sm">Résumé Période complète</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<table class="full-width" style="border-collapse:collapse">
<tr class="text-weight-bold" style="border-bottom:1px solid #e0e0e0">
<td class="q-pa-xs"></td>
<td class="q-pa-xs text-right">Perçue</td>
<td class="q-pa-xs text-right">Payée</td>
<td class="q-pa-xs text-right">Net à remettre</td>
</tr>
<tr style="border-bottom:1px solid #f0f0f0">
<td class="q-pa-xs text-weight-bold">TPS (5%)</td>
<td class="q-pa-xs text-right">{{ formatMoney(totals.tps_collected) }}</td>
<td class="q-pa-xs text-right">{{ formatMoney(totals.tps_paid) }}</td>
<td class="q-pa-xs text-right text-weight-bold">{{ formatMoney(totals.tps_net) }}</td>
</tr>
<tr style="border-bottom:1px solid #f0f0f0">
<td class="q-pa-xs text-weight-bold">TVQ (9.975%)</td>
<td class="q-pa-xs text-right">{{ formatMoney(totals.tvq_collected) }}</td>
<td class="q-pa-xs text-right">{{ formatMoney(totals.tvq_paid) }}</td>
<td class="q-pa-xs text-right text-weight-bold">{{ formatMoney(totals.tvq_net) }}</td>
</tr>
<tr class="text-weight-bold" style="border-top:2px solid #333">
<td class="q-pa-xs">Total taxes</td>
<td class="q-pa-xs text-right">{{ formatMoney(totals.tps_collected + totals.tvq_collected) }}</td>
<td class="q-pa-xs text-right">{{ formatMoney(totals.tps_paid + totals.tvq_paid) }}</td>
<td class="q-pa-xs text-right text-negative">{{ formatMoney(totals.total_tax_net) }}</td>
</tr>
</table>
</div>
<div class="col-12 col-md-6">
<div class="text-caption text-grey-6 q-mb-xs">Revenu total (comptes 4000+)</div>
<div class="text-h5 text-weight-bold text-positive">{{ formatMoney(totals.revenue) }}</div>
<div class="text-caption text-grey-5 q-mt-sm">
Le montant «Net à remettre» correspond à la taxe perçue sur les ventes moins la taxe payée sur les achats.
Un montant positif = à remettre au gouvernement.
</div>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<div v-if="!loading && !periods.length" class="text-center text-grey-5 q-pa-xl">
<q-icon name="account_balance" size="64px" class="q-mb-md" />
<div>Sélectionnez une période et cliquez <b>Générer</b></div>
<div class="text-caption q-mt-sm">Ce rapport produit les montants TPS/TVQ pour les déclarations fiscales</div>
</div>
</q-page>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { fetchTaxReport } from 'src/api/reports'
import { formatMoney } from 'src/composables/useFormatters'
import Chart from 'chart.js/auto'
const now = new Date()
const startDate = ref(new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10))
const endDate = ref(now.toISOString().slice(0, 10))
const periodType = ref('monthly')
const loading = ref(false)
const periods = ref([])
const chartCanvas = ref(null)
let chartInstance = null
const columns = [
{ name: 'label', label: 'Période', field: 'label', align: 'left' },
{ name: 'tps_collected', label: 'TPS perçue', field: 'tps_collected', align: 'right', format: v => formatMoney(v) },
{ name: 'tps_paid', label: 'TPS payée', field: 'tps_paid', align: 'right', format: v => formatMoney(v) },
{ name: 'tps_net', label: 'TPS net', field: 'tps_net', align: 'right' },
{ name: 'tvq_collected', label: 'TVQ perçue', field: 'tvq_collected', align: 'right', format: v => formatMoney(v) },
{ name: 'tvq_paid', label: 'TVQ payée', field: 'tvq_paid', align: 'right', format: v => formatMoney(v) },
{ name: 'tvq_net', label: 'TVQ net', field: 'tvq_net', align: 'right' },
{ name: 'total_tax_net', label: 'Total net', field: 'total_tax_net', align: 'right' },
{ name: 'revenue', label: 'Revenu', field: 'revenue', align: 'right' },
]
const totals = computed(() => {
const t = { tps_collected: 0, tps_paid: 0, tps_net: 0, tvq_collected: 0, tvq_paid: 0, tvq_net: 0, total_tax_net: 0, revenue: 0 }
for (const p of periods.value) {
t.tps_collected += p.tps_collected
t.tps_paid += p.tps_paid
t.tps_net += p.tps_net
t.tvq_collected += p.tvq_collected
t.tvq_paid += p.tvq_paid
t.tvq_net += p.tvq_net
t.total_tax_net += p.total_tax_net
t.revenue += p.revenue
}
return t
})
async function loadReport () {
loading.value = true
try {
const res = await fetchTaxReport(startDate.value, endDate.value, periodType.value)
periods.value = res.periods || []
await nextTick()
renderChart()
} catch (e) {
console.error('Tax report error:', e)
} finally {
loading.value = false
}
}
function renderChart () {
if (chartInstance) chartInstance.destroy()
if (!chartCanvas.value || !periods.value.length) return
const labels = periods.value.map(p => p.label)
chartInstance = new Chart(chartCanvas.value, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'TPS perçue', data: periods.value.map(p => p.tps_collected), backgroundColor: '#6366f188', borderColor: '#6366f1', borderWidth: 1 },
{ label: 'TPS payée', data: periods.value.map(p => -p.tps_paid), backgroundColor: '#6366f144', borderColor: '#6366f1', borderWidth: 1, borderDash: [4, 4] },
{ label: 'TVQ perçue', data: periods.value.map(p => p.tvq_collected), backgroundColor: '#10b98188', borderColor: '#10b981', borderWidth: 1 },
{ label: 'TVQ payée', data: periods.value.map(p => -p.tvq_paid), backgroundColor: '#10b98144', borderColor: '#10b981', borderWidth: 1, borderDash: [4, 4] },
{ label: 'Revenu', data: periods.value.map(p => p.revenue), type: 'line', borderColor: '#f59e0b', backgroundColor: 'transparent', borderWidth: 2, yAxisID: 'y1', tension: 0.3, pointRadius: 3 },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + formatMoney(Math.abs(ctx.parsed.y)) } },
},
scales: {
x: { grid: { display: false } },
y: { position: 'left', ticks: { callback: v => formatMoney(v) }, grid: { color: 'rgba(0,0,0,0.06)' } },
y1: { position: 'right', ticks: { callback: v => (v / 1000).toFixed(0) + 'k$' }, grid: { display: false } },
},
},
})
}
function downloadCSV () {
if (!periods.value.length) return
const header = ['Période', 'TPS perçue', 'TPS payée', 'TPS net', 'TVQ perçue', 'TVQ payée', 'TVQ net', 'Total net', 'Revenu']
const csvRows = periods.value.map(p => [
p.label, p.tps_collected.toFixed(2), p.tps_paid.toFixed(2), p.tps_net.toFixed(2),
p.tvq_collected.toFixed(2), p.tvq_paid.toFixed(2), p.tvq_net.toFixed(2),
p.total_tax_net.toFixed(2), p.revenue.toFixed(2),
])
const t = totals.value
csvRows.push(['TOTAL', t.tps_collected.toFixed(2), t.tps_paid.toFixed(2), t.tps_net.toFixed(2),
t.tvq_collected.toFixed(2), t.tvq_paid.toFixed(2), t.tvq_net.toFixed(2),
t.total_tax_net.toFixed(2), t.revenue.toFixed(2),
])
const csv = [header, ...csvRows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `taxes_${startDate.value}_${endDate.value}.csv`
a.click()
}
</script>

View File

@ -0,0 +1,178 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense icon="arrow_back" to="/rapports" class="q-mr-sm" />
<div class="text-h6 text-weight-bold">Rapport de ventes</div>
<q-space />
<q-btn flat dense icon="download" label="CSV" @click="downloadCSV" :disable="!rows.length" />
</div>
<!-- Filters -->
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-auto">
<q-input v-model="startDate" type="date" label="Début" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-input v-model="endDate" type="date" label="Fin" dense outlined style="width:160px" />
</div>
<div class="col-auto">
<q-btn color="primary" label="Générer" icon="play_arrow" @click="loadReport" :loading="loading" />
</div>
</div>
<!-- Summary cards -->
<div v-if="summary" class="row q-col-gutter-sm q-mb-md">
<div class="col-auto">
<div class="ops-card text-center" style="min-width:130px">
<div class="text-caption text-grey-6">Factures</div>
<div class="text-h6 text-weight-bold">{{ summary.count }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:130px">
<div class="text-caption text-grey-6">Sous-total</div>
<div class="text-h6 text-weight-bold">{{ formatMoney(summary.subtotal) }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:110px">
<div class="text-caption text-grey-6">TPS</div>
<div class="text-h6 text-weight-bold">{{ formatMoney(summary.tps) }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:110px">
<div class="text-caption text-grey-6">TVQ</div>
<div class="text-h6 text-weight-bold">{{ formatMoney(summary.tvq) }}</div>
</div>
</div>
<div class="col-auto">
<div class="ops-card text-center" style="min-width:130px">
<div class="text-caption text-grey-6">Total</div>
<div class="text-h6 text-weight-bold text-positive">{{ formatMoney(summary.total) }}</div>
</div>
</div>
<div v-if="summary.returns" class="col-auto">
<div class="ops-card text-center" style="min-width:130px">
<div class="text-caption text-grey-6">Notes de crédit</div>
<div class="text-h6 text-weight-bold text-negative">{{ summary.returns }}</div>
</div>
</div>
</div>
<!-- Data table -->
<q-table
v-if="rows.length"
:rows="rows"
:columns="columns"
row-key="name"
flat bordered
class="ops-table"
:pagination="{ rowsPerPage: 50 }"
dense
:filter="search"
>
<template #top-right>
<q-input v-model="search" dense outlined placeholder="Filtrer..." clearable style="width:200px">
<template #prepend><q-icon name="search" /></template>
</q-input>
</template>
<template #body-cell-name="props">
<q-td :props="props">
<a :href="erpLink('Sales Invoice', props.value)" target="_blank" class="text-primary">{{ props.value }}</a>
</q-td>
</template>
<template #body-cell-total="props">
<q-td :props="props">
<span :class="props.row.is_return ? 'text-negative' : 'text-weight-bold'">
{{ formatMoney(props.value) }}
</span>
</q-td>
</template>
<template #body-cell-status="props">
<q-td :props="props">
<q-badge :color="statusColor(props.value)" :label="props.value" />
</q-td>
</template>
</q-table>
<!-- Empty state -->
<div v-if="!loading && !rows.length" class="text-center text-grey-5 q-pa-xl">
<q-icon name="receipt_long" size="64px" class="q-mb-md" />
<div>Sélectionnez une période et cliquez <b>Générer</b></div>
</div>
</q-page>
</template>
<script setup>
import { ref } from 'vue'
import { fetchSalesReport } from 'src/api/reports'
import { formatMoney, formatDateShort, erpLink } from 'src/composables/useFormatters'
const now = new Date()
const startDate = ref(new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10))
const endDate = ref(now.toISOString().slice(0, 10))
const loading = ref(false)
const rows = ref([])
const summary = ref(null)
const search = ref('')
const columns = [
{ name: 'name', label: '#Facture', field: 'name', align: 'left', sortable: true },
{ name: 'date', label: 'Date', field: 'date', align: 'left', sortable: true, format: v => formatDateShort(v) },
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'subtotal', label: 'Sous-total', field: 'subtotal', align: 'right', sortable: true, format: v => formatMoney(v) },
{ name: 'tps', label: 'TPS', field: 'tps', align: 'right', sortable: true, format: v => formatMoney(v) },
{ name: 'tvq', label: 'TVQ', field: 'tvq', align: 'right', sortable: true, format: v => formatMoney(v) },
{ name: 'total', label: 'Total', field: 'total', align: 'right', sortable: true },
{ name: 'outstanding', label: 'Impayé', field: 'outstanding', align: 'right', sortable: true, format: v => v > 0 ? formatMoney(v) : '—' },
{ name: 'status', label: 'Statut', field: 'status', align: 'center', sortable: true },
]
function statusColor (s) {
if (s === 'Paid') return 'positive'
if (s === 'Overdue') return 'negative'
if (s === 'Return') return 'purple'
if (s === 'Cancelled') return 'grey'
return 'warning'
}
async function loadReport () {
loading.value = true
try {
const res = await fetchSalesReport(startDate.value, endDate.value)
rows.value = res.rows || []
summary.value = res.summary || null
} catch (e) {
console.error('Sales report error:', e)
} finally {
loading.value = false
}
}
function downloadCSV () {
if (!rows.value.length) return
const header = ['#Facture', 'Date', '#Client', 'Client', 'SousTotal', 'TPS', 'TVQ', 'Total', 'Impayé', 'Statut']
const csvRows = rows.value.map(r => [
r.name, r.date, r.customer, `"${r.customer_name}"`,
r.subtotal.toFixed(2), r.tps.toFixed(2), r.tvq.toFixed(2),
r.total.toFixed(2), r.outstanding.toFixed(2), r.status,
])
// Summary row
if (summary.value) {
csvRows.push(['', '', '', 'TOTAL',
summary.value.subtotal.toFixed(2), summary.value.tps.toFixed(2),
summary.value.tvq.toFixed(2), summary.value.total.toFixed(2), '', '',
])
}
const csv = [header, ...csvRows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `ventes_${startDate.value}_${endDate.value}.csv`
a.click()
}
</script>

View File

@ -108,9 +108,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { Notify } from 'quasar'
const HUB_URL = (window.location.hostname === 'localhost')
? 'http://localhost:3300'
: 'https://msg.gigafibre.ca'
import { HUB_URL } from 'src/config/hub'
// KPI / Tabs
const kpis = [

View File

@ -1,9 +1,11 @@
/* ── Root ── */
.sb-root {
--sb-bg: #0d0f18; --sb-sidebar: #111422; --sb-card: #181c2e; --sb-card-h: #1e2338;
--sb-border: rgba(255,255,255,0.06); --sb-border-acc: rgba(99,102,241,0.4);
--sb-text: #e2e4ef; --sb-muted: #7b80a0;
/* Light theme — aligned with ops-app design language */
--sb-bg: #f8fafc; --sb-sidebar: #ffffff; --sb-card: #ffffff; --sb-card-h: #f1f5f9;
--sb-border: #e2e8f0; --sb-border-acc: rgba(99,102,241,0.4);
--sb-text: #1e293b; --sb-muted: #64748b;
--sb-acc: #6366f1; --sb-green: #10b981; --sb-red: #ef4444; --sb-orange: #f59e0b;
--sb-overlay: rgba(0,0,0,0.04); --sb-overlay-hover: rgba(0,0,0,0.08);
display: flex; flex-direction: column;
height: 100vh; width: 100%; overflow: hidden;
background: var(--sb-bg); color: var(--sb-text);
@ -11,10 +13,11 @@
}
/* ── Header ── */
.sb-header { display:flex; align-items:center; gap:0.5rem; padding:0 0.75rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 1px 12px rgba(0,0,0,0.4); z-index:30; overflow:hidden; }
.sb-header { display:flex; align-items:center; gap:0.5rem; padding:0 0.75rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 1px 4px rgba(0,0,0,0.06); z-index:30; overflow:hidden; }
.sb-header-left { display:flex; align-items:center; gap:0.4rem; flex-shrink:0; }
.sb-header-center { display:flex; align-items:center; gap:0.35rem; flex:1; justify-content:center; }
.sb-header-right { display:flex; align-items:center; gap:0.35rem; flex-shrink:0; margin-left:auto; }
.sb-nlp-row { padding:0.3rem 0.5rem; border-bottom:1px solid var(--sb-border); background:var(--sb-card); }
.sb-logo-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.72rem; font-weight:700; padding:0.2rem 0.55rem; cursor:pointer; white-space:nowrap; text-decoration:none; }
.sb-logo-btn:hover { color:var(--sb-text); border-color:var(--sb-border-acc); }
.sb-tabs { display:flex; align-items:center; border-left:1px solid var(--sb-border); margin-left:0.25rem; padding-left:0.4rem; gap:0.15rem; }
@ -32,7 +35,7 @@
.sb-planning-toggle {
margin-left: 6px; border-radius: 6px; font-size: 0.72rem;
border: 1px solid rgba(100,180,255,0.15); transition: all 0.15s;
&.active { background: rgba(100,180,255,0.15); border-color: rgba(100,180,255,0.4); color: #93c5fd; }
&.active { background: rgba(100,180,255,0.15); border-color: rgba(100,180,255,0.4); color: #4f76b8; }
}
.sb-search { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.22rem 0.55rem; width:160px; outline:none; }
.sb-search::placeholder { color:var(--sb-muted); }
@ -41,7 +44,7 @@
.sb-search-bar:hover { border-color:var(--sb-border-acc); }
.sb-search-icon { font-size:0.7rem; flex-shrink:0; }
.sb-search-placeholder { color:var(--sb-muted); font-size:0.72rem; }
.sb-search-chip { display:inline-flex; align-items:center; gap:3px; background:rgba(99,102,241,0.2); color:#a5b4fc; border:1px solid rgba(99,102,241,0.3); border-radius:4px; font-size:0.65rem; font-weight:600; padding:1px 6px; white-space:nowrap; cursor:pointer; transition:background 0.1s; }
.sb-search-chip { display:inline-flex; align-items:center; gap:3px; background:rgba(99,102,241,0.12); color:#4f46e5; border:1px solid rgba(99,102,241,0.3); border-radius:4px; font-size:0.65rem; font-weight:600; padding:1px 6px; white-space:nowrap; cursor:pointer; transition:background 0.1s; }
.sb-search-chip:hover { background:rgba(99,102,241,0.35); }
.sb-search-chip-count { background:rgba(99,102,241,0.3); }
// Quick preset bar (saved groups in header)
@ -82,14 +85,14 @@
.sb-logout-btn:hover { opacity:1; color:var(--sb-red); }
.sb-erp-dot { width:7px; height:7px; border-radius:50%; background:var(--sb-red); transition:background 0.3s; }
.sb-erp-dot.ok { background:var(--sb-green); }
.sb-wo-btn { background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:0.7rem; font-weight:800; padding:0.22rem 0.65rem; cursor:pointer; white-space:nowrap; }
.sb-wo-btn { background:var(--sb-acc); border:none; border-radius:6px; color:var(--sb-text); font-size:0.7rem; font-weight:800; padding:0.22rem 0.65rem; cursor:pointer; white-space:nowrap; }
.sb-wo-btn:hover { filter:brightness(1.15); }
/* ── Body ── */
.sb-body { flex:1; display:flex; overflow:hidden; min-height:0; position:relative; }
/* ── Toolbar dropdown panels ── */
.sb-toolbar-panel { background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 4px 16px rgba(0,0,0,0.4); z-index:18; flex-shrink:0; }
.sb-toolbar-panel { background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 4px 16px rgba(0,0,0,0.08); z-index:18; flex-shrink:0; }
.sb-toolbar-panel-inner { padding:0; }
.sb-slide-down-enter-active, .sb-slide-down-leave-active { transition: max-height 0.2s ease, opacity 0.2s ease; overflow:hidden; }
.sb-slide-down-enter-from, .sb-slide-down-leave-to { max-height:0; opacity:0; }
@ -104,23 +107,23 @@
.sbs-icon:hover, .sbs-icon.active { background:var(--sb-card); color:var(--sb-text); }
.sbs-icon svg { width:16px; height:16px; }
.sbs-badge { position:absolute; top:4px; right:4px; width:7px; height:7px; border-radius:50%; background:var(--sb-acc); }
.sbs-count { position:absolute; top:2px; right:0; min-width:14px; height:14px; border-radius:7px; background:var(--sb-acc); color:#fff; font-size:0.5rem; font-weight:700; display:flex; align-items:center; justify-content:center; padding:0 3px; }
.sbs-count { position:absolute; top:2px; right:0; min-width:14px; height:14px; border-radius:7px; background:var(--sb-acc); color:var(--sb-text); font-size:0.5rem; font-weight:700; display:flex; align-items:center; justify-content:center; padding:0 3px; }
.sbs-search-full { width:100%; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.25rem 0.45rem; outline:none; box-sizing:border-box; }
.sbs-search-full:focus { border-color:var(--sb-border-acc); }
/* ── Flyout ── */
.sb-flyout { width:260px; min-width:260px; flex-shrink:0; display:flex; flex-direction:column; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); overflow-y:auto; z-index:19; box-shadow:4px 0 16px rgba(0,0,0,0.3); }
.sb-flyout::-webkit-scrollbar { width:3px; }
.sb-flyout::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-flyout::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); }
.sb-overlay-backdrop { position:absolute; top:0; left:0; right:0; bottom:0; z-index:24; background:rgba(0,0,0,0.3); }
.sb-left-overlay { position:absolute; top:0; left:0; z-index:25; width:280px; min-width:280px; height:100%; display:flex; flex-direction:column; background:var(--sb-sidebar); color:var(--sb-text); border-right:1px solid var(--sb-border); box-shadow:4px 0 16px rgba(0,0,0,0.5); }
.sb-left-overlay { position:absolute; top:0; left:0; z-index:25; width:280px; min-width:280px; height:100%; display:flex; flex-direction:column; background:var(--sb-sidebar); color:var(--sb-text); border-right:1px solid var(--sb-border); box-shadow:4px 0 16px rgba(0,0,0,0.1); }
.sbf-section { padding:0.6rem 0.65rem; border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sbf-section-grow { flex:1; overflow-y:auto; padding:0.5rem 0.65rem; display:flex; flex-direction:column; gap:0.3rem; }
.sbf-section-grow::-webkit-scrollbar { width:3px; }
.sbf-section-grow::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sbf-section-grow::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); }
.sbf-title { font-size:0.58rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:var(--sb-muted); margin-bottom:0.4rem; display:flex; align-items:center; justify-content:space-between; }
.sbf-count { background:var(--sb-acc); color:#fff; font-size:0.58rem; padding:0.05rem 0.35rem; border-radius:8px; font-weight:700; }
.sbf-primary-btn { width:100%; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:0.7rem; font-weight:700; padding:0.35rem 0.5rem; cursor:pointer; }
.sbf-count { background:var(--sb-acc); color:var(--sb-text); font-size:0.58rem; padding:0.05rem 0.35rem; border-radius:8px; font-weight:700; }
.sbf-primary-btn { width:100%; background:var(--sb-acc); border:none; border-radius:6px; color:var(--sb-text); font-size:0.7rem; font-weight:700; padding:0.35rem 0.5rem; cursor:pointer; }
.sbf-primary-btn:hover { filter:brightness(1.12); }
.sbf-chip { display:flex; align-items:center; justify-content:space-between; background:rgba(99,102,241,0.12); border:1px solid var(--sb-border-acc); border-radius:6px; padding:0.2rem 0.45rem; font-size:0.68rem; margin-top:0.3rem; color:var(--sb-acc); }
.sbf-chip button { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.8rem; }
@ -144,7 +147,7 @@
.sb-center-col { flex:1; display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; }
.sb-board { flex:1; overflow:auto; min-width:0; position:relative; min-height:0; }
.sb-board::-webkit-scrollbar { width:5px; height:5px; }
.sb-board::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:3px; }
.sb-board::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); border-radius:3px; }
/* ── Grid ── */
.sb-grid { display:flex; flex-direction:column; }
@ -157,7 +160,7 @@
.sb-cal-hdr-cell.sb-col-today { background:rgba(99,102,241,0.06); }
.sb-cal-wd { font-size:0.58rem; font-weight:800; text-transform:uppercase; letter-spacing:0.05em; color:var(--sb-muted); }
.sb-cal-dn { font-size:0.82rem; font-weight:700; color:var(--sb-text); line-height:1; }
.sb-today-bubble { background:var(--sb-acc); color:#fff !important; border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; }
.sb-today-bubble { background:var(--sb-acc); color:var(--sb-text) !important; border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; }
.sb-htick { position:absolute; top:0; height:100%; border-left:1px solid var(--sb-border); }
.sb-htick-lbl { position:absolute; top:6px; left:4px; font-size:0.58rem; color:var(--sb-muted); white-space:nowrap; font-weight:600; }
.sb-day-lbl { position:absolute; top:4px; left:6px; font-size:0.65rem; font-weight:700; color:var(--sb-text); white-space:nowrap; letter-spacing:0.02em; text-transform:capitalize; }
@ -175,10 +178,10 @@
.sb-loading-row, .sb-empty-row { padding:2rem; text-align:center; color:var(--sb-muted); font-style:italic; }
.sb-cal-row { display:flex; flex:1; min-width:0; overflow:hidden; }
.sb-cal-cell { flex:1; min-width:0; border-left:1px solid var(--sb-border); padding:2px 3px; position:relative; display:flex; flex-direction:column; gap:3px; transition:background 0.1s; }
.sb-cal-cell:hover { background:rgba(255,255,255,0.02); }
.sb-cal-cell:hover { background:rgba(0,0,0,0.02); }
.sb-cal-drop { position:absolute; inset:1px; border:1.5px dashed var(--sb-acc); border-radius:4px; background:rgba(99,102,241,0.08); pointer-events:none; z-index:2; }
.sb-chip { font-size:0.62rem; font-weight:600; padding:4px 6px; border-radius:6px; border-left:3px solid transparent; overflow:hidden; cursor:pointer; transition:background 0.1s; color:var(--sb-text); line-height:1.5; z-index:1; position:relative; }
.sb-chip:hover { background:rgba(255,255,255,0.12); }
.sb-chip:hover { background:rgba(0,0,0,0.06); }
.sb-chip-done { opacity:0.45; text-decoration:line-through; }
.sb-chip-sel { outline:1.5px solid #6366f1; outline-offset:1px; }
.sb-chip-assist { opacity:0.7; border-left-style:dashed !important; font-style:italic; }
@ -186,16 +189,16 @@
.sb-chip-multi { outline:2px solid #f59e0b; outline-offset:1px; }
.sb-chip-urgent { width:6px; height:6px; border-radius:50%; background:#ef4444; flex-shrink:0; box-shadow:0 0 4px #ef4444; }
.sb-chip-line1 { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:3px; }
.sb-chip-line2 { font-size:0.52rem; color:rgba(255,255,255,0.7); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; line-height:1.1; display:flex; align-items:center; gap:2px; }
.sb-chip-line2 { font-size:0.52rem; color:rgba(0,0,0,0.45); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; line-height:1.1; display:flex; align-items:center; gap:2px; }
.sb-chip-line2 svg { width:9px; height:9px; flex-shrink:0; }
.sb-day-load { margin-top:auto; flex-shrink:0; padding:2px 2px 1px; display:flex; align-items:center; gap:3px; }
.sb-day-load-track { flex:1; height:3px; background:rgba(255,255,255,0.08); border-radius:2px; overflow:hidden; }
.sb-day-load-track { flex:1; height:3px; background:rgba(0,0,0,0.05); border-radius:2px; overflow:hidden; }
.sb-day-load-fill { height:100%; border-radius:2px; transition:width 0.3s, background 0.3s; }
.sb-day-load-label { font-size:0.5rem; font-weight:700; color:var(--sb-muted); white-space:nowrap; font-variant-numeric:tabular-nums; }
.sb-res-cell { width:200px; min-width:200px; flex-shrink:0; position:sticky; left:0; z-index:5; align-self:stretch; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); display:flex; align-items:center; gap:0.5rem; padding:0 0.65rem; cursor:pointer; transition:background 0.12s; }
.sb-res-cell:hover { background:var(--sb-card-h); }
.sb-avatar { width:32px; height:32px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:0.68rem; font-weight:700; color:#fff; }
.sb-avatar-mat { background:#1e293b; border:1.5px solid #475569; border-radius:8px; font-size:0.9rem; }
.sb-avatar-mat { background:#e2e8f0; border:1.5px solid #cbd5e1; border-radius:8px; font-size:0.9rem; }
.sb-res-info { flex:1; min-width:0; position:relative; }
.sb-res-name { font-size:0.78rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:3px; }
.sb-res-tag-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
@ -209,11 +212,11 @@
opacity:0; transition:opacity 0.15s;
pointer-events:none;
button {
background:rgba(255,255,255,0.08); border:none; border-radius:4px;
background:rgba(0,0,0,0.05); border:none; border-radius:4px;
color:#c8cce0; font-size:0.65rem; font-weight:700;
width:22px; height:22px; display:flex; align-items:center; justify-content:center;
cursor:pointer; transition:background 0.12s, color 0.12s;
&:hover { background:rgba(255,255,255,0.16); color:#e2e4ef; }
&:hover { background:rgba(0,0,0,0.08); color:var(--sb-text); }
}
.sb-act-play { color:#10b981; &:hover { background:rgba(16,185,129,0.2); } }
}
@ -222,13 +225,13 @@
.sb-res-absence-tag { font-size:0.52rem; color:#94a3b8; font-style:italic; }
.sb-res-sub { display:flex; align-items:center; gap:0.3rem; margin:0.1rem 0; }
.sb-load { font-size:0.58rem; color:var(--sb-muted); }
.sb-util-bar { height:3px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; margin-top:0.15rem; }
.sb-util-bar { height:3px; background:rgba(0,0,0,0.04); border-radius:2px; overflow:hidden; margin-top:0.15rem; }
.sb-util-fill { height:100%; border-radius:2px; transition:width 0.3s, background 0.3s; }
.sb-capacity-line { position:absolute; top:0; bottom:0; width:1px; border-left:1px dashed rgba(255,255,255,0.18); z-index:1; pointer-events:none; }
.sb-capacity-line { position:absolute; top:0; bottom:0; width:1px; border-left:1px dashed rgba(0,0,0,0.1); z-index:1; pointer-events:none; }
.sb-st { font-size:0.58rem; font-weight:700; padding:0.07rem 0.3rem; border-radius:4px; white-space:nowrap; }
.st-available { background:rgba(16,185,129,0.15); color:var(--sb-green); }
.st-enroute { background:rgba(245,158,11,0.15); color:var(--sb-orange); }
.st-busy { background:rgba(99,102,241,0.15); color:#818cf8; }
.st-busy { background:rgba(99,102,241,0.15); color:#4f46e5; }
.st-off { background:rgba(239,68,68,0.1); color:var(--sb-red); }
.st-inactive { background:rgba(100,116,139,0.15); color:#64748b; }
.prio-high { color:var(--sb-red); font-weight:700; }
@ -236,13 +239,13 @@
.prio-low { color:var(--sb-green); }
.sb-timeline { position:relative; flex:1; overflow:hidden; }
.sb-day-bg, .sb-month-bg { position:absolute; top:0; bottom:0; }
.sb-bg-alt { background:rgba(255,255,255,0.012); }
.sb-bg-alt { background:rgba(0,0,0,0.015); }
.sb-bg-today { background:rgba(99,102,241,0.06); }
.sb-hour-guide { position:absolute; top:0; bottom:0; width:1px; background:var(--sb-border); opacity:0.6; pointer-events:none; }
.sb-quarter-guide { position:absolute; top:0; bottom:0; width:1px; background:var(--sb-border); opacity:0.2; pointer-events:none; }
.sb-drop-line { position:absolute; top:2px; bottom:2px; width:3px; background:#6366f1; border-radius:2px; box-shadow:0 0 10px #6366f1, 0 0 4px #6366f1; pointer-events:none; z-index:20; transform:translateX(-1px); }
.sb-block { position:absolute; border-radius:6px; overflow:hidden; display:flex; align-items:center; cursor:grab; z-index:4; box-shadow:0 2px 8px rgba(0,0,0,0.35); transition:box-shadow 0.12s, transform 0.12s; min-width:18px; }
.sb-block:hover { box-shadow:0 4px 16px rgba(0,0,0,0.5); transform:translateY(-1px); z-index:5; }
.sb-block:hover { box-shadow:0 4px 16px rgba(0,0,0,0.1); transform:translateY(-1px); z-index:5; }
.sb-block-done { opacity:0.55; }
// Draft (unpublished) job hatched diagonal stripes
@ -251,25 +254,25 @@
-45deg,
transparent,
transparent 5px,
rgba(255, 255, 255, 0.08) 5px,
rgba(255, 255, 255, 0.08) 10px
rgba(0, 0, 0, 0.06) 5px,
rgba(0, 0, 0, 0.06) 10px
);
border: 1px dashed rgba(255, 255, 255, 0.2);
border: 1px dashed rgba(0, 0, 0, 0.15);
}
// Day picker buttons (recurrence)
.sb-day-btn {
width: 24px; height: 24px; border-radius: 50%; border: 1px solid #4b5563;
background: transparent; color: #9ca3af; font-size: 0.62rem; cursor: pointer;
width: 24px; height: 24px; border-radius: 50%; border: 1px solid #cbd5e1;
background: transparent; color: #64748b; font-size: 0.62rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0;
&:hover { border-color: #818cf8; color: #c4c8e4; }
&:hover { border-color: #6366f1; color: #4f46e5; }
&.sb-day-btn-active { background: #6366f1; border-color: #6366f1; color: #fff; }
}
// Ghost (recurring) block
.sb-block-ghost {
opacity: 0.55;
border: 2px dashed rgba(255, 255, 255, 0.3) !important;
border: 2px dashed rgba(0, 0, 0, 0.2) !important;
cursor: pointer;
transition: opacity 0.15s;
&:hover { opacity: 0.85; }
@ -277,7 +280,7 @@
}
.sb-chip-ghost {
opacity: 0.55;
border: 1px dashed rgba(255, 255, 255, 0.4) !important;
border: 1px dashed rgba(0, 0, 0, 0.25) !important;
cursor: pointer;
&:hover { opacity: 0.85; }
}
@ -401,15 +404,15 @@
.sb-absence-detail { font-size:0.55rem !important; opacity:0.75; font-style:italic; margin-top:1px; }
.sb-block.sb-block-sel { outline:2px solid #6366f1; outline-offset:1px; z-index:6 !important; }
.sb-block.sb-block-multi { outline:2px solid #f59e0b; outline-offset:1px; z-index:6 !important; }
.sb-multi-bar { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); z-index:200; background:#181c2e; color:#e2e4ef; border:1px solid rgba(99,102,241,0.3); border-radius:10px; padding:6px 14px; display:flex; align-items:center; gap:8px; box-shadow:0 8px 32px rgba(0,0,0,0.5); font-size:0.72rem; }
.sb-multi-bar { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); z-index:200; background:var(--sb-card); color:var(--sb-text); border:1px solid rgba(99,102,241,0.3); border-radius:10px; padding:6px 14px; display:flex; align-items:center; gap:8px; box-shadow:0 8px 32px rgba(0,0,0,0.1); font-size:0.72rem; }
.sb-multi-count { font-weight:700; color:#f59e0b; }
.sb-multi-btn { background:none; border:1px solid rgba(255,255,255,0.1); border-radius:5px; color:#e2e4ef; font-size:0.65rem; padding:3px 8px; cursor:pointer; }
.sb-multi-btn:hover { background:rgba(255,255,255,0.08); }
.sb-multi-btn { background:none; border:1px solid rgba(0,0,0,0.06); border-radius:5px; color:var(--sb-text); font-size:0.65rem; padding:3px 8px; cursor:pointer; }
.sb-multi-btn:hover { background:rgba(0,0,0,0.05); }
.sb-multi-btn.sb-ctx-warn { color:#ef4444; border-color:rgba(239,68,68,0.3); }
.sb-multi-clear { color:#7b80a0; }
.sb-multi-sep { color:rgba(255,255,255,0.15); }
.sb-multi-sep { color:rgba(0,0,0,0.08); }
.sb-multi-lbl { color:#7b80a0; font-size:0.62rem; }
.sb-multi-tech { background:none; border:2px solid; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; font-size:0.5rem; font-weight:800; color:#e2e4ef; cursor:pointer; }
.sb-multi-tech { background:none; border:2px solid; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; font-size:0.5rem; font-weight:800; color:var(--sb-text); cursor:pointer; }
.sb-multi-tech:hover { filter:brightness(1.3); }
.sb-slide-up-enter-active, .sb-slide-up-leave-active { transition:transform 0.2s, opacity 0.2s; }
.sb-slide-up-enter-from, .sb-slide-up-leave-to { transform:translateX(-50%) translateY(20px); opacity:0; }
@ -419,36 +422,36 @@
.sb-block-status-icon { display:inline-flex; align-items:center; margin-right:5px; flex-shrink:0; }
.sb-block-status-icon svg { width:13px; height:13px; }
.sb-block-assistants { display:flex; gap:2px; position:absolute; top:2px; right:2px; }
.sb-assist-badge { width:16px; height:16px; border-radius:50%; font-size:0.45rem; font-weight:800; color:#fff; display:flex; align-items:center; justify-content:center; border:1.5px solid rgba(0,0,0,0.3); }
.sb-assist-badge-lead { border-color:rgba(255,255,255,0.5); }
.sb-block-team { outline:1.5px solid rgba(255,255,255,0.25); outline-offset:1px; }
.sb-assist-badge { width:16px; height:16px; border-radius:50%; font-size:0.45rem; font-weight:800; color:var(--sb-text); display:flex; align-items:center; justify-content:center; border:1.5px solid rgba(0,0,0,0.3); }
.sb-assist-badge-lead { border-color:rgba(0,0,0,0.25); }
.sb-block-team { outline:1.5px solid rgba(0,0,0,0.12); outline-offset:1px; }
.sb-block-drop-hover { outline:2px solid #6366f1 !important; outline-offset:1px; filter:brightness(1.3); }
.sb-block-assist { opacity:0.7; border:1.5px dashed rgba(255,255,255,0.25); cursor:pointer; z-index:3; background-image:repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255,255,255,0.04) 4px, rgba(255,255,255,0.04) 8px) !important; }
.sb-block-assist { opacity:0.7; border:1.5px dashed rgba(0,0,0,0.12); cursor:pointer; z-index:3; background-image:repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0,0,0,0.03) 4px, rgba(0,0,0,0.03) 8px) !important; }
.sb-block-assist .sb-block-meta { font-size:0.52rem; }
.sb-block-assist:hover { opacity:0.9; }
.sb-block-assist-pinned { opacity:0.85; border-style:solid; background-image:none !important; }
.sb-move-handle { position:absolute; left:0; top:0; bottom:0; width:8px; cursor:grab; z-index:6; }
.sb-move-handle:hover { background:rgba(255,255,255,0.15); border-radius:6px 0 0 6px; }
.sb-move-handle::after { content:''; position:absolute; left:1px; top:50%; transform:translateY(-50%); font-size:0.5rem; color:rgba(255,255,255,0.4); }
.sb-move-handle:hover { background:rgba(0,0,0,0.08); border-radius:6px 0 0 6px; }
.sb-move-handle::after { content:''; position:absolute; left:1px; top:50%; transform:translateY(-50%); font-size:0.5rem; color:rgba(0,0,0,0.2); }
.sb-move-handle:active { cursor:grabbing; }
.sb-resize-handle { position:absolute; right:0; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:6; }
.sb-resize-handle:hover { background:rgba(255,255,255,0.15); border-radius:0 6px 6px 0; }
.sb-resize-handle::after { content:''; position:absolute; right:2px; top:50%; transform:translateY(-50%); width:2px; height:12px; border-radius:1px; background:rgba(255,255,255,0.3); }
.sb-resize-handle:hover { background:rgba(0,0,0,0.08); border-radius:0 6px 6px 0; }
.sb-resize-handle::after { content:''; position:absolute; right:2px; top:50%; transform:translateY(-50%); width:2px; height:12px; border-radius:1px; background:rgba(0,0,0,0.15); }
.sbf-team-badges { display:flex; gap:2px; margin-top:2px; }
.sb-type-icon { display:inline-flex; align-items:center; margin-right:3px; flex-shrink:0; vertical-align:middle; }
.sb-type-icon svg { width:11px; height:11px; }
.sb-block-color-bar { width:3px; height:100%; flex-shrink:0; background:rgba(0,0,0,0.25); }
.sb-block-inner { flex:1; min-width:0; padding:0 5px; }
.sb-block-title { font-size:0.68rem; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#fff; }
.sb-block-meta { font-size:0.58rem; color:rgba(255,255,255,0.7); white-space:nowrap; }
.sb-block-addr { font-size:0.52rem; color:rgba(255,255,255,0.5); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:2px; }
.sb-block-title { font-size:0.68rem; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--sb-text); }
.sb-block-meta { font-size:0.58rem; color:rgba(0,0,0,0.45); white-space:nowrap; }
.sb-block-addr { font-size:0.52rem; color:rgba(0,0,0,0.25); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:2px; }
.sb-block-addr svg { width:9px; height:9px; flex-shrink:0; }
.sb-block-pin { display:inline-flex; align-items:center; margin-right:2px; }
.sb-block-pin svg { width:10px; height:10px; }
.sb-travel-trail { position:absolute; border-radius:4px; z-index:2; display:flex; align-items:center; justify-content:center; pointer-events:none; transition:opacity 0.2s; }
.sb-travel-route { border-left-style:solid; }
.sb-travel-est { border-left-style:dashed; opacity:0.7; }
.sb-travel-lbl { font-size:0.55rem; color:rgba(255,255,255,0.65); white-space:nowrap; font-style:italic; }
.sb-travel-lbl { font-size:0.55rem; color:rgba(0,0,0,0.08); white-space:nowrap; font-style:italic; }
/* ── Bottom panel ── */
.sb-bottom-panel { flex-shrink:0; border-top:1px solid var(--sb-border); display:flex; flex-direction:column; background:var(--sb-sidebar); position:relative; overflow:hidden; }
@ -460,13 +463,13 @@
.sb-bottom-close:hover { color:var(--sb-red); }
.sb-bottom-body { flex:1; overflow-y:auto; overflow-x:auto; display:flex; flex-direction:column; }
.sb-bottom-body::-webkit-scrollbar { width:4px; height:4px; }
.sb-bottom-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-bottom-body::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); }
.sb-bottom-date-sep { display:flex; align-items:center; gap:0.4rem; padding:0.3rem 0.75rem; background:rgba(99,102,241,0.06); border-bottom:1px solid var(--sb-border); position:sticky; top:0; z-index:2; user-select:none; cursor:crosshair; }
.sb-bottom-date-label { font-size:0.62rem; font-weight:800; color:var(--sb-acc); text-transform:uppercase; letter-spacing:0.05em; }
.sb-bottom-date-count { font-size:0.55rem; color:var(--sb-muted); }
.sb-bottom-scroll { flex:1; overflow-y:auto; overflow-x:hidden; min-height:0; cursor:crosshair; }
.sb-bottom-scroll::-webkit-scrollbar { width:4px; }
.sb-bottom-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-bottom-scroll::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); }
.sb-bottom-table { width:100%; border-collapse:collapse; font-size:0.72rem; table-layout:fixed; }
.sb-bottom-table thead th { position:sticky; top:0; z-index:3; background:var(--sb-sidebar); text-align:left; font-size:0.55rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:var(--sb-muted); padding:0.3rem 0.5rem; border-bottom:1px solid var(--sb-border); white-space:nowrap; overflow:hidden; }
.sb-col-resize { position:absolute; right:0; top:0; bottom:0; width:5px; cursor:col-resize; z-index:4; }
@ -481,17 +484,17 @@
.sb-bt-chk { padding:0 !important; text-align:center !important; }
.sb-bt-checkbox { display:inline-block; width:14px; height:14px; border-radius:3px; border:1.5px solid var(--sb-muted); vertical-align:middle; position:relative; }
.sb-bt-checkbox.checked { background:var(--sb-acc); border-color:var(--sb-acc); }
.sb-bt-checkbox.checked::after { content:''; position:absolute; inset:0; color:#fff; font-size:0.55rem; font-weight:800; display:flex; align-items:center; justify-content:center; }
.sb-bt-checkbox.checked::after { content:''; position:absolute; inset:0; color:var(--sb-text); font-size:0.55rem; font-weight:800; display:flex; align-items:center; justify-content:center; }
.sb-bt-prio-dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.sb-bt-name-text { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
.sb-bt-addr { color:var(--sb-muted); font-size:0.65rem; overflow:hidden; text-overflow:ellipsis; }
.sb-bt-dur-wrap { display:flex; align-items:center; gap:6px; }
.sb-bt-dur-bar { flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; min-width:30px; }
.sb-bt-dur-bar { flex:1; height:4px; background:rgba(0,0,0,0.04); border-radius:2px; overflow:hidden; min-width:30px; }
.sb-bt-dur-fill { height:100%; border-radius:2px; transition:width 0.2s; }
.sb-bt-dur-lbl { font-size:0.62rem; color:var(--sb-muted); font-variant-numeric:tabular-nums; white-space:nowrap; min-width:28px; }
.sb-bt-prio-tag { font-size:0.6rem; font-weight:700; padding:1px 6px; border-radius:4px; }
.sb-bt-skill-chip { display:inline-flex; align-items:center; font-size:0.55rem; font-weight:600; color:var(--sb-text); background:rgba(255,255,255,0.08); border:1px solid var(--sb-border); padding:1px 6px; border-radius:10px; margin-right:3px; white-space:nowrap; }
.sb-bt-no-tag { font-size:0.6rem; color:rgba(255,255,255,0.2); }
.sb-bt-skill-chip { display:inline-flex; align-items:center; font-size:0.55rem; font-weight:600; color:var(--sb-text); background:rgba(0,0,0,0.05); border:1px solid var(--sb-border); padding:1px 6px; border-radius:10px; margin-right:3px; white-space:nowrap; }
.sb-bt-no-tag { font-size:0.6rem; color:rgba(0,0,0,0.1); }
.sb-bottom-sel-count { font-size:0.65rem; font-weight:700; color:var(--sb-acc); }
.sb-bottom-sel-lbl { font-size:0.72rem; color:var(--sb-muted); }
.sb-bottom-sel-clear { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.8rem; }
@ -521,9 +524,9 @@
.sb-month-day-out { opacity:0.28; pointer-events:none; }
.sb-month-day-today { border-color:var(--sb-acc); }
.sb-month-day-num { font-size:0.7rem; font-weight:700; color:var(--sb-text); width:20px; height:20px; display:flex; align-items:center; justify-content:center; border-radius:50%; flex-shrink:0; }
.sb-month-day-today .sb-month-day-num { background:var(--sb-acc); color:#fff; }
.sb-month-day-today .sb-month-day-num { background:var(--sb-acc); color:var(--sb-text); }
.sb-month-avatars { display:flex; flex-wrap:wrap; gap:2px; }
.sb-month-avatar { width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.48rem; font-weight:800; color:#fff; flex-shrink:0; cursor:pointer; transition:transform 0.1s; box-shadow:0 1px 3px rgba(0,0,0,0.4); }
.sb-month-avatar { width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.48rem; font-weight:800; color:var(--sb-text); flex-shrink:0; cursor:pointer; transition:transform 0.1s; box-shadow:0 1px 3px rgba(0,0,0,0.08); }
.sb-month-avatar:hover { transform:scale(1.3); z-index:2; position:relative; }
.sb-month-stats { display:flex; gap:6px; align-items:center; }
.sb-month-stat { font-size:0.52rem; font-weight:600; display:flex; align-items:center; gap:2px; }
@ -540,7 +543,7 @@
.sb-map-bar { display:flex; align-items:center; gap:0.4rem; padding:0.4rem 0.65rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sb-map-title { font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); }
.sb-map-tech { font-size:0.7rem; font-weight:700; }
.sb-map-route-hint { font-size:0.58rem; font-weight:400; color:rgba(255,255,255,0.35); margin-left:0.25rem; }
.sb-map-route-hint { font-size:0.58rem; font-weight:400; color:rgba(0,0,0,0.18); margin-left:0.25rem; }
.sb-map-hint { font-size:0.62rem; color:var(--sb-muted); font-style:italic; flex:1; }
.sb-map-bar-geofix { background:rgba(99,102,241,0.18); border-bottom-color:var(--sb-border-acc); }
.sb-geofix-hint { flex:1; font-size:0.68rem; color:var(--sb-text); animation:sb-geofix-pulse 1.4s ease-in-out infinite; }
@ -552,67 +555,67 @@
.sb-legend-item { display:flex; align-items:center; gap:0.2rem; font-size:0.62rem; color:var(--sb-muted); }
.sb-legend-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
.sb-map { flex:1; min-height:0; }
.sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.55); cursor:pointer; transition:transform 0.15s; }
.sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.25); cursor:pointer; transition:transform 0.15s; }
.sb-map-tech-pin:hover { transform:scale(1.2); }
.sb-map-tech-pin { position:relative; }
.sb-map-crew-badge { position:absolute; top:-4px; right:-6px; min-width:16px; height:16px; border-radius:8px; background:#6366f1; color:#fff; font-size:9px; font-weight:800; display:flex; align-items:center; justify-content:center; border:2px solid #0f172a; line-height:1; padding:0 3px; }
.sb-map-crew-badge { position:absolute; top:-4px; right:-6px; min-width:16px; height:16px; border-radius:8px; background:#6366f1; color:#fff; font-size:9px; font-weight:800; display:flex; align-items:center; justify-content:center; border:2px solid #fff; line-height:1; padding:0 3px; }
.sb-map-gps-active { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); animation:gps-glow 2s infinite; }
@keyframes gps-glow { 0%,100% { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); } 50% { box-shadow:0 0 0 6px rgba(16,185,129,0.3), 0 0 20px rgba(16,185,129,0.3), 0 2px 10px rgba(0,0,0,0.55); } }
.sb-map-drag-ghost { padding:4px 8px; border-radius:6px; background:rgba(99,102,241,0.9); color:#fff; font-size:0.68rem; font-weight:700; box-shadow:0 4px 16px rgba(0,0,0,0.55); max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none; }
.sb-map-drag-ghost { padding:4px 8px; border-radius:6px; background:rgba(99,102,241,0.9); color:#fff; font-size:0.68rem; font-weight:700; box-shadow:0 4px 16px rgba(0,0,0,0.2); max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none; }
/* ── Right panel ── */
.sb-right { width:280px; min-width:280px; flex-shrink:0; display:flex; flex-direction:column; background:#111422; color:#e2e4ef; border-left:1px solid rgba(255,255,255,0.06); overflow:hidden; }
.sb-rp-hdr { display:flex; align-items:center; padding:0.55rem 0.75rem; border-bottom:1px solid rgba(255,255,255,0.06); flex-shrink:0; }
.sb-right { width:280px; min-width:280px; flex-shrink:0; display:flex; flex-direction:column; background:var(--sb-sidebar); color:var(--sb-text); border-left:1px solid rgba(0,0,0,0.04); overflow:hidden; }
.sb-rp-hdr { display:flex; align-items:center; padding:0.55rem 0.75rem; border-bottom:1px solid rgba(0,0,0,0.04); flex-shrink:0; }
.sb-rp-title { font-size:0.68rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; flex:1; }
.sb-rp-close { background:none; border:none; color:#7b80a0; cursor:pointer; font-size:0.95rem; transition:color 0.12s; }
.sb-rp-close:hover { color:#ef4444; }
.sb-rp-body { flex:1; overflow-y:auto; padding:0.65rem 0.75rem; color:#e2e4ef; }
.sb-rp-body { flex:1; overflow-y:auto; padding:0.65rem 0.75rem; color:var(--sb-text); }
.sb-rp-body::-webkit-scrollbar { width:3px; }
.sb-rp-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-rp-body::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); }
.sb-rp-color-bar { height:3px; border-radius:2px; margin-bottom:0.75rem; }
.sb-rp-urgent-tag { background:rgba(239,68,68,0.15); color:#ef4444; font-size:0.7rem; font-weight:700; padding:0.25rem 0.5rem; border-radius:6px; display:inline-block; margin-bottom:0.5rem; }
.sb-rp-field { margin-bottom:0.45rem; color:#e2e4ef; }
.sb-rp-field { margin-bottom:0.45rem; color:var(--sb-text); }
.sb-rp-lbl { display:block; font-size:0.58rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.1rem; }
.sb-rp-actions { padding:0.65rem 0.75rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; flex-direction:column; gap:0.35rem; flex-shrink:0; }
.sb-rp-primary { background:#6366f1; border:none; border-radius:7px; color:#fff; font-size:0.72rem; font-weight:700; padding:0.4rem 0.75rem; cursor:pointer; }
.sb-rp-actions { padding:0.65rem 0.75rem; border-top:1px solid rgba(0,0,0,0.04); display:flex; flex-direction:column; gap:0.35rem; flex-shrink:0; }
.sb-rp-primary { background:#6366f1; border:none; border-radius:7px; color:var(--sb-text); font-size:0.72rem; font-weight:700; padding:0.4rem 0.75rem; cursor:pointer; }
.sb-rp-primary:hover { filter:brightness(1.12); }
.sb-rp-btn { background:none; border:1px solid rgba(255,255,255,0.06); border-radius:7px; color:#7b80a0; font-size:0.7rem; padding:0.35rem 0.75rem; cursor:pointer; transition:border-color 0.12s, color 0.12s; }
.sb-rp-btn:hover { border-color:rgba(99,102,241,0.4); color:#e2e4ef; }
.sb-rp-btn { background:none; border:1px solid rgba(0,0,0,0.04); border-radius:7px; color:#7b80a0; font-size:0.7rem; padding:0.35rem 0.75rem; cursor:pointer; transition:border-color 0.12s, color 0.12s; }
.sb-rp-btn:hover { border-color:rgba(99,102,241,0.4); color:var(--sb-text); }
.sb-assign-grid { display:flex; flex-direction:column; gap:0.3rem; }
.sb-assign-btn { display:flex; align-items:center; gap:0.4rem; background:none; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.7rem; padding:0.3rem 0.55rem; cursor:pointer; transition:background 0.12s; text-align:left; }
.sb-assign-btn:hover { background:#181c2e; }
.sb-assign-btn { display:flex; align-items:center; gap:0.4rem; background:none; border:1px solid rgba(0,0,0,0.04); border-radius:6px; color:var(--sb-text); font-size:0.7rem; padding:0.3rem 0.55rem; cursor:pointer; transition:background 0.12s; text-align:left; }
.sb-assign-btn:hover { background:var(--sb-card); }
.sb-assign-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
/* ── Context menu ── */
.sb-ctx { position:fixed; z-index:200; background:#181c2e; color:#e2e4ef; border:1px solid rgba(99,102,241,0.4); border-radius:9px; padding:0.3rem; box-shadow:0 8px 28px rgba(0,0,0,0.55); min-width:180px; }
.sb-ctx-item { display:block; width:100%; background:none; border:none; border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.4rem 0.7rem; cursor:pointer; text-align:left; transition:background 0.1s; }
.sb-ctx-item:hover { background:#1e2338; }
.sb-ctx-sep { height:1px; background:rgba(255,255,255,0.06); margin:0.2rem 0; }
.sb-ctx { position:fixed; z-index:200; background:var(--sb-card); color:var(--sb-text); border:1px solid rgba(99,102,241,0.25); border-radius:9px; padding:0.3rem; box-shadow:0 8px 28px rgba(0,0,0,0.15); min-width:180px; }
.sb-ctx-item { display:block; width:100%; background:none; border:none; border-radius:6px; color:var(--sb-text); font-size:0.75rem; padding:0.4rem 0.7rem; cursor:pointer; text-align:left; transition:background 0.1s; }
.sb-ctx-item:hover { background:var(--sb-card-h); }
.sb-ctx-sep { height:1px; background:rgba(0,0,0,0.04); margin:0.2rem 0; }
.sb-ctx-warn { color:#ef4444 !important; }
/* ── Modals ── */
.sb-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); z-index:100; display:flex; align-items:center; justify-content:center; }
.sb-overlay-top { z-index:110; }
.sb-modal { background:#111422; color:#e2e4ef; border:1px solid rgba(255,255,255,0.06); border-radius:14px; padding:0; min-width:360px; max-width:500px; width:100%; box-shadow:0 24px 60px rgba(0,0,0,0.6); overflow:hidden; max-height:85vh; display:flex; flex-direction:column; }
.sb-modal { background:var(--sb-sidebar); color:var(--sb-text); border:1px solid rgba(0,0,0,0.04); border-radius:14px; padding:0; min-width:360px; max-width:500px; width:100%; box-shadow:0 24px 60px rgba(0,0,0,0.12); overflow:hidden; max-height:85vh; display:flex; flex-direction:column; }
.sb-modal-wide { min-width:580px; max-width:680px; }
.sb-modal-tags { min-width:420px; max-width:560px; overflow:visible; }
.sb-modal-hdr { display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1rem; border-bottom:1px solid rgba(255,255,255,0.06); font-weight:700; font-size:0.85rem; color:#e2e4ef; }
.sb-modal-body { padding:0.75rem 1rem; color:#e2e4ef; overflow-y:auto; flex:1; min-height:0; }
.sb-modal-hdr { display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1rem; border-bottom:1px solid rgba(0,0,0,0.04); font-weight:700; font-size:0.85rem; color:var(--sb-text); }
.sb-modal-body { padding:0.75rem 1rem; color:var(--sb-text); overflow-y:auto; flex:1; min-height:0; }
.sb-modal-body::-webkit-scrollbar { width:4px; }
.sb-modal-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:2px; }
.sb-modal-ftr { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; }
.sb-modal-footer { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; }
.sb-modal-body::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); border-radius:2px; }
.sb-modal-ftr { padding:0.65rem 1rem; border-top:1px solid rgba(0,0,0,0.04); display:flex; gap:0.5rem; }
.sb-modal-footer { padding:0.65rem 1rem; border-top:1px solid rgba(0,0,0,0.04); display:flex; gap:0.5rem; }
.sb-form-row { margin-bottom:0.6rem; }
.sb-form-lbl { display:block; font-size:0.62rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.2rem; }
.sb-form-val { font-size:0.78rem; font-weight:600; color:#e2e4ef; }
.sb-form-sel { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.3rem 0.5rem; cursor:pointer; }
.sb-form-input { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.3rem 0.5rem; box-sizing:border-box; }
.sb-form-val { font-size:0.78rem; font-weight:600; color:var(--sb-text); }
.sb-form-sel { width:100%; background:var(--sb-card); border:1px solid rgba(0,0,0,0.04); border-radius:6px; color:var(--sb-text); font-size:0.75rem; padding:0.3rem 0.5rem; cursor:pointer; }
.sb-form-input { width:100%; background:var(--sb-card); border:1px solid rgba(0,0,0,0.04); border-radius:6px; color:var(--sb-text); font-size:0.75rem; padding:0.3rem 0.5rem; box-sizing:border-box; }
.sb-form-sel:focus, .sb-form-input:focus { border-color:rgba(99,102,241,0.4); outline:none; }
.sb-addr-wrap { position:relative; }
.sb-addr-dropdown { position:absolute; top:100%; left:0; right:0; z-index:50; background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px; max-height:200px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.4); }
.sb-addr-item { padding:6px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef; border-bottom:1px solid rgba(255,255,255,0.04); }
.sb-addr-dropdown { position:absolute; top:100%; left:0; right:0; z-index:50; background:var(--sb-card); border:1px solid rgba(99,102,241,0.3); border-radius:6px; max-height:200px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.08); }
.sb-addr-item { padding:6px 10px; cursor:pointer; font-size:0.72rem; color:var(--sb-text); border-bottom:1px solid rgba(0,0,0,0.03); }
.sb-addr-item:hover { background:rgba(99,102,241,0.12); }
.sb-addr-item strong { color:#fff; }
.sb-addr-item strong { color:var(--sb-text); }
.sb-addr-city { float:right; font-size:0.6rem; color:#7b80a0; }
.sb-addr-confirmed { font-size:0.6rem; color:#10b981; margin-top:3px; }
.sb-addr-cp { font-size:0.6rem; color:#6366f1; margin-left:4px; }
@ -620,18 +623,18 @@
.sb-wo-body { display:flex; gap:1rem; }
.sb-wo-form { flex:1; min-width:0; }
.sb-wo-minimap { width:280px; flex-shrink:0; display:flex; align-items:flex-start; }
.sb-minimap-img { width:100%; border-radius:8px; border:1px solid rgba(255,255,255,0.06); }
.sb-minimap-img { width:100%; border-radius:8px; border:1px solid rgba(0,0,0,0.04); }
.sb-rsel-groups { margin-bottom:0.6rem; }
.sb-rsel-section-title { font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; margin-bottom:0.3rem; }
.sb-rsel-chips { display:flex; flex-wrap:wrap; gap:0.3rem; }
.sb-rsel-chip { background:#181c2e; border:1px solid rgba(255,255,255,0.1); border-radius:6px; color:#c4c8e4; font-size:0.7rem; font-weight:600; padding:0.25rem 0.6rem; cursor:pointer; transition:all 0.12s; }
.sb-rsel-chip:hover { border-color:rgba(99,102,241,0.4); color:#e2e4ef; }
.sb-rsel-chip.active { background:rgba(99,102,241,0.2); border-color:#6366f1; color:#a5b4fc; }
.sb-rsel-chip { background:var(--sb-card); border:1px solid rgba(0,0,0,0.06); border-radius:6px; color:var(--sb-muted); font-size:0.7rem; font-weight:600; padding:0.25rem 0.6rem; cursor:pointer; transition:all 0.12s; }
.sb-rsel-chip:hover { border-color:rgba(99,102,241,0.4); color:var(--sb-text); }
.sb-rsel-chip.active { background:rgba(99,102,241,0.12); border-color:#6366f1; color:#4f46e5; }
.sb-rsel-group-actions { margin-top:0.4rem; }
.sb-rsel-apply-group { background:#6366f1; border:none; border-radius:6px; color:#fff; font-size:0.68rem; font-weight:600; padding:0.3rem 0.8rem; cursor:pointer; transition:background 0.12s; }
.sb-rsel-apply-group { background:#6366f1; border:none; border-radius:6px; color:var(--sb-text); font-size:0.68rem; font-weight:600; padding:0.3rem 0.8rem; cursor:pointer; transition:background 0.12s; }
.sb-rsel-apply-group:hover { background:#4f46e5; }
.sb-rsel-search-row { margin-bottom:0.5rem; }
.sb-rsel-search { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.08); border-radius:6px; color:#e2e4ef; font-size:0.72rem; padding:0.35rem 0.6rem; outline:none; }
.sb-rsel-search { width:100%; background:var(--sb-card); border:1px solid rgba(0,0,0,0.05); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.35rem 0.6rem; outline:none; }
.sb-rsel-search:focus { border-color:rgba(99,102,241,0.4); }
.sb-rsel-search::placeholder { color:#7b80a0; }
.sb-rsel-preset { position:relative; padding-right:1.4rem; }
@ -639,32 +642,32 @@
.sb-rsel-preset-del { position:absolute; right:4px; top:50%; transform:translateY(-50%); font-size:0.6rem; opacity:0; transition:opacity 0.1s; color:#f87171; }
.sb-rsel-preset:hover .sb-rsel-preset-del { opacity:1; }
.sb-rsel-save-row { display:flex; align-items:center; gap:4px; }
.sb-rsel-save-input { background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:4px; color:#e2e4ef; font-size:0.7rem; padding:0.22rem 0.5rem; width:140px; outline:none; }
.sb-rsel-save-input { background:var(--sb-card); border:1px solid rgba(99,102,241,0.3); border-radius:4px; color:var(--sb-text); font-size:0.7rem; padding:0.22rem 0.5rem; width:140px; outline:none; }
.sb-rsel-save-input:focus { border-color:#6366f1; }
.sb-rsel-save-input::placeholder { color:#7b80a0; }
.sb-rsel-save-btn { background:#6366f1; border:none; border-radius:4px; color:#fff; font-size:0.7rem; font-weight:700; padding:0.22rem 0.5rem; cursor:pointer; }
.sb-rsel-save-btn { background:#6366f1; border:none; border-radius:4px; color:var(--sb-text); font-size:0.7rem; font-weight:700; padding:0.22rem 0.5rem; cursor:pointer; }
.sb-rsel-save-btn:disabled { opacity:0.4; cursor:default; }
.sb-res-sel-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sb-res-sel-grp-tag { font-size:0.55rem; color:#7b80a0; background:rgba(255,255,255,0.06); padding:1px 5px; border-radius:3px; flex-shrink:0; }
.sb-res-sel-grp-tag { font-size:0.55rem; color:#7b80a0; background:rgba(0,0,0,0.04); padding:1px 5px; border-radius:3px; flex-shrink:0; }
.sb-res-sel-wrap { display:flex; gap:1rem; align-items:flex-start; }
.sb-res-sel-col { flex:1; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:8px; overflow:hidden; max-height:300px; overflow-y:auto; }
.sb-res-sel-col { flex:1; background:var(--sb-card); border:1px solid rgba(0,0,0,0.04); border-radius:8px; overflow:hidden; max-height:300px; overflow-y:auto; }
.sb-res-sel-col::-webkit-scrollbar { width:3px; }
.sb-res-sel-col::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-res-sel-hdr { padding:0.4rem 0.6rem; background:#111422; font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; border-bottom:1px solid rgba(255,255,255,0.06); }
.sb-res-sel-item { display:flex; align-items:center; gap:0.45rem; padding:0.4rem 0.6rem; cursor:pointer; font-size:0.72rem; color:#e2e4ef; transition:background 0.1s; border-bottom:1px solid rgba(255,255,255,0.06); }
.sb-res-sel-item:hover { background:#1e2338; }
.sb-res-sel-col::-webkit-scrollbar-thumb { background:rgba(0,0,0,0.06); }
.sb-res-sel-hdr { padding:0.4rem 0.6rem; background:var(--sb-sidebar); font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; border-bottom:1px solid rgba(0,0,0,0.04); }
.sb-res-sel-item { display:flex; align-items:center; gap:0.45rem; padding:0.4rem 0.6rem; cursor:pointer; font-size:0.72rem; color:var(--sb-text); transition:background 0.1s; border-bottom:1px solid rgba(0,0,0,0.04); }
.sb-res-sel-item:hover { background:var(--sb-card-h); }
.sb-res-sel-active { color:#6366f1; }
.sb-res-sel-rm { margin-left:auto; color:#7b80a0; font-size:0.8rem; }
.sb-res-sel-group-hdr { padding:0.25rem 0.6rem; font-size:0.55rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:#6366f1; background:rgba(99,102,241,0.08); border-bottom:1px solid rgba(255,255,255,0.06); position:sticky; top:0; z-index:1; }
.sb-res-sel-group-hdr { padding:0.25rem 0.6rem; font-size:0.55rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:#6366f1; background:rgba(99,102,241,0.08); border-bottom:1px solid rgba(0,0,0,0.04); position:sticky; top:0; z-index:1; }
.sb-res-sel-arrow { font-size:1.2rem; color:#7b80a0; align-self:center; flex-shrink:0; }
.sb-avatar-xs { width:22px; height:22px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:0.55rem; font-weight:700; color:#fff; }
.sb-avatar-material { background:#1e293b; border:1.5px solid #475569; border-radius:6px; font-size:0.7rem; }
.sb-avatar-material { background:#e2e8f0; border:1.5px solid #cbd5e1; border-radius:6px; font-size:0.7rem; }
.sb-res-sel-cat-tag { font-size:0.55rem; color:#f59e0b; background:rgba(245,158,11,0.12); padding:1px 5px; border-radius:3px; flex-shrink:0; }
/* ── Resource type toggle ── */
.sb-res-type-toggle { display:flex; gap:1px; background:var(--sb-border); border-radius:5px; overflow:hidden; flex-shrink:0; }
.sb-res-type-toggle button { background:var(--sb-card); color:var(--sb-muted); border:none; font-size:0.62rem; font-weight:600; font-family:inherit; padding:2px 8px; cursor:pointer; display:flex; align-items:center; gap:3px; transition:all 0.12s; white-space:nowrap; }
.sb-res-type-toggle button.active { background:rgba(99,102,241,0.2); color:#a5b4fc; }
.sb-res-type-toggle button.active { background:rgba(99,102,241,0.12); color:#4f46e5; }
.sb-res-type-toggle button:hover { background:rgba(99,102,241,0.12); }
/* ── Transitions ── */
@ -683,7 +686,7 @@
// Overload alert badge
.sb-overload-alert {
font-size: 0.68rem; font-weight: 600;
color: #fbbf24; background: rgba(251, 191, 36, 0.12);
color: #b45309; background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 5px; padding: 2px 8px;
cursor: default; white-space: nowrap;
@ -694,7 +697,7 @@
50% { opacity: 0.7; }
}
/* GPS Settings Modal */
.sb-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; display:flex; align-items:center; justify-content:center; }
.sb-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.12); z-index:200; display:flex; align-items:center; justify-content:center; }
.sb-gps-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:12px; width:700px; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; }
.sb-gps-modal-hdr { padding:14px 20px; border-bottom:1px solid var(--sb-border); display:flex; align-items:center; justify-content:space-between; }
.sb-gps-modal-hdr h3 { font-size:15px; margin:0; }
@ -712,10 +715,10 @@
.sb-gps-coords { font-size:11px; color:var(--sb-muted); font-family:monospace; }
.sb-gps-footer { display:flex; align-items:center; justify-content:space-between; margin-top:12px; padding-top:12px; border-top:1px solid var(--sb-border); }
.sb-gps-info { font-size:11px; color:var(--sb-muted); }
.sb-gps-refresh { padding:5px 12px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; }
.sb-gps-refresh { padding:5px 12px; background:var(--sb-acc); border:none; border-radius:6px; color:var(--sb-text); font-size:12px; cursor:pointer; }
.sb-gps-input { width:100%; padding:4px 8px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-fg); font-size:12px; }
.sb-gps-add-row td { padding-top:8px; border-top:1px solid var(--sb-border); }
.sb-gps-add-btn { padding:4px 14px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; white-space:nowrap; }
.sb-gps-add-btn { padding:4px 14px; background:var(--sb-acc); border:none; border-radius:6px; color:var(--sb-text); font-size:12px; cursor:pointer; white-space:nowrap; }
.sb-gps-add-btn:disabled { opacity:.5; cursor:not-allowed; }
.sb-gps-actions { white-space:nowrap; }
.sb-gps-absence-btn { background:none; border:none; color:var(--sb-muted); font-size:14px; cursor:pointer; padding:2px 6px; border-radius:4px; }
@ -737,7 +740,7 @@
.sb-absence-modal { min-width:480px; max-width:560px; }
.sb-absence-form { display:flex; flex-direction:column; gap:14px; }
.sb-absence-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--sb-muted); margin-bottom:4px; display:block; }
.sb-absence-opt { font-weight:400; text-transform:none; color:rgba(255,255,255,0.25); }
.sb-absence-opt { font-weight:400; text-transform:none; color:rgba(0,0,0,0.12); }
.sb-absence-reasons { display:flex; flex-wrap:wrap; gap:6px; }
.sb-absence-reason-btn {
padding:5px 12px; border-radius:6px; font-size:12px; cursor:pointer;
@ -745,7 +748,7 @@
transition:all 0.15s;
}
.sb-absence-reason-btn:hover { border-color:var(--sb-acc); }
.sb-absence-reason-btn.active { background:rgba(99,102,241,0.15); border-color:var(--sb-acc); color:#a5b4fc; }
.sb-absence-reason-btn.active { background:rgba(99,102,241,0.12); border-color:var(--sb-acc); color:#4f46e5; }
.sb-absence-dates { display:flex; gap:12px; }
.sb-absence-dates > div { flex:1; }
.sb-absence-jobs { background:rgba(239,68,68,0.06); border:1px solid rgba(239,68,68,0.15); border-radius:8px; padding:10px; }
@ -753,7 +756,7 @@
.sb-absence-radio { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--sb-fg); cursor:pointer; }
.sb-absence-radio input { accent-color:var(--sb-acc); }
.sb-absence-job-list { max-height:120px; overflow-y:auto; margin-top:6px; }
.sb-absence-job-item { display:flex; align-items:center; gap:6px; padding:3px 0; font-size:11px; color:var(--sb-fg); border-bottom:1px solid rgba(255,255,255,0.04); }
.sb-absence-job-item { display:flex; align-items:center; gap:6px; padding:3px 0; font-size:11px; color:var(--sb-fg); border-bottom:1px solid rgba(0,0,0,0.03); }
.sb-absence-job-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
.sb-absence-job-date { color:var(--sb-muted); margin-left:auto; font-size:10px; }
.sb-absence-no-jobs { font-size:12px; color:var(--sb-muted); font-style:italic; }
@ -762,14 +765,14 @@
.sb-gps-phone { width:110px; font-variant-numeric:tabular-nums; }
/* ── Login Overlay ── */
.sb-login-overlay { position:fixed; inset:0; background:var(--sb-bg); z-index:9999; display:flex; align-items:center; justify-content:center; }
.sb-login-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:14px; padding:40px 36px; width:340px; display:flex; flex-direction:column; align-items:center; gap:16px; box-shadow:0 8px 40px rgba(0,0,0,0.6); }
.sb-login-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:14px; padding:40px 36px; width:340px; display:flex; flex-direction:column; align-items:center; gap:16px; box-shadow:0 8px 40px rgba(0,0,0,0.12); }
.sb-login-logo { font-size:1.6rem; font-weight:800; color:var(--sb-acc); letter-spacing:-0.5px; }
.sb-login-sub { font-size:11px; color:var(--sb-muted); margin:0; text-align:center; }
.sb-login-sub a { color:var(--sb-acc); text-decoration:none; }
.sb-login-form { width:100%; display:flex; flex-direction:column; gap:10px; }
.sb-login-input { width:100%; padding:9px 12px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:7px; color:var(--sb-text); font-size:13px; outline:none; box-sizing:border-box; }
.sb-login-input:focus { border-color:var(--sb-acc); }
.sb-login-btn { width:100%; padding:10px; background:var(--sb-acc); border:none; border-radius:7px; color:#fff; font-size:13px; font-weight:600; cursor:pointer; transition:opacity 0.15s; }
.sb-login-btn { width:100%; padding:10px; background:var(--sb-acc); border:none; border-radius:7px; color:var(--sb-text); font-size:13px; font-weight:600; cursor:pointer; transition:opacity 0.15s; }
.sb-login-btn:disabled { opacity:0.6; cursor:not-allowed; }
.sb-login-error { color:var(--sb-red); font-size:12px; margin:0; text-align:center; }
@ -779,8 +782,8 @@
.sb-preset-btn { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:0.68rem; padding:4px 10px; cursor:pointer; transition:border-color 0.15s, background 0.15s; }
.sb-preset-btn:hover { border-color:var(--sb-acc); background:rgba(99,102,241,0.12); }
.sb-schedule-grid { padding:0.5rem 1rem; display:flex; flex-direction:column; gap:6px; }
.sb-schedule-day { display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:6px; background:rgba(255,255,255,0.03); transition:background 0.15s; }
.sb-schedule-day:hover { background:rgba(255,255,255,0.06); }
.sb-schedule-day { display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:6px; background:rgba(0,0,0,0.02); transition:background 0.15s; }
.sb-schedule-day:hover { background:rgba(0,0,0,0.04); }
.sb-schedule-off { opacity:0.5; }
.sb-schedule-toggle { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:60px; }
.sb-schedule-toggle input[type="checkbox"] { accent-color:var(--sb-acc); width:14px; height:14px; cursor:pointer; }
@ -832,7 +835,7 @@
background: var(--sb-card); border-radius: 14px; padding: 24px;
max-width: 380px; width: 90%; text-align: center;
border: 1px solid var(--sb-border-acc);
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
}
.sb-confirm-icon { font-size: 2rem; margin-bottom: 8px; }
.sb-confirm-title { font-size: 1rem; font-weight: 700; color: var(--sb-text); margin-bottom: 10px; }
@ -841,8 +844,8 @@
display: inline-block; font-size: 0.68rem; font-weight: 600; padding: 1px 8px;
border-radius: 4px; margin: 4px 2px;
}
.sb-confirm-tag-pub { background: rgba(99,102,241,0.2); color: #818cf8; }
.sb-confirm-tag-ip { background: rgba(251,191,36,0.2); color: #fbbf24; }
.sb-confirm-tag-pub { background: rgba(99,102,241,0.12); color: #4f46e5; }
.sb-confirm-tag-ip { background: rgba(251,191,36,0.15); color: #b45309; }
.sb-confirm-tag-asg { background: rgba(59,130,246,0.2); color: #60a5fa; }
.sb-confirm-warn { font-size: 0.75rem; color: #f59e0b; font-style: italic; }
.sb-confirm-actions { display: flex; gap: 8px; justify-content: center; }

View File

@ -0,0 +1,854 @@
/* ── Root ── */
.sb-root {
/* Light theme — aligned with ops-app design language */
--sb-bg: #f8fafc; --sb-sidebar: #ffffff; --sb-card: #ffffff; --sb-card-h: #f1f5f9;
--sb-border: #e2e8f0; --sb-border-acc: rgba(99,102,241,0.4);
--sb-text: #1e293b; --sb-muted: #64748b;
--sb-acc: #6366f1; --sb-green: #10b981; --sb-red: #ef4444; --sb-orange: #f59e0b;
--sb-overlay: rgba(0,0,0,0.04); --sb-overlay-hover: rgba(0,0,0,0.08);
display: flex; flex-direction: column;
height: 100vh; width: 100%; overflow: hidden;
background: var(--sb-bg); color: var(--sb-text);
font-family: 'Inter', system-ui, sans-serif; font-size: 0.82rem;
}
/* ── Header ── */
.sb-header { display:flex; align-items:center; gap:0.5rem; padding:0 0.75rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 1px 4px rgba(0,0,0,0.06); z-index:30; overflow:hidden; }
.sb-header-left { display:flex; align-items:center; gap:0.4rem; flex-shrink:0; }
.sb-header-center { display:flex; align-items:center; gap:0.35rem; flex:1; justify-content:center; }
.sb-header-right { display:flex; align-items:center; gap:0.35rem; flex-shrink:0; margin-left:auto; }
.sb-logo-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.72rem; font-weight:700; padding:0.2rem 0.55rem; cursor:pointer; white-space:nowrap; text-decoration:none; }
.sb-logo-btn:hover { color:var(--sb-text); border-color:var(--sb-border-acc); }
.sb-tabs { display:flex; align-items:center; border-left:1px solid var(--sb-border); margin-left:0.25rem; padding-left:0.4rem; gap:0.15rem; }
.sb-tab { background:none; border:none; color:var(--sb-muted); font-size:0.72rem; font-weight:600; padding:0.3rem 0.6rem; cursor:pointer; border-bottom:2px solid transparent; transition:color 0.15s, border-color 0.15s; white-space:nowrap; }
.sb-tab.active { color:var(--sb-acc); border-bottom-color:var(--sb-acc); }
.sb-tab-add { opacity:0.45; font-size:1rem; padding:0.15rem 0.4rem; }
.sb-tab-add:hover { opacity:1; }
.sb-hbtn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.85rem; padding:0.2rem 0.55rem; cursor:pointer; }
.sb-hbtn:hover { background:var(--sb-card); }
.sb-today-btn { font-size:0.7rem; font-weight:700; }
.sb-period-label { font-size:0.78rem; font-weight:600; min-width:180px; text-align:center; white-space:nowrap; }
.sb-view-sw { display:flex; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; overflow:hidden; }
.sb-view-sw button { background:none; border:none; color:var(--sb-muted); font-size:0.68rem; font-weight:700; padding:0.22rem 0.6rem; cursor:pointer; transition:color 0.12s, background 0.12s; }
.sb-view-sw button.active { background:none; color:var(--sb-acc); box-shadow:inset 0 0 0 1.5px var(--sb-acc); }
.sb-planning-toggle {
margin-left: 6px; border-radius: 6px; font-size: 0.72rem;
border: 1px solid rgba(100,180,255,0.15); transition: all 0.15s;
&.active { background: rgba(100,180,255,0.15); border-color: rgba(100,180,255,0.4); color: #93c5fd; }
}
.sb-search { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.22rem 0.55rem; width:160px; outline:none; }
.sb-search::placeholder { color:var(--sb-muted); }
.sb-search:focus { border-color:var(--sb-border-acc); }
.sb-search-bar { display:flex; align-items:center; gap:4px; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; padding:0.2rem 0.5rem; min-width:160px; max-width:320px; cursor:pointer; transition:border-color 0.12s; flex-wrap:wrap; }
.sb-search-bar:hover { border-color:var(--sb-border-acc); }
.sb-search-icon { font-size:0.7rem; flex-shrink:0; }
.sb-search-placeholder { color:var(--sb-muted); font-size:0.72rem; }
.sb-search-chip { display:inline-flex; align-items:center; gap:3px; background:rgba(99,102,241,0.2); color:#a5b4fc; border:1px solid rgba(99,102,241,0.3); border-radius:4px; font-size:0.65rem; font-weight:600; padding:1px 6px; white-space:nowrap; cursor:pointer; transition:background 0.1s; }
.sb-search-chip:hover { background:rgba(99,102,241,0.35); }
.sb-search-chip-count { background:rgba(99,102,241,0.3); }
// ── Quick preset bar (saved groups in header) ──
.sb-quick-presets {
display: flex; gap: 3px; align-items: center; flex-wrap: wrap;
}
.sb-quick-preset {
background: var(--sb-card); border: 1px solid var(--sb-border); border-radius: 5px;
color: var(--sb-muted); font-size: 0.62rem; font-weight: 600;
padding: 2px 7px; cursor: pointer; white-space: nowrap;
transition: all 0.12s; display: inline-flex; align-items: center; gap: 2px;
&:hover { border-color: var(--sb-border-acc); color: var(--sb-text); }
&.active { border-color: var(--sb-acc); color: var(--sb-acc); background: rgba(99,102,241,0.1); }
.sb-qp-icon { font-size: 0.7rem; }
}
// ── Group save button in selector ──
.sb-rsel-group-wrap {
display: inline-flex; align-items: center; gap: 0;
}
.sb-rsel-save-group {
background: none; border: none; cursor: pointer; font-size: 0.65rem;
padding: 2px 3px; opacity: 0.35; transition: opacity 0.12s;
&:hover { opacity: 1; }
}
.sb-rsel-group-count {
font-size: 0.6rem; opacity: 0.6; margin-left: 3px;
}
.sb-rsel-preset-group { border-color: rgba(74, 222, 128, 0.3); }
.sb-rsel-preset-group.active { border-color: #4ade80; background: rgba(74, 222, 128, 0.12); }
.sb-rsel-preset-icon { font-size: 0.7rem; margin-right: 2px; }
.sb-icon-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.68rem; font-weight:600; padding:0.22rem 0.55rem; cursor:pointer; white-space:nowrap; transition:color 0.12s, border-color 0.12s; }
.sb-icon-btn:hover, .sb-icon-btn.active { color:var(--sb-text); border-color:var(--sb-border-acc); background:var(--sb-card); }
.sb-user-menu { display:flex; align-items:center; gap:6px; }
.sb-user-name { font-size:11px; color:var(--sb-muted); max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sb-erp-link { font-size:10px; padding:2px 6px; background:rgba(99,102,241,.15); color:var(--sb-accent); border-radius:4px; text-decoration:none; font-weight:600; }
.sb-erp-link:hover { background:rgba(99,102,241,.3); }
.sb-logout-btn { font-size:14px !important; opacity:.6; }
.sb-logout-btn:hover { opacity:1; color:var(--sb-red); }
.sb-erp-dot { width:7px; height:7px; border-radius:50%; background:var(--sb-red); transition:background 0.3s; }
.sb-erp-dot.ok { background:var(--sb-green); }
.sb-wo-btn { background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:0.7rem; font-weight:800; padding:0.22rem 0.65rem; cursor:pointer; white-space:nowrap; }
.sb-wo-btn:hover { filter:brightness(1.15); }
/* ── Body ── */
.sb-body { flex:1; display:flex; overflow:hidden; min-height:0; position:relative; }
/* ── Toolbar dropdown panels ── */
.sb-toolbar-panel { background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 4px 16px rgba(0,0,0,0.4); z-index:18; flex-shrink:0; }
.sb-toolbar-panel-inner { padding:0; }
.sb-slide-down-enter-active, .sb-slide-down-leave-active { transition: max-height 0.2s ease, opacity 0.2s ease; overflow:hidden; }
.sb-slide-down-enter-from, .sb-slide-down-leave-to { max-height:0; opacity:0; }
.sb-slide-down-enter-to, .sb-slide-down-leave-from { max-height:400px; opacity:1; }
/* ── Sidebar ── */
.sb-sidebar-strip { width:48px; min-width:48px; flex-shrink:0; display:flex; flex-direction:column; align-items:center; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); padding:6px 0; gap:2px; z-index:20; }
.sbs-search-wrap { width:100%; padding:0 6px; margin-bottom:4px; }
.sbs-search { width:100%; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:5px 4px; outline:none; text-align:center; box-sizing:border-box; }
.sbs-search:focus { border-color:var(--sb-border-acc); }
.sbs-icon { width:36px; height:36px; border-radius:8px; border:none; background:none; color:var(--sb-muted); cursor:pointer; display:flex; align-items:center; justify-content:center; position:relative; transition:background 0.12s, color 0.12s; text-decoration:none; }
.sbs-icon:hover, .sbs-icon.active { background:var(--sb-card); color:var(--sb-text); }
.sbs-icon svg { width:16px; height:16px; }
.sbs-badge { position:absolute; top:4px; right:4px; width:7px; height:7px; border-radius:50%; background:var(--sb-acc); }
.sbs-count { position:absolute; top:2px; right:0; min-width:14px; height:14px; border-radius:7px; background:var(--sb-acc); color:#fff; font-size:0.5rem; font-weight:700; display:flex; align-items:center; justify-content:center; padding:0 3px; }
.sbs-search-full { width:100%; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.25rem 0.45rem; outline:none; box-sizing:border-box; }
.sbs-search-full:focus { border-color:var(--sb-border-acc); }
/* ── Flyout ── */
.sb-flyout { width:260px; min-width:260px; flex-shrink:0; display:flex; flex-direction:column; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); overflow-y:auto; z-index:19; box-shadow:4px 0 16px rgba(0,0,0,0.3); }
.sb-flyout::-webkit-scrollbar { width:3px; }
.sb-flyout::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-overlay-backdrop { position:absolute; top:0; left:0; right:0; bottom:0; z-index:24; background:rgba(0,0,0,0.3); }
.sb-left-overlay { position:absolute; top:0; left:0; z-index:25; width:280px; min-width:280px; height:100%; display:flex; flex-direction:column; background:var(--sb-sidebar); color:var(--sb-text); border-right:1px solid var(--sb-border); box-shadow:4px 0 16px rgba(0,0,0,0.5); }
.sbf-section { padding:0.6rem 0.65rem; border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sbf-section-grow { flex:1; overflow-y:auto; padding:0.5rem 0.65rem; display:flex; flex-direction:column; gap:0.3rem; }
.sbf-section-grow::-webkit-scrollbar { width:3px; }
.sbf-section-grow::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sbf-title { font-size:0.58rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:var(--sb-muted); margin-bottom:0.4rem; display:flex; align-items:center; justify-content:space-between; }
.sbf-count { background:var(--sb-acc); color:#fff; font-size:0.58rem; padding:0.05rem 0.35rem; border-radius:8px; font-weight:700; }
.sbf-primary-btn { width:100%; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:0.7rem; font-weight:700; padding:0.35rem 0.5rem; cursor:pointer; }
.sbf-primary-btn:hover { filter:brightness(1.12); }
.sbf-chip { display:flex; align-items:center; justify-content:space-between; background:rgba(99,102,241,0.12); border:1px solid var(--sb-border-acc); border-radius:6px; padding:0.2rem 0.45rem; font-size:0.68rem; margin-top:0.3rem; color:var(--sb-acc); }
.sbf-chip button { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.8rem; }
.sbf-lbl { display:block; font-size:0.62rem; color:var(--sb-muted); margin:0.3rem 0 0.15rem; }
.sbf-select { width:100%; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:0.7rem; padding:0.22rem 0.4rem; cursor:pointer; }
.sbf-clear-btn { width:100%; background:none; border:1px solid rgba(239,68,68,0.3); border-radius:5px; color:var(--sb-red); font-size:0.67rem; padding:0.22rem; cursor:pointer; margin-top:0.3rem; }
.sbf-auto-btn { background:none; border:1px solid rgba(99,102,241,0.4); border-radius:4px; color:#6366f1; font-size:0.58rem; font-weight:700; padding:1px 6px; cursor:pointer; margin-left:auto; }
.sbf-auto-btn:hover { background:rgba(99,102,241,0.12); }
.sbf-clear-btn:hover { background:rgba(239,68,68,0.08); }
.sbf-drop-active { background:rgba(99,102,241,0.07); border-radius:6px; }
.sbf-drop-hint { font-size:0.68rem; color:var(--sb-acc); font-weight:700; text-align:center; padding:0.4rem; border:1.5px dashed var(--sb-border-acc); border-radius:6px; margin-bottom:0.35rem; }
.sbf-empty { font-size:0.72rem; color:var(--sb-muted); font-style:italic; padding:0.25rem 0; }
.sbf-card { display:flex; border-radius:7px; overflow:hidden; cursor:grab; background:var(--sb-card); border:1px solid var(--sb-border); transition:border-color 0.12s, transform 0.12s; }
.sbf-card:hover { border-color:var(--sb-border-acc); transform:translateY(-1px); }
.sbf-card-stripe { width:4px; flex-shrink:0; }
.sbf-card-body { flex:1; min-width:0; padding:0.35rem 0.45rem; }
.sbf-card-title { font-size:0.72rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sbf-card-meta { font-size:0.62rem; color:var(--sb-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* ── Center ── */
.sb-center-col { flex:1; display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; }
.sb-board { flex:1; overflow:auto; min-width:0; position:relative; min-height:0; }
.sb-board::-webkit-scrollbar { width:5px; height:5px; }
.sb-board::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:3px; }
/* ── Grid ── */
.sb-grid { display:flex; flex-direction:column; }
.sb-grid-cal { display:flex; flex-direction:column; width:100%; min-width:0; }
.sb-grid-hdr { display:flex; height:46px; flex-shrink:0; position:sticky; top:0; z-index:15; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); }
.sb-res-hdr { width:200px; min-width:200px; flex-shrink:0; position:sticky; left:0; z-index:16; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); display:flex; align-items:center; padding:0 0.75rem; font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); gap:0.35rem; }
.sb-time-hdr-wrap { overflow:hidden; position:relative; }
.sb-cal-hdr { display:flex; flex:1; overflow:hidden; }
.sb-cal-hdr-cell { flex:1; min-width:0; border-left:1px solid var(--sb-border); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px; padding:2px 0; }
.sb-cal-hdr-cell.sb-col-today { background:rgba(99,102,241,0.06); }
.sb-cal-wd { font-size:0.58rem; font-weight:800; text-transform:uppercase; letter-spacing:0.05em; color:var(--sb-muted); }
.sb-cal-dn { font-size:0.82rem; font-weight:700; color:var(--sb-text); line-height:1; }
.sb-today-bubble { background:var(--sb-acc); color:#fff !important; border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; }
.sb-htick { position:absolute; top:0; height:100%; border-left:1px solid var(--sb-border); }
.sb-htick-lbl { position:absolute; top:6px; left:4px; font-size:0.58rem; color:var(--sb-muted); white-space:nowrap; font-weight:600; }
.sb-day-lbl { position:absolute; top:4px; left:6px; font-size:0.65rem; font-weight:700; color:var(--sb-text); white-space:nowrap; letter-spacing:0.02em; text-transform:capitalize; }
.sb-day-today { color:var(--sb-acc); }
.sb-htick.sb-day-boundary { border-left:2px solid var(--sb-border); }
/* ── Rows ── */
.sb-row { display:flex; border-bottom:1px solid var(--sb-border); transition:background 0.12s; }
.sb-row-cal { align-items:stretch; min-height:44px; height:auto !important; }
.sb-row-sel { background:rgba(99,102,241,0.04); }
.sb-row-elevated { position:relative; z-index:8; }
.sb-row-absent { opacity:0.7; }
.sb-row-absent .sb-avatar { filter: grayscale(0.6); }
.sb-row-elevated .sb-timeline { overflow:visible; }
.sb-loading-row, .sb-empty-row { padding:2rem; text-align:center; color:var(--sb-muted); font-style:italic; }
.sb-cal-row { display:flex; flex:1; min-width:0; overflow:hidden; }
.sb-cal-cell { flex:1; min-width:0; border-left:1px solid var(--sb-border); padding:2px 3px; position:relative; display:flex; flex-direction:column; gap:3px; transition:background 0.1s; }
.sb-cal-cell:hover { background:rgba(255,255,255,0.02); }
.sb-cal-drop { position:absolute; inset:1px; border:1.5px dashed var(--sb-acc); border-radius:4px; background:rgba(99,102,241,0.08); pointer-events:none; z-index:2; }
.sb-chip { font-size:0.62rem; font-weight:600; padding:4px 6px; border-radius:6px; border-left:3px solid transparent; overflow:hidden; cursor:pointer; transition:background 0.1s; color:var(--sb-text); line-height:1.5; z-index:1; position:relative; }
.sb-chip:hover { background:rgba(255,255,255,0.12); }
.sb-chip-done { opacity:0.45; text-decoration:line-through; }
.sb-chip-sel { outline:1.5px solid #6366f1; outline-offset:1px; }
.sb-chip-assist { opacity:0.7; border-left-style:dashed !important; font-style:italic; }
.sb-chip-assist-tag { font-size:0.5rem; margin-right:2px; }
.sb-chip-multi { outline:2px solid #f59e0b; outline-offset:1px; }
.sb-chip-urgent { width:6px; height:6px; border-radius:50%; background:#ef4444; flex-shrink:0; box-shadow:0 0 4px #ef4444; }
.sb-chip-line1 { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:3px; }
.sb-chip-line2 { font-size:0.52rem; color:rgba(255,255,255,0.7); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; line-height:1.1; display:flex; align-items:center; gap:2px; }
.sb-chip-line2 svg { width:9px; height:9px; flex-shrink:0; }
.sb-day-load { margin-top:auto; flex-shrink:0; padding:2px 2px 1px; display:flex; align-items:center; gap:3px; }
.sb-day-load-track { flex:1; height:3px; background:rgba(255,255,255,0.08); border-radius:2px; overflow:hidden; }
.sb-day-load-fill { height:100%; border-radius:2px; transition:width 0.3s, background 0.3s; }
.sb-day-load-label { font-size:0.5rem; font-weight:700; color:var(--sb-muted); white-space:nowrap; font-variant-numeric:tabular-nums; }
.sb-res-cell { width:200px; min-width:200px; flex-shrink:0; position:sticky; left:0; z-index:5; align-self:stretch; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); display:flex; align-items:center; gap:0.5rem; padding:0 0.65rem; cursor:pointer; transition:background 0.12s; }
.sb-res-cell:hover { background:var(--sb-card-h); }
.sb-avatar { width:32px; height:32px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:0.68rem; font-weight:700; color:#fff; }
.sb-avatar-mat { background:#1e293b; border:1.5px solid #475569; border-radius:8px; font-size:0.9rem; }
.sb-res-info { flex:1; min-width:0; position:relative; }
.sb-res-name { font-size:0.78rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:3px; }
.sb-res-tag-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
// Hover action overlay — floats over the name area on hover
.sb-res-actions {
position:absolute; top:0; right:0; bottom:0;
display:flex; align-items:center; gap:2px;
padding:0 4px 0 16px;
background:linear-gradient(90deg, transparent, var(--sb-sidebar) 30%);
opacity:0; transition:opacity 0.15s;
pointer-events:none;
button {
background:rgba(255,255,255,0.08); border:none; border-radius:4px;
color:#c8cce0; font-size:0.65rem; font-weight:700;
width:22px; height:22px; display:flex; align-items:center; justify-content:center;
cursor:pointer; transition:background 0.12s, color 0.12s;
&:hover { background:rgba(255,255,255,0.16); color:#e2e4ef; }
}
.sb-act-play { color:#10b981; &:hover { background:rgba(16,185,129,0.2); } }
}
.sb-res-cell:hover .sb-res-actions { opacity:1; pointer-events:auto; }
.sb-res-absence-tag { font-size:0.52rem; color:#94a3b8; font-style:italic; }
.sb-res-sub { display:flex; align-items:center; gap:0.3rem; margin:0.1rem 0; }
.sb-load { font-size:0.58rem; color:var(--sb-muted); }
.sb-util-bar { height:3px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; margin-top:0.15rem; }
.sb-util-fill { height:100%; border-radius:2px; transition:width 0.3s, background 0.3s; }
.sb-capacity-line { position:absolute; top:0; bottom:0; width:1px; border-left:1px dashed rgba(255,255,255,0.18); z-index:1; pointer-events:none; }
.sb-st { font-size:0.58rem; font-weight:700; padding:0.07rem 0.3rem; border-radius:4px; white-space:nowrap; }
.st-available { background:rgba(16,185,129,0.15); color:var(--sb-green); }
.st-enroute { background:rgba(245,158,11,0.15); color:var(--sb-orange); }
.st-busy { background:rgba(99,102,241,0.15); color:#818cf8; }
.st-off { background:rgba(239,68,68,0.1); color:var(--sb-red); }
.st-inactive { background:rgba(100,116,139,0.15); color:#64748b; }
.prio-high { color:var(--sb-red); font-weight:700; }
.prio-med { color:var(--sb-orange); font-weight:700; }
.prio-low { color:var(--sb-green); }
.sb-timeline { position:relative; flex:1; overflow:hidden; }
.sb-day-bg, .sb-month-bg { position:absolute; top:0; bottom:0; }
.sb-bg-alt { background:rgba(255,255,255,0.012); }
.sb-bg-today { background:rgba(99,102,241,0.06); }
.sb-hour-guide { position:absolute; top:0; bottom:0; width:1px; background:var(--sb-border); opacity:0.6; pointer-events:none; }
.sb-quarter-guide { position:absolute; top:0; bottom:0; width:1px; background:var(--sb-border); opacity:0.2; pointer-events:none; }
.sb-drop-line { position:absolute; top:2px; bottom:2px; width:3px; background:#6366f1; border-radius:2px; box-shadow:0 0 10px #6366f1, 0 0 4px #6366f1; pointer-events:none; z-index:20; transform:translateX(-1px); }
.sb-block { position:absolute; border-radius:6px; overflow:hidden; display:flex; align-items:center; cursor:grab; z-index:4; box-shadow:0 2px 8px rgba(0,0,0,0.35); transition:box-shadow 0.12s, transform 0.12s; min-width:18px; }
.sb-block:hover { box-shadow:0 4px 16px rgba(0,0,0,0.5); transform:translateY(-1px); z-index:5; }
.sb-block-done { opacity:0.55; }
// ── Draft (unpublished) job — hatched diagonal stripes ──
.sb-block-draft {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
rgba(255, 255, 255, 0.08) 5px,
rgba(255, 255, 255, 0.08) 10px
);
border: 1px dashed rgba(255, 255, 255, 0.2);
}
// ── Day picker buttons (recurrence) ──
.sb-day-btn {
width: 24px; height: 24px; border-radius: 50%; border: 1px solid #4b5563;
background: transparent; color: #9ca3af; font-size: 0.62rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0;
&:hover { border-color: #818cf8; color: #c4c8e4; }
&.sb-day-btn-active { background: #6366f1; border-color: #6366f1; color: #fff; }
}
// ── Ghost (recurring) block ──
.sb-block-ghost {
opacity: 0.55;
border: 2px dashed rgba(255, 255, 255, 0.3) !important;
cursor: pointer;
transition: opacity 0.15s;
&:hover { opacity: 0.85; }
.sb-ghost-icon { font-size: 0.65rem; margin-right: 2px; }
}
.sb-chip-ghost {
opacity: 0.55;
border: 1px dashed rgba(255, 255, 255, 0.4) !important;
cursor: pointer;
&:hover { opacity: 0.85; }
}
// ── Shift availability background blocks ──
.sb-block-shift {
position: absolute;
z-index: 0;
border-radius: 6px;
background: rgba(100, 180, 255, 0.08);
border: 1px solid rgba(100, 180, 255, 0.15);
pointer-events: none;
.sb-shift-label {
position: absolute;
top: 2px; left: 6px;
font-size: 0.55rem;
color: rgba(140, 190, 255, 0.5);
white-space: nowrap;
letter-spacing: 0.02em;
user-select: none;
}
}
.sb-block-shift-oncall {
background: repeating-linear-gradient(
-45deg,
rgba(255, 180, 60, 0.06),
rgba(255, 180, 60, 0.06) 5px,
rgba(255, 180, 60, 0.02) 5px,
rgba(255, 180, 60, 0.02) 10px
);
border-color: rgba(255, 180, 60, 0.2);
.sb-shift-label { color: rgba(255, 190, 80, 0.5); }
}
// ── Week calendar: schedule availability bands (planning mode) ──
.sb-sched-band {
border-radius: 4px; padding: 2px 5px; cursor: pointer;
font-size: 0.62rem; white-space: nowrap; overflow: hidden;
transition: opacity 0.12s;
&:hover { opacity: 0.9; filter: brightness(1.15); }
.sb-sched-time { opacity: 0.85; }
}
.sb-sched-available {
background: rgba(74, 222, 128, 0.12);
border: 1px solid rgba(74, 222, 128, 0.25);
color: #86efac;
}
.sb-sched-oncall {
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.25);
color: #fcd34d;
}
// ── Month calendar: selected tech availability blocks ──
.sb-month-avail {
border-radius: 4px; padding: 2px 5px; margin: 1px 0;
font-size: 0.62rem; cursor: pointer;
display: flex; align-items: center; gap: 3px;
transition: filter 0.12s;
&:hover { filter: brightness(1.2); }
.sb-month-avail-icon { font-size: 0.7rem; flex-shrink: 0; }
.sb-month-avail-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
.sb-month-avail-available { color: #86efac; }
.sb-month-avail-oncall { color: #fcd34d; }
.sb-month-avail-absence { color: #fca5a5; }
.sb-month-avail-dayoff { color: #9ca3af; opacity: 0.6; }
// ── Absence block (grayed-out timeline overlay) ──
.sb-block-absence {
background: repeating-linear-gradient(
-45deg,
rgba(100, 116, 139, 0.18),
rgba(100, 116, 139, 0.18) 6px,
rgba(100, 116, 139, 0.10) 6px,
rgba(100, 116, 139, 0.10) 12px
) !important;
border: 1px dashed rgba(100, 116, 139, 0.4);
border-radius: 6px;
cursor: default;
z-index: 2;
pointer-events: auto;
&:hover { box-shadow: none; transform: none; }
.sb-resize-left { left: 0; right: auto; &:hover { border-radius: 6px 0 0 6px; } &::after { right: auto; left: 2px; } }
}
.sb-absence-inner {
display: flex; align-items: center; gap: 5px;
height: 100%; padding: 0 8px;
overflow: hidden; white-space: nowrap;
color: #94a3b8; font-size: 0.65rem; font-weight: 600;
letter-spacing: 0.02em;
}
.sb-absence-icon { font-size: 0.8rem; flex-shrink: 0; }
.sb-absence-label { opacity: 0.85; }
.sb-absence-dates { opacity: 0.6; font-size: 0.58rem; font-weight: 400; }
// Week calendar absence cell overlay
.sb-cal-absent {
background: repeating-linear-gradient(
-45deg,
rgba(100, 116, 139, 0.08),
rgba(100, 116, 139, 0.08) 4px,
transparent 4px,
transparent 8px
) !important;
}
.sb-chip-absence {
background: rgba(100, 116, 139, 0.18) !important;
border-left-color: #94a3b8 !important;
color: #94a3b8 !important;
font-style: italic;
pointer-events: none;
.sb-chip-line1 { opacity: 0.85; }
}
.sb-chip-dayoff {
background: repeating-linear-gradient(135deg, rgba(100,116,139,0.08), rgba(100,116,139,0.08) 4px, rgba(100,116,139,0.16) 4px, rgba(100,116,139,0.16) 8px) !important;
border-left-color: #64748b !important;
color: #64748b !important;
}
.sb-chip-absence-full { opacity:0.85; }
.sb-absence-detail { font-size:0.55rem !important; opacity:0.75; font-style:italic; margin-top:1px; }
.sb-block.sb-block-sel { outline:2px solid #6366f1; outline-offset:1px; z-index:6 !important; }
.sb-block.sb-block-multi { outline:2px solid #f59e0b; outline-offset:1px; z-index:6 !important; }
.sb-multi-bar { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); z-index:200; background:#181c2e; color:#e2e4ef; border:1px solid rgba(99,102,241,0.3); border-radius:10px; padding:6px 14px; display:flex; align-items:center; gap:8px; box-shadow:0 8px 32px rgba(0,0,0,0.5); font-size:0.72rem; }
.sb-multi-count { font-weight:700; color:#f59e0b; }
.sb-multi-btn { background:none; border:1px solid rgba(255,255,255,0.1); border-radius:5px; color:#e2e4ef; font-size:0.65rem; padding:3px 8px; cursor:pointer; }
.sb-multi-btn:hover { background:rgba(255,255,255,0.08); }
.sb-multi-btn.sb-ctx-warn { color:#ef4444; border-color:rgba(239,68,68,0.3); }
.sb-multi-clear { color:#7b80a0; }
.sb-multi-sep { color:rgba(255,255,255,0.15); }
.sb-multi-lbl { color:#7b80a0; font-size:0.62rem; }
.sb-multi-tech { background:none; border:2px solid; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; font-size:0.5rem; font-weight:800; color:#e2e4ef; cursor:pointer; }
.sb-multi-tech:hover { filter:brightness(1.3); }
.sb-slide-up-enter-active, .sb-slide-up-leave-active { transition:transform 0.2s, opacity 0.2s; }
.sb-slide-up-enter-from, .sb-slide-up-leave-to { transform:translateX(-50%) translateY(20px); opacity:0; }
.sb-lasso { position:absolute; border:1.5px dashed #f59e0b; background:rgba(245,158,11,0.08); border-radius:3px; pointer-events:none; z-index:50; }
.sb-board:has(.sb-lasso) { user-select:none; -webkit-user-select:none; cursor:crosshair; }
.sb-block.sb-block-linked { outline:2px dashed #6366f1; outline-offset:1px; z-index:7 !important; opacity:1 !important; box-shadow:0 4px 16px rgba(99,102,241,0.3) !important; transform:translateY(-1px); }
.sb-block-status-icon { display:inline-flex; align-items:center; margin-right:5px; flex-shrink:0; }
.sb-block-status-icon svg { width:13px; height:13px; }
.sb-block-assistants { display:flex; gap:2px; position:absolute; top:2px; right:2px; }
.sb-assist-badge { width:16px; height:16px; border-radius:50%; font-size:0.45rem; font-weight:800; color:#fff; display:flex; align-items:center; justify-content:center; border:1.5px solid rgba(0,0,0,0.3); }
.sb-assist-badge-lead { border-color:rgba(255,255,255,0.5); }
.sb-block-team { outline:1.5px solid rgba(255,255,255,0.25); outline-offset:1px; }
.sb-block-drop-hover { outline:2px solid #6366f1 !important; outline-offset:1px; filter:brightness(1.3); }
.sb-block-assist { opacity:0.7; border:1.5px dashed rgba(255,255,255,0.25); cursor:pointer; z-index:3; background-image:repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255,255,255,0.04) 4px, rgba(255,255,255,0.04) 8px) !important; }
.sb-block-assist .sb-block-meta { font-size:0.52rem; }
.sb-block-assist:hover { opacity:0.9; }
.sb-block-assist-pinned { opacity:0.85; border-style:solid; background-image:none !important; }
.sb-move-handle { position:absolute; left:0; top:0; bottom:0; width:8px; cursor:grab; z-index:6; }
.sb-move-handle:hover { background:rgba(255,255,255,0.15); border-radius:6px 0 0 6px; }
.sb-move-handle::after { content:'⠿'; position:absolute; left:1px; top:50%; transform:translateY(-50%); font-size:0.5rem; color:rgba(255,255,255,0.4); }
.sb-move-handle:active { cursor:grabbing; }
.sb-resize-handle { position:absolute; right:0; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:6; }
.sb-resize-handle:hover { background:rgba(255,255,255,0.15); border-radius:0 6px 6px 0; }
.sb-resize-handle::after { content:''; position:absolute; right:2px; top:50%; transform:translateY(-50%); width:2px; height:12px; border-radius:1px; background:rgba(255,255,255,0.3); }
.sbf-team-badges { display:flex; gap:2px; margin-top:2px; }
.sb-type-icon { display:inline-flex; align-items:center; margin-right:3px; flex-shrink:0; vertical-align:middle; }
.sb-type-icon svg { width:11px; height:11px; }
.sb-block-color-bar { width:3px; height:100%; flex-shrink:0; background:rgba(0,0,0,0.25); }
.sb-block-inner { flex:1; min-width:0; padding:0 5px; }
.sb-block-title { font-size:0.68rem; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#fff; }
.sb-block-meta { font-size:0.58rem; color:rgba(255,255,255,0.7); white-space:nowrap; }
.sb-block-addr { font-size:0.52rem; color:rgba(255,255,255,0.5); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:2px; }
.sb-block-addr svg { width:9px; height:9px; flex-shrink:0; }
.sb-block-pin { display:inline-flex; align-items:center; margin-right:2px; }
.sb-block-pin svg { width:10px; height:10px; }
.sb-travel-trail { position:absolute; border-radius:4px; z-index:2; display:flex; align-items:center; justify-content:center; pointer-events:none; transition:opacity 0.2s; }
.sb-travel-route { border-left-style:solid; }
.sb-travel-est { border-left-style:dashed; opacity:0.7; }
.sb-travel-lbl { font-size:0.55rem; color:rgba(255,255,255,0.65); white-space:nowrap; font-style:italic; }
/* ── Bottom panel ── */
.sb-bottom-panel { flex-shrink:0; border-top:1px solid var(--sb-border); display:flex; flex-direction:column; background:var(--sb-sidebar); position:relative; overflow:hidden; }
.sb-bottom-resize { position:absolute; top:0; left:0; right:0; height:4px; z-index:10; cursor:row-resize; background:transparent; }
.sb-bottom-resize:hover { background:rgba(99,102,241,0.35); }
.sb-bottom-hdr { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0.75rem; border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sb-bottom-title { font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); display:flex; align-items:center; gap:0.35rem; }
.sb-bottom-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.9rem; margin-left:auto; }
.sb-bottom-close:hover { color:var(--sb-red); }
.sb-bottom-body { flex:1; overflow-y:auto; overflow-x:auto; display:flex; flex-direction:column; }
.sb-bottom-body::-webkit-scrollbar { width:4px; height:4px; }
.sb-bottom-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-bottom-date-sep { display:flex; align-items:center; gap:0.4rem; padding:0.3rem 0.75rem; background:rgba(99,102,241,0.06); border-bottom:1px solid var(--sb-border); position:sticky; top:0; z-index:2; user-select:none; cursor:crosshair; }
.sb-bottom-date-label { font-size:0.62rem; font-weight:800; color:var(--sb-acc); text-transform:uppercase; letter-spacing:0.05em; }
.sb-bottom-date-count { font-size:0.55rem; color:var(--sb-muted); }
.sb-bottom-scroll { flex:1; overflow-y:auto; overflow-x:hidden; min-height:0; cursor:crosshair; }
.sb-bottom-scroll::-webkit-scrollbar { width:4px; }
.sb-bottom-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-bottom-table { width:100%; border-collapse:collapse; font-size:0.72rem; table-layout:fixed; }
.sb-bottom-table thead th { position:sticky; top:0; z-index:3; background:var(--sb-sidebar); text-align:left; font-size:0.55rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:var(--sb-muted); padding:0.3rem 0.5rem; border-bottom:1px solid var(--sb-border); white-space:nowrap; overflow:hidden; }
.sb-col-resize { position:absolute; right:0; top:0; bottom:0; width:5px; cursor:col-resize; z-index:4; }
.sb-col-resize:hover { background:rgba(99,102,241,0.35); }
.sb-bottom-row { cursor:pointer; transition:background 0.1s; border-bottom:1px solid var(--sb-border); }
.sb-bottom-row:hover { background:var(--sb-card-h); }
.sb-bottom-row-sel { background:rgba(99,102,241,0.1) !important; }
.sb-bottom-row-sel:hover { background:rgba(99,102,241,0.15) !important; }
.sb-bt-lasso { position:absolute; border:1.5px dashed #f59e0b; background:rgba(245,158,11,0.08); pointer-events:none; z-index:50; border-radius:3px; }
.sb-bottom-scroll:has(.sb-bt-lasso) { user-select:none; -webkit-user-select:none; cursor:crosshair; }
.sb-bottom-row td { padding:0.3rem 0.5rem; white-space:nowrap; color:var(--sb-text); overflow:hidden; text-overflow:ellipsis; }
.sb-bt-chk { padding:0 !important; text-align:center !important; }
.sb-bt-checkbox { display:inline-block; width:14px; height:14px; border-radius:3px; border:1.5px solid var(--sb-muted); vertical-align:middle; position:relative; }
.sb-bt-checkbox.checked { background:var(--sb-acc); border-color:var(--sb-acc); }
.sb-bt-checkbox.checked::after { content:'✓'; position:absolute; inset:0; color:#fff; font-size:0.55rem; font-weight:800; display:flex; align-items:center; justify-content:center; }
.sb-bt-prio-dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.sb-bt-name-text { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
.sb-bt-addr { color:var(--sb-muted); font-size:0.65rem; overflow:hidden; text-overflow:ellipsis; }
.sb-bt-dur-wrap { display:flex; align-items:center; gap:6px; }
.sb-bt-dur-bar { flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; min-width:30px; }
.sb-bt-dur-fill { height:100%; border-radius:2px; transition:width 0.2s; }
.sb-bt-dur-lbl { font-size:0.62rem; color:var(--sb-muted); font-variant-numeric:tabular-nums; white-space:nowrap; min-width:28px; }
.sb-bt-prio-tag { font-size:0.6rem; font-weight:700; padding:1px 6px; border-radius:4px; }
.sb-bt-skill-chip { display:inline-flex; align-items:center; font-size:0.55rem; font-weight:600; color:var(--sb-text); background:rgba(255,255,255,0.08); border:1px solid var(--sb-border); padding:1px 6px; border-radius:10px; margin-right:3px; white-space:nowrap; }
.sb-bt-no-tag { font-size:0.6rem; color:rgba(255,255,255,0.2); }
.sb-bottom-sel-count { font-size:0.65rem; font-weight:700; color:var(--sb-acc); }
.sb-bottom-sel-lbl { font-size:0.72rem; color:var(--sb-muted); }
.sb-bottom-sel-clear { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.8rem; }
.sb-bottom-sel-clear:hover { color:var(--sb-red); }
.sb-bottom-sel-all { background:none; border:1px solid var(--sb-border); border-radius:4px; color:var(--sb-muted); font-size:0.58rem; padding:2px 6px; cursor:pointer; }
.sb-bottom-sel-all:hover { color:var(--sb-text); border-color:var(--sb-border-acc); }
.sb-crit-row { display:flex; align-items:center; gap:0.5rem; padding:0.4rem 0.5rem; border-bottom:1px solid var(--sb-border); background:var(--sb-card); border-radius:6px; margin-bottom:4px; cursor:grab; transition:background 0.12s, transform 0.12s, border-color 0.12s; }
.sb-crit-row:active { cursor:grabbing; }
.sb-crit-drag-over { border-color:var(--sb-acc); background:rgba(99,102,241,0.12); transform:scale(1.02); }
.sb-crit-order { font-size:0.65rem; font-weight:800; color:var(--sb-acc); width:18px; text-align:center; flex-shrink:0; }
.sb-crit-label { flex:1; font-size:0.72rem; color:var(--sb-text); display:flex; align-items:center; gap:0.4rem; cursor:pointer; }
.sb-crit-label input[type="checkbox"] { accent-color:var(--sb-acc); }
.sb-crit-arrows { display:flex; flex-direction:column; gap:1px; }
.sb-crit-arrows button { background:none; border:1px solid var(--sb-border); border-radius:3px; color:var(--sb-muted); font-size:0.5rem; padding:0 4px; cursor:pointer; line-height:1.2; }
.sb-crit-arrows button:hover:not(:disabled) { color:var(--sb-text); border-color:var(--sb-border-acc); }
.sb-crit-arrows button:disabled { opacity:0.25; cursor:default; }
.sb-crit-handle { color:var(--sb-muted); font-size:0.7rem; cursor:grab; user-select:none; flex-shrink:0; opacity:0.4; transition:opacity 0.12s; }
.sb-crit-row:hover .sb-crit-handle { opacity:0.8; }
/* ── Month ── */
.sb-month-wrap { flex:1; overflow-y:auto; display:flex; flex-direction:column; padding:0.5rem; gap:0.25rem; }
.sb-month-dow-hdr { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; margin-bottom:2px; flex-shrink:0; }
.sb-month-dow { text-align:center; font-size:0.6rem; font-weight:800; color:var(--sb-muted); padding:0.25rem 0; text-transform:uppercase; letter-spacing:0.07em; }
.sb-month-week { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; }
.sb-month-day { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; padding:0.3rem 0.35rem; min-height:80px; cursor:pointer; display:flex; flex-direction:column; gap:3px; transition:background 0.1s; }
.sb-month-day:hover { background:var(--sb-card-h); }
.sb-month-day-out { opacity:0.28; pointer-events:none; }
.sb-month-day-today { border-color:var(--sb-acc); }
.sb-month-day-num { font-size:0.7rem; font-weight:700; color:var(--sb-text); width:20px; height:20px; display:flex; align-items:center; justify-content:center; border-radius:50%; flex-shrink:0; }
.sb-month-day-today .sb-month-day-num { background:var(--sb-acc); color:#fff; }
.sb-month-avatars { display:flex; flex-wrap:wrap; gap:2px; }
.sb-month-avatar { width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.48rem; font-weight:800; color:#fff; flex-shrink:0; cursor:pointer; transition:transform 0.1s; box-shadow:0 1px 3px rgba(0,0,0,0.4); }
.sb-month-avatar:hover { transform:scale(1.3); z-index:2; position:relative; }
.sb-month-stats { display:flex; gap:6px; align-items:center; }
.sb-month-stat { font-size:0.52rem; font-weight:600; display:flex; align-items:center; gap:2px; }
.sb-month-stat-present { color:var(--sb-green); }
.sb-month-stat-absent { color:var(--sb-muted); opacity:0.7; }
.sb-month-hours { font-size:0.5rem; color:var(--sb-muted); font-weight:600; }
.sb-month-job-count { font-size:0.52rem; color:var(--sb-muted); margin-top:auto; }
/* ── Map ── */
.sb-map-backdrop { position:absolute; top:0; left:0; right:0; bottom:0; z-index:14; background:rgba(0,0,0,0.15); }
.sb-map-panel { flex-shrink:0; z-index:15; display:flex; flex-direction:column; border-left:1px solid var(--sb-border); overflow:hidden; position:relative; }
.sb-map-resize-handle { position:absolute; left:0; top:0; bottom:0; width:5px; z-index:10; cursor:col-resize; background:transparent; transition:background 0.15s; }
.sb-map-resize-handle:hover { background:rgba(99,102,241,0.35); }
.sb-map-bar { display:flex; align-items:center; gap:0.4rem; padding:0.4rem 0.65rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sb-map-title { font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); }
.sb-map-tech { font-size:0.7rem; font-weight:700; }
.sb-map-route-hint { font-size:0.58rem; font-weight:400; color:rgba(255,255,255,0.35); margin-left:0.25rem; }
.sb-map-hint { font-size:0.62rem; color:var(--sb-muted); font-style:italic; flex:1; }
.sb-map-bar-geofix { background:rgba(99,102,241,0.18); border-bottom-color:var(--sb-border-acc); }
.sb-geofix-hint { flex:1; font-size:0.68rem; color:var(--sb-text); animation:sb-geofix-pulse 1.4s ease-in-out infinite; }
.sb-geofix-cancel { background:none; border:1px solid var(--sb-border-acc); border-radius:5px; color:var(--sb-muted); font-size:0.65rem; padding:0.18rem 0.45rem; cursor:pointer; }
.sb-geofix-cancel:hover { color:var(--sb-red); border-color:rgba(239,68,68,0.4); }
@keyframes sb-geofix-pulse { 0%,100%{ opacity:1 } 50%{ opacity:0.55 } }
.sb-map-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.9rem; margin-left:auto; }
.sb-map-legend { display:flex; flex-wrap:wrap; gap:0.3rem 0.6rem; padding:0.3rem 0.65rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sb-legend-item { display:flex; align-items:center; gap:0.2rem; font-size:0.62rem; color:var(--sb-muted); }
.sb-legend-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
.sb-map { flex:1; min-height:0; }
.sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.55); cursor:pointer; transition:transform 0.15s; }
.sb-map-tech-pin:hover { transform:scale(1.2); }
.sb-map-tech-pin { position:relative; }
.sb-map-crew-badge { position:absolute; top:-4px; right:-6px; min-width:16px; height:16px; border-radius:8px; background:#6366f1; color:#fff; font-size:9px; font-weight:800; display:flex; align-items:center; justify-content:center; border:2px solid #0f172a; line-height:1; padding:0 3px; }
.sb-map-gps-active { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); animation:gps-glow 2s infinite; }
@keyframes gps-glow { 0%,100% { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); } 50% { box-shadow:0 0 0 6px rgba(16,185,129,0.3), 0 0 20px rgba(16,185,129,0.3), 0 2px 10px rgba(0,0,0,0.55); } }
.sb-map-drag-ghost { padding:4px 8px; border-radius:6px; background:rgba(99,102,241,0.9); color:#fff; font-size:0.68rem; font-weight:700; box-shadow:0 4px 16px rgba(0,0,0,0.55); max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none; }
/* ── Right panel ── */
.sb-right { width:280px; min-width:280px; flex-shrink:0; display:flex; flex-direction:column; background:#111422; color:#e2e4ef; border-left:1px solid rgba(255,255,255,0.06); overflow:hidden; }
.sb-rp-hdr { display:flex; align-items:center; padding:0.55rem 0.75rem; border-bottom:1px solid rgba(255,255,255,0.06); flex-shrink:0; }
.sb-rp-title { font-size:0.68rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; flex:1; }
.sb-rp-close { background:none; border:none; color:#7b80a0; cursor:pointer; font-size:0.95rem; transition:color 0.12s; }
.sb-rp-close:hover { color:#ef4444; }
.sb-rp-body { flex:1; overflow-y:auto; padding:0.65rem 0.75rem; color:#e2e4ef; }
.sb-rp-body::-webkit-scrollbar { width:3px; }
.sb-rp-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-rp-color-bar { height:3px; border-radius:2px; margin-bottom:0.75rem; }
.sb-rp-urgent-tag { background:rgba(239,68,68,0.15); color:#ef4444; font-size:0.7rem; font-weight:700; padding:0.25rem 0.5rem; border-radius:6px; display:inline-block; margin-bottom:0.5rem; }
.sb-rp-field { margin-bottom:0.45rem; color:#e2e4ef; }
.sb-rp-lbl { display:block; font-size:0.58rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.1rem; }
.sb-rp-actions { padding:0.65rem 0.75rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; flex-direction:column; gap:0.35rem; flex-shrink:0; }
.sb-rp-primary { background:#6366f1; border:none; border-radius:7px; color:#fff; font-size:0.72rem; font-weight:700; padding:0.4rem 0.75rem; cursor:pointer; }
.sb-rp-primary:hover { filter:brightness(1.12); }
.sb-rp-btn { background:none; border:1px solid rgba(255,255,255,0.06); border-radius:7px; color:#7b80a0; font-size:0.7rem; padding:0.35rem 0.75rem; cursor:pointer; transition:border-color 0.12s, color 0.12s; }
.sb-rp-btn:hover { border-color:rgba(99,102,241,0.4); color:#e2e4ef; }
.sb-assign-grid { display:flex; flex-direction:column; gap:0.3rem; }
.sb-assign-btn { display:flex; align-items:center; gap:0.4rem; background:none; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.7rem; padding:0.3rem 0.55rem; cursor:pointer; transition:background 0.12s; text-align:left; }
.sb-assign-btn:hover { background:#181c2e; }
.sb-assign-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
/* ── Context menu ── */
.sb-ctx { position:fixed; z-index:200; background:#181c2e; color:#e2e4ef; border:1px solid rgba(99,102,241,0.4); border-radius:9px; padding:0.3rem; box-shadow:0 8px 28px rgba(0,0,0,0.55); min-width:180px; }
.sb-ctx-item { display:block; width:100%; background:none; border:none; border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.4rem 0.7rem; cursor:pointer; text-align:left; transition:background 0.1s; }
.sb-ctx-item:hover { background:#1e2338; }
.sb-ctx-sep { height:1px; background:rgba(255,255,255,0.06); margin:0.2rem 0; }
.sb-ctx-warn { color:#ef4444 !important; }
/* ── Modals ── */
.sb-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); z-index:100; display:flex; align-items:center; justify-content:center; }
.sb-overlay-top { z-index:110; }
.sb-modal { background:#111422; color:#e2e4ef; border:1px solid rgba(255,255,255,0.06); border-radius:14px; padding:0; min-width:360px; max-width:500px; width:100%; box-shadow:0 24px 60px rgba(0,0,0,0.6); overflow:hidden; max-height:85vh; display:flex; flex-direction:column; }
.sb-modal-wide { min-width:580px; max-width:680px; }
.sb-modal-tags { min-width:420px; max-width:560px; overflow:visible; }
.sb-modal-hdr { display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1rem; border-bottom:1px solid rgba(255,255,255,0.06); font-weight:700; font-size:0.85rem; color:#e2e4ef; }
.sb-modal-body { padding:0.75rem 1rem; color:#e2e4ef; overflow-y:auto; flex:1; min-height:0; }
.sb-modal-body::-webkit-scrollbar { width:4px; }
.sb-modal-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:2px; }
.sb-modal-ftr { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; }
.sb-modal-footer { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; }
.sb-form-row { margin-bottom:0.6rem; }
.sb-form-lbl { display:block; font-size:0.62rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.2rem; }
.sb-form-val { font-size:0.78rem; font-weight:600; color:#e2e4ef; }
.sb-form-sel { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.3rem 0.5rem; cursor:pointer; }
.sb-form-input { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.3rem 0.5rem; box-sizing:border-box; }
.sb-form-sel:focus, .sb-form-input:focus { border-color:rgba(99,102,241,0.4); outline:none; }
.sb-addr-wrap { position:relative; }
.sb-addr-dropdown { position:absolute; top:100%; left:0; right:0; z-index:50; background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px; max-height:200px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.4); }
.sb-addr-item { padding:6px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef; border-bottom:1px solid rgba(255,255,255,0.04); }
.sb-addr-item:hover { background:rgba(99,102,241,0.12); }
.sb-addr-item strong { color:#fff; }
.sb-addr-city { float:right; font-size:0.6rem; color:#7b80a0; }
.sb-addr-confirmed { font-size:0.6rem; color:#10b981; margin-top:3px; }
.sb-addr-cp { font-size:0.6rem; color:#6366f1; margin-left:4px; }
.sb-modal-wo { min-width:500px; max-width:720px; }
.sb-wo-body { display:flex; gap:1rem; }
.sb-wo-form { flex:1; min-width:0; }
.sb-wo-minimap { width:280px; flex-shrink:0; display:flex; align-items:flex-start; }
.sb-minimap-img { width:100%; border-radius:8px; border:1px solid rgba(255,255,255,0.06); }
.sb-rsel-groups { margin-bottom:0.6rem; }
.sb-rsel-section-title { font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; margin-bottom:0.3rem; }
.sb-rsel-chips { display:flex; flex-wrap:wrap; gap:0.3rem; }
.sb-rsel-chip { background:#181c2e; border:1px solid rgba(255,255,255,0.1); border-radius:6px; color:#c4c8e4; font-size:0.7rem; font-weight:600; padding:0.25rem 0.6rem; cursor:pointer; transition:all 0.12s; }
.sb-rsel-chip:hover { border-color:rgba(99,102,241,0.4); color:#e2e4ef; }
.sb-rsel-chip.active { background:rgba(99,102,241,0.2); border-color:#6366f1; color:#a5b4fc; }
.sb-rsel-group-actions { margin-top:0.4rem; }
.sb-rsel-apply-group { background:#6366f1; border:none; border-radius:6px; color:#fff; font-size:0.68rem; font-weight:600; padding:0.3rem 0.8rem; cursor:pointer; transition:background 0.12s; }
.sb-rsel-apply-group:hover { background:#4f46e5; }
.sb-rsel-search-row { margin-bottom:0.5rem; }
.sb-rsel-search { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.08); border-radius:6px; color:#e2e4ef; font-size:0.72rem; padding:0.35rem 0.6rem; outline:none; }
.sb-rsel-search:focus { border-color:rgba(99,102,241,0.4); }
.sb-rsel-search::placeholder { color:#7b80a0; }
.sb-rsel-preset { position:relative; padding-right:1.4rem; }
.sb-rsel-preset-count { font-size:0.6rem; opacity:0.6; margin-left:2px; }
.sb-rsel-preset-del { position:absolute; right:4px; top:50%; transform:translateY(-50%); font-size:0.6rem; opacity:0; transition:opacity 0.1s; color:#f87171; }
.sb-rsel-preset:hover .sb-rsel-preset-del { opacity:1; }
.sb-rsel-save-row { display:flex; align-items:center; gap:4px; }
.sb-rsel-save-input { background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:4px; color:#e2e4ef; font-size:0.7rem; padding:0.22rem 0.5rem; width:140px; outline:none; }
.sb-rsel-save-input:focus { border-color:#6366f1; }
.sb-rsel-save-input::placeholder { color:#7b80a0; }
.sb-rsel-save-btn { background:#6366f1; border:none; border-radius:4px; color:#fff; font-size:0.7rem; font-weight:700; padding:0.22rem 0.5rem; cursor:pointer; }
.sb-rsel-save-btn:disabled { opacity:0.4; cursor:default; }
.sb-res-sel-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sb-res-sel-grp-tag { font-size:0.55rem; color:#7b80a0; background:rgba(255,255,255,0.06); padding:1px 5px; border-radius:3px; flex-shrink:0; }
.sb-res-sel-wrap { display:flex; gap:1rem; align-items:flex-start; }
.sb-res-sel-col { flex:1; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:8px; overflow:hidden; max-height:300px; overflow-y:auto; }
.sb-res-sel-col::-webkit-scrollbar { width:3px; }
.sb-res-sel-col::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-res-sel-hdr { padding:0.4rem 0.6rem; background:#111422; font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; border-bottom:1px solid rgba(255,255,255,0.06); }
.sb-res-sel-item { display:flex; align-items:center; gap:0.45rem; padding:0.4rem 0.6rem; cursor:pointer; font-size:0.72rem; color:#e2e4ef; transition:background 0.1s; border-bottom:1px solid rgba(255,255,255,0.06); }
.sb-res-sel-item:hover { background:#1e2338; }
.sb-res-sel-active { color:#6366f1; }
.sb-res-sel-rm { margin-left:auto; color:#7b80a0; font-size:0.8rem; }
.sb-res-sel-group-hdr { padding:0.25rem 0.6rem; font-size:0.55rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:#6366f1; background:rgba(99,102,241,0.08); border-bottom:1px solid rgba(255,255,255,0.06); position:sticky; top:0; z-index:1; }
.sb-res-sel-arrow { font-size:1.2rem; color:#7b80a0; align-self:center; flex-shrink:0; }
.sb-avatar-xs { width:22px; height:22px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:0.55rem; font-weight:700; color:#fff; }
.sb-avatar-material { background:#1e293b; border:1.5px solid #475569; border-radius:6px; font-size:0.7rem; }
.sb-res-sel-cat-tag { font-size:0.55rem; color:#f59e0b; background:rgba(245,158,11,0.12); padding:1px 5px; border-radius:3px; flex-shrink:0; }
/* ── Resource type toggle ── */
.sb-res-type-toggle { display:flex; gap:1px; background:var(--sb-border); border-radius:5px; overflow:hidden; flex-shrink:0; }
.sb-res-type-toggle button { background:var(--sb-card); color:var(--sb-muted); border:none; font-size:0.62rem; font-weight:600; font-family:inherit; padding:2px 8px; cursor:pointer; display:flex; align-items:center; gap:3px; transition:all 0.12s; white-space:nowrap; }
.sb-res-type-toggle button.active { background:rgba(99,102,241,0.2); color:#a5b4fc; }
.sb-res-type-toggle button:hover { background:rgba(99,102,241,0.12); }
/* ── Transitions ── */
.sb-slide-left-enter-active, .sb-slide-left-leave-active { transition:width 0.18s, min-width 0.18s, opacity 0.18s; }
.sb-slide-left-enter-from, .sb-slide-left-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; }
.sb-slide-right-enter-active, .sb-slide-right-leave-active { transition:width 0.18s, min-width 0.18s, opacity 0.18s; }
.sb-slide-right-enter-from, .sb-slide-right-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; }
// ── Offer pool sidebar ──
.sb-offer-pool-col {
width: 320px; min-width: 320px; height: 100%;
border-left: 1px solid var(--sb-border);
overflow: hidden; flex-shrink: 0;
}
// ── Overload alert badge ──
.sb-overload-alert {
font-size: 0.68rem; font-weight: 600;
color: #fbbf24; background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 5px; padding: 2px 8px;
cursor: default; white-space: nowrap;
animation: sb-pulse-warn 2s infinite;
}
@keyframes sb-pulse-warn {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* GPS Settings Modal */
.sb-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; display:flex; align-items:center; justify-content:center; }
.sb-gps-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:12px; width:700px; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; }
.sb-gps-modal-hdr { padding:14px 20px; border-bottom:1px solid var(--sb-border); display:flex; align-items:center; justify-content:space-between; }
.sb-gps-modal-hdr h3 { font-size:15px; margin:0; }
.sb-gps-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:20px; }
.sb-gps-modal-body { padding:16px 20px; overflow-y:auto; }
.sb-gps-desc { color:var(--sb-muted); font-size:12px; margin-bottom:14px; }
.sb-gps-table { width:100%; border-collapse:collapse; }
.sb-gps-table th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--sb-muted); padding:6px 8px; border-bottom:1px solid var(--sb-border); }
.sb-gps-table td { padding:8px; border-bottom:1px solid var(--sb-border); font-size:13px; }
.sb-gps-select { width:100%; padding:5px 8px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:12px; }
.sb-gps-badge { padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
.sb-gps-online { background:rgba(16,185,129,0.15); color:#10b981; }
.sb-gps-offline { background:rgba(245,158,11,0.15); color:#f59e0b; }
.sb-gps-none { background:rgba(107,114,128,0.15); color:#6b7280; }
.sb-gps-coords { font-size:11px; color:var(--sb-muted); font-family:monospace; }
.sb-gps-footer { display:flex; align-items:center; justify-content:space-between; margin-top:12px; padding-top:12px; border-top:1px solid var(--sb-border); }
.sb-gps-info { font-size:11px; color:var(--sb-muted); }
.sb-gps-refresh { padding:5px 12px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; }
.sb-gps-input { width:100%; padding:4px 8px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-fg); font-size:12px; }
.sb-gps-add-row td { padding-top:8px; border-top:1px solid var(--sb-border); }
.sb-gps-add-btn { padding:4px 14px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; white-space:nowrap; }
.sb-gps-add-btn:disabled { opacity:.5; cursor:not-allowed; }
.sb-gps-actions { white-space:nowrap; }
.sb-gps-absence-btn { background:none; border:none; color:var(--sb-muted); font-size:14px; cursor:pointer; padding:2px 6px; border-radius:4px; }
.sb-gps-absence-btn:hover { color:#f59e0b; background:rgba(245,158,11,0.1); }
.sb-gps-react-btn { background:none; border:none; color:#22c55e; font-size:14px; cursor:pointer; padding:2px 6px; border-radius:4px; }
.sb-gps-react-btn:hover { background:rgba(34,197,94,0.1); }
.sb-gps-inactive-row { opacity:0.45; }
.sb-gps-inactive-row:hover { opacity:0.75; }
.sb-gps-toggle-row { margin-bottom:10px; }
.sb-gps-toggle-label { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--sb-muted); cursor:pointer; }
.sb-gps-toggle-label input[type="checkbox"] { accent-color:var(--sb-acc); }
.sb-gps-inactive-count { color:#f59e0b; font-weight:600; }
.sb-gps-absence-info { font-size:10px; color:#f59e0b; margin-top:2px; }
.sb-gps-absence-date { color:var(--sb-muted); margin-left:4px; }
.sb-gps-editable { cursor:pointer; border-bottom:1px dashed transparent; }
.sb-gps-editable:hover { border-bottom-color:var(--sb-muted); }
/* ── Absence Modal ── */
.sb-absence-modal { min-width:480px; max-width:560px; }
.sb-absence-form { display:flex; flex-direction:column; gap:14px; }
.sb-absence-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--sb-muted); margin-bottom:4px; display:block; }
.sb-absence-opt { font-weight:400; text-transform:none; color:rgba(255,255,255,0.25); }
.sb-absence-reasons { display:flex; flex-wrap:wrap; gap:6px; }
.sb-absence-reason-btn {
padding:5px 12px; border-radius:6px; font-size:12px; cursor:pointer;
background:var(--sb-bg); border:1px solid var(--sb-border); color:var(--sb-fg);
transition:all 0.15s;
}
.sb-absence-reason-btn:hover { border-color:var(--sb-acc); }
.sb-absence-reason-btn.active { background:rgba(99,102,241,0.15); border-color:var(--sb-acc); color:#a5b4fc; }
.sb-absence-dates { display:flex; gap:12px; }
.sb-absence-dates > div { flex:1; }
.sb-absence-jobs { background:rgba(239,68,68,0.06); border:1px solid rgba(239,68,68,0.15); border-radius:8px; padding:10px; }
.sb-absence-job-actions { display:flex; flex-direction:column; gap:6px; margin:6px 0; }
.sb-absence-radio { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--sb-fg); cursor:pointer; }
.sb-absence-radio input { accent-color:var(--sb-acc); }
.sb-absence-job-list { max-height:120px; overflow-y:auto; margin-top:6px; }
.sb-absence-job-item { display:flex; align-items:center; gap:6px; padding:3px 0; font-size:11px; color:var(--sb-fg); border-bottom:1px solid rgba(255,255,255,0.04); }
.sb-absence-job-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
.sb-absence-job-date { color:var(--sb-muted); margin-left:auto; font-size:10px; }
.sb-absence-no-jobs { font-size:12px; color:var(--sb-muted); font-style:italic; }
.sb-gps-edit-name { font-weight:600; }
.sb-gps-status-sel { min-width:100px; }
.sb-gps-phone { width:110px; font-variant-numeric:tabular-nums; }
/* ── Login Overlay ── */
.sb-login-overlay { position:fixed; inset:0; background:var(--sb-bg); z-index:9999; display:flex; align-items:center; justify-content:center; }
.sb-login-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:14px; padding:40px 36px; width:340px; display:flex; flex-direction:column; align-items:center; gap:16px; box-shadow:0 8px 40px rgba(0,0,0,0.6); }
.sb-login-logo { font-size:1.6rem; font-weight:800; color:var(--sb-acc); letter-spacing:-0.5px; }
.sb-login-sub { font-size:11px; color:var(--sb-muted); margin:0; text-align:center; }
.sb-login-sub a { color:var(--sb-acc); text-decoration:none; }
.sb-login-form { width:100%; display:flex; flex-direction:column; gap:10px; }
.sb-login-input { width:100%; padding:9px 12px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:7px; color:var(--sb-text); font-size:13px; outline:none; box-sizing:border-box; }
.sb-login-input:focus { border-color:var(--sb-acc); }
.sb-login-btn { width:100%; padding:10px; background:var(--sb-acc); border:none; border-radius:7px; color:#fff; font-size:13px; font-weight:600; cursor:pointer; transition:opacity 0.15s; }
.sb-login-btn:disabled { opacity:0.6; cursor:not-allowed; }
.sb-login-error { color:var(--sb-red); font-size:12px; margin:0; text-align:center; }
/* ── Schedule editor modal ── */
.sb-schedule-modal { width:520px; max-width:95vw; }
.sb-schedule-presets { display:flex; gap:6px; padding:0.5rem 1rem; flex-wrap:wrap; }
.sb-preset-btn { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:0.68rem; padding:4px 10px; cursor:pointer; transition:border-color 0.15s, background 0.15s; }
.sb-preset-btn:hover { border-color:var(--sb-acc); background:rgba(99,102,241,0.12); }
.sb-schedule-grid { padding:0.5rem 1rem; display:flex; flex-direction:column; gap:6px; }
.sb-schedule-day { display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:6px; background:rgba(255,255,255,0.03); transition:background 0.15s; }
.sb-schedule-day:hover { background:rgba(255,255,255,0.06); }
.sb-schedule-off { opacity:0.5; }
.sb-schedule-toggle { display:flex; align-items:center; gap:6px; cursor:pointer; min-width:60px; }
.sb-schedule-toggle input[type="checkbox"] { accent-color:var(--sb-acc); width:14px; height:14px; cursor:pointer; }
.sb-schedule-day-label { font-size:0.75rem; font-weight:600; color:var(--sb-text); }
.sb-schedule-time { background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:4px; color:var(--sb-text); font-size:0.72rem; padding:3px 6px; width:85px; outline:none; }
.sb-schedule-time:focus { border-color:var(--sb-acc); }
.sb-schedule-sep { color:var(--sb-muted); font-size:0.72rem; }
.sb-schedule-hours { color:var(--sb-muted); font-size:0.68rem; min-width:30px; text-align:right; }
.sb-schedule-off-label { color:var(--sb-muted); font-size:0.72rem; font-style:italic; }
// ── Extra shifts (on-call / garde) editor ──
.sb-extra-shifts-section { padding: 0.5rem 1rem; border-top: 1px solid var(--sb-border); }
.sb-extra-shifts-hdr {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;
span { font-size: 0.78rem; font-weight: 600; color: var(--sb-text); }
}
.sb-rp-btn-sm { font-size: 0.68rem !important; padding: 2px 8px !important; }
.sb-extra-shifts-empty { font-size: 0.72rem; color: var(--sb-muted); font-style: italic; padding: 4px 0; }
.sb-extra-shift-row {
display: flex; align-items: center; gap: 6px; padding: 5px 8px; margin-bottom: 4px;
border-radius: 6px; background: rgba(251, 191, 36, 0.06); border: 1px solid rgba(251, 191, 36, 0.12);
}
.sb-extra-shift-label {
background: var(--sb-bg); border: 1px solid var(--sb-border); border-radius: 4px;
color: var(--sb-text); font-size: 0.72rem; padding: 3px 6px; width: 70px; outline: none;
&:focus { border-color: #f59e0b; }
}
.sb-extra-shift-recurrence {
display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0;
}
.sb-extra-shift-pattern {
background: var(--sb-bg); border: 1px solid var(--sb-border); border-radius: 4px;
color: var(--sb-text); font-size: 0.68rem; padding: 3px 4px; min-width: 0; outline: none;
&:focus { border-color: #f59e0b; }
}
.sb-extra-shift-sep { color: var(--sb-muted); font-size: 0.68rem; white-space: nowrap; }
.sb-extra-shift-interval {
background: var(--sb-bg); border: 1px solid var(--sb-border); border-radius: 4px;
color: var(--sb-text); font-size: 0.72rem; padding: 3px 4px; width: 42px; text-align: center; outline: none;
&:focus { border-color: #f59e0b; }
}
.sb-extra-shift-del {
background: none; border: none; color: #f87171; cursor: pointer; font-size: 0.85rem;
padding: 2px 4px; border-radius: 3px; transition: background 0.12s;
&:hover { background: rgba(248, 113, 113, 0.15); }
}
/* ── Confirm unassign dialog ── */
.sb-confirm-dialog {
background: var(--sb-card); border-radius: 14px; padding: 24px;
max-width: 380px; width: 90%; text-align: center;
border: 1px solid var(--sb-border-acc);
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.sb-confirm-icon { font-size: 2rem; margin-bottom: 8px; }
.sb-confirm-title { font-size: 1rem; font-weight: 700; color: var(--sb-text); margin-bottom: 10px; }
.sb-confirm-body { font-size: 0.82rem; color: var(--sb-muted); line-height: 1.5; margin-bottom: 16px; }
.sb-confirm-tag {
display: inline-block; font-size: 0.68rem; font-weight: 600; padding: 1px 8px;
border-radius: 4px; margin: 4px 2px;
}
.sb-confirm-tag-pub { background: rgba(99,102,241,0.2); color: #818cf8; }
.sb-confirm-tag-ip { background: rgba(251,191,36,0.2); color: #fbbf24; }
.sb-confirm-tag-asg { background: rgba(59,130,246,0.2); color: #60a5fa; }
.sb-confirm-warn { font-size: 0.75rem; color: #f59e0b; font-style: italic; }
.sb-confirm-actions { display: flex; gap: 8px; justify-content: center; }
.sb-confirm-danger {
background: #dc2626 !important; color: white !important;
&:hover { background: #b91c1c !important; }
}

View File

@ -12,6 +12,8 @@ const routes = [
{ path: 'scan', name: 'tech-scan', component: () => import('src/modules/tech/pages/TechScanPage.vue') },
{ path: 'diagnostic', name: 'tech-diag', component: () => import('src/modules/tech/pages/TechDiagnosticPage.vue') },
{ path: 'more', name: 'tech-more', component: () => import('src/modules/tech/pages/TechMorePage.vue') },
// Magic link: /j/{jwt-token} — must be LAST to not capture static paths above
{ path: ':token', name: 'tech-magic', component: () => import('src/modules/tech/pages/TechTasksPage.vue'), props: true },
],
},
// Ops staff desktop view
@ -25,6 +27,10 @@ const routes = [
{ path: 'tickets', component: () => import('src/pages/TicketsPage.vue') },
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
{ path: 'rapports/revenus', component: () => import('src/pages/ReportRevenuPage.vue') },
{ path: 'rapports/ventes', component: () => import('src/pages/ReportVentesPage.vue') },
{ path: 'rapports/taxes', component: () => import('src/pages/ReportTaxesPage.vue') },
{ path: 'rapports/ar', component: () => import('src/pages/ReportARPage.vue') },
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },

View File

@ -1,385 +0,0 @@
# Architecture comparative — Legacy vs ERPNext
## 1. Schéma relationnel Legacy (gestionclient / MariaDB)
```
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENTS & ADRESSES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ account (15,303) delivery (17,114) │
│ ├─ id (PK) ├─ id (PK) │
│ ├─ customer_id (ex: DR2) ├─ account_id → account.id │
│ ├─ first_name, last_name ├─ name (label) │
│ ├─ company ├─ address1, city, zip │
│ ├─ email, tel_home, cell ├─ longitude, latitude │
│ ├─ address1, city, zip └─ contact, email │
│ ├─ status (1=actif,4=terminé) │
│ ├─ group_id (5=rés, 8=com) │
│ ├─ ppa, ppa_code, ppa_account ← PPA AccesD Desjardins │
│ ├─ stripe_id ← Paiement carte │
│ ├─ tax_group │
│ └─ language_id │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ SERVICES & FORFAITS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ service (66,879) product (833) │
│ ├─ id (PK) ├─ id (PK) │
│ ├─ delivery_id → delivery.id ├─ sku (ex: FTTH150I) │
│ ├─ device_id → device.id ├─ price │
│ ├─ product_id → product.id ├─ active │
│ ├─ status (1=actif, 0=inactif) ├─ category → product_cat.id │
│ ├─ date_orig, date_next_invoice ├─ download_speed (Kbps) │
│ ├─ payment_recurrence ├─ upload_speed (Kbps) │
│ ├─ hijack (prix spécial) ├─ quota_day, quota_night │
│ ├─ hijack_price, hijack_desc ├─ fibre_lineprofile │
│ ├─ radius_user, radius_pwd └─ fibre_serviceprofile │
│ ├─ date_end_contract │
│ └─ actif_until product_cat (34) │
│ ├─ id, name │
│ Note: service EST l'abonnement. ├─ num_compte (→ compta) │
│ Un client a N services actifs. └─ combo_dispo │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ FACTURATION & PAIEMENTS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ invoice (629,944) payment (540,522) │
│ ├─ id (PK) ├─ id (PK) │
│ ├─ account_id → account.id ├─ account_id → account.id │
│ ├─ date_orig (unix timestamp) ├─ date_orig │
│ ├─ total_amt, billed_amt ├─ amount │
│ ├─ billing_status (0=draft,1=ok) ├─ type (chèque,PPA,CC...) │
│ ├─ process_status ├─ reference │
│ ├─ due_date └─ applied_amt │
│ ├─ notes, template_message │
│ └─ ppa_charge payment_item (684,778) │
│ ├─ payment_id → payment.id │
│ invoice_item (1,859,260) ├─ invoice_id → invoice.id │
│ ├─ invoice_id → invoice.id └─ amount │
│ ├─ service_id → service.id │
│ ├─ sku → product.sku invoice_tax (par facture) │
│ ├─ quantity, unitary_price ├─ invoice_id │
│ └─ product_name ├─ tax_id → tax.id │
│ └─ amount │
│ tax (4) │
│ ├─ TPS 5%, TVQ 8.925/9.975% │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ COMPTABILITÉ │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ compta_comptes (48) compta_journal_ecriture (1.2M)│
│ ├─ id, num_compte (ex: 4020) ├─ id │
│ ├─ category (Actif/Passif/Rev) ├─ date │
│ └─ desc ├─ compta_id → comptes.id │
│ ├─ debit, credit │
│ compta_setup └─ desc │
│ └─ month_closed (fermeture) │
│ │
│ LOGIQUE: product_cat.num_compte lie chaque catégorie │
│ de produit à un compte de revenus (ex: cat 32 → 4020) │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ RÉSEAU & ÉQUIPEMENTS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ device (10,377) fibre (16,057) │
│ ├─ id (PK) ├─ id (PK) │
│ ├─ delivery_id → delivery.id ├─ service_id → service.id │
│ ├─ category, name ├─ terrain, rue, ville │
│ ├─ sn (serial number) ├─ frame, slot, port │
│ ├─ mac ├─ vlan_manage/internet/tel │
│ ├─ model, manufacturier ├─ ontid, sn │
│ ├─ manage (IP admin) ├─ info_connect (IP OLT) │
│ ├─ user, pass ├─ latitude, longitude │
│ └─ parent (device hiérarchie) └─ placemarks_id │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ TICKETS & SUPPORT │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ticket (242,618) ticket_msg (784,290) │
│ ├─ id (PK) ├─ id │
│ ├─ account_id → account.id ├─ ticket_id → ticket.id │
│ ├─ delivery_id → delivery.id ├─ msg (mediumtext, base64) │
│ ├─ subject └─ unread_csv │
│ ├─ dept_id → ticket_dept.id │
│ ├─ assign_to → staff.id ticket_dept (21) │
│ ├─ status (open/pending/closed) ├─ Support, Facturation │
│ ├─ due_date, priority ├─ Installation, Réparation │
│ ├─ wizard (JSON install data) └─ Vente, SysAdmin, etc. │
│ └─ wizard_fibre │
│ │
│ bon_travail (14,472) staff (155) │
│ ├─ account_id, tech1, tech2 ├─ username, password │
│ ├─ heure_arrive/depart ├─ first_name, last_name │
│ └─ subtotal, tps, tvq, total ├─ email, cell, ext │
│ ├─ rights (serialized PHP) │
│ └─ dept_list │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 2. Schéma ERPNext cible
```
┌─────────────────────────────────────────────────────────────────────┐
│ CLIENTS & ADRESSES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Customer Address │
│ ├─ name (PK, auto) ├─ name (PK, auto) │
│ ├─ customer_name ├─ address_title │
│ ├─ customer_type (Ind/Company) ├─ address_line1, city │
│ ├─ customer_group ├─ state, pincode, country │
│ ├─ territory (Canada) ├─ latitude, longitude │
│ ├─ *legacy_account_id ← NEW ├─ links[] → Customer │
│ ├─ *legacy_customer_id ← NEW └─ address_type │
│ ├─ *ppa_enabled ← NEW │
│ ├─ *stripe_id ← NEW Contact │
│ └─ disabled ├─ first_name, last_name │
│ ├─ email_ids[], phone_nos[] │
│ Note: Customer est SÉPARÉ de └─ links[] → Customer │
│ Address et Contact (pattern ERPNext) │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ SERVICES & FORFAITS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Item (type=Service) Subscription │
│ ├─ item_code (= legacy SKU) ├─ party_type = Customer │
│ ├─ item_name ├─ party → Customer │
│ ├─ item_group → Item Group ├─ plans[] → Subscription Plan│
│ ├─ standard_rate ├─ start_date, end_date │
│ ├─ *legacy_product_id ← NEW ├─ status (Active/Cancelled) │
│ ├─ *download_speed ← NEW ├─ generate_invoice_at │
│ ├─ *upload_speed ← NEW ├─ sales_tax_template │
│ ├─ *quota_day_gb ← NEW ├─ *radius_user ← NEW │
│ ├─ *quota_night_gb ← NEW ├─ *radius_pwd ← NEW │
│ ├─ *fibre_lineprofile ← NEW └─ *legacy_service_id ← NEW │
│ └─ *fibre_serviceprofile ← NEW │
│ Subscription Plan │
│ Item Group (34) ├─ plan_name │
│ ├─ Services/ ├─ item → Item │
│ │ ├─ Mensualités fibre ├─ cost (prix fixe) │
│ │ ├─ Mensualités sans fil ├─ billing_interval = Month │
│ │ ├─ Téléphonie └─ currency = CAD │
│ │ └─ ...16 sous-groupes │
│ ├─ Products/ │
│ │ ├─ Équipement fibre Note: hijack_price du legacy │
│ │ ├─ Quincaillerie → additional_discount sur │
│ │ └─ ...8 sous-groupes la Subscription │
│ └─ Frais et ajustements/ │
│ └─ ...10 sous-groupes │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ FACTURATION & PAIEMENTS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Sales Invoice Payment Entry │
│ ├─ customer → Customer ├─ party → Customer │
│ ├─ posting_date ├─ posting_date │
│ ├─ items[] (child table) ├─ paid_amount │
│ │ ├─ item_code → Item ├─ references[] (child) │
│ │ ├─ qty, rate │ ├─ reference_name → SINV │
│ │ └─ amount │ └─ allocated_amount │
│ ├─ taxes[] → Tax Template ├─ mode_of_payment │
│ ├─ grand_total └─ reference_no │
│ ├─ status (Draft/Submitted/Paid) │
│ └─ is_return (pour crédits) Payment Reconciliation │
│ └─ auto-match paiements │
│ Sales Taxes Template │
│ ├─ TPS 5% Journal Entry │
│ └─ TVQ 9.975% └─ Opening balance │
│ │
│ NATIF: Subscription génère auto les Sales Invoice chaque mois │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ RÉSEAU & ÉQUIPEMENTS (FSM) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Service Location (custom) Service Equipment (custom) │
│ ├─ name (LOC-#####) ├─ name (EQP-#####) │
│ ├─ customer → Customer ├─ location → Service Loc │
│ ├─ address → Address ├─ serial_number (SN) │
│ ├─ gps_lat, gps_lon ├─ mac_address │
│ ├─ connection_type (FTTH/Wireless) ├─ equipment_type │
│ ├─ olt_ip, olt_frame/slot/port ├─ status │
│ └─ vlan_internet/tele/manage └─ model, manufacturer │
│ │
│ Dispatch Job (custom) Dispatch Technician (custom) │
│ ├─ customer, service_location ├─ full_name, phone │
│ ├─ job_type (Install/Repair/...) ├─ traccar_device_id │
│ ├─ assigned_tech └─ status │
│ ├─ scheduled_date/time │
│ └─ equipment[], checklist[] │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ TICKETS & SUPPORT │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Issue (natif ERPNext HD) Employee (natif ERPNext HR) │
│ ├─ subject ├─ employee_name │
│ ├─ customer → Customer ├─ department │
│ ├─ issue_type → ticket_dept ├─ user_id → User │
│ ├─ priority └─ cell_phone, email │
│ ├─ status (Open/Replied/Closed) │
│ ├─ description (HTML) Note: staff.rights (PHP) │
│ └─ resolution_details → Rôles ERPNext │
│ │
│ Issue peut contenir des Comments (Communication) │
│ → ticket_msg.msg migré ici (base64 images → fichiers) │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 3. Mapping table par table
| Legacy Table | Records | ERPNext DocType | Custom Fields | Notes |
|---|---|---|---|---|
| account | 15,303 | **Customer** | legacy_account_id, legacy_customer_id, ppa_enabled, stripe_id | status 1→enabled, 4→disabled |
| account (contact) | — | **Contact** | — | email, tel, cell séparés |
| delivery | 17,114 | **Address** + **Service Location** | latitude, longitude | GPS dans Address natif |
| product | 833 | **Item** | legacy_product_id, download/upload_speed, quota_gb, fibre_profiles | ✅ FAIT |
| product_cat | 34 | **Item Group** | — | ✅ FAIT |
| service | 66,879 | **Subscription** | radius_user, radius_pwd, legacy_service_id | hijack → discount |
| invoice | 629,944 | **Sales Invoice** | — | Opening balance pour historique |
| invoice_item | 1,859,260 | **Sales Invoice Item** | — | Idem |
| payment | 540,522 | **Payment Entry** | — | Opening balance pour historique |
| payment_item | 684,778 | **Payment Entry Reference** | — | Idem |
| tax | 4 | **Sales Taxes Template** | — | TPS 5% + TVQ 9.975% |
| compta_comptes | 48 | **Account** (Chart) | — | Map → plan comptable canadien existant |
| compta_journal | 1.2M | **Journal Entry** | — | Opening balance seulement |
| device | 10,377 | **Service Equipment** | — | FSM custom doctype |
| fibre | 16,057 | **Service Location** (champs OLT) | — | FSM custom doctype |
| ticket | 242,618 | **Issue** | — | AI knowledge base |
| ticket_msg | 784,290 | **Communication** | — | base64 → File uploads |
| bon_travail | 14,472 | **Dispatch Job** | — | FSM custom doctype |
| staff | 155 | **Employee** | — | rights → Roles |
| ticket_dept | 21 | **Issue Type** | — | Support, Facturation, etc. |
---
## 4. Différences d'architecture clés
### Ce qui change fondamentalement
| Aspect | Legacy | ERPNext |
|---|---|---|
| **Nommage** | ID numérique auto-inc | Naming series (CUST-.YYYY.-, etc.) |
| **Client/Adresse** | Tout dans `account` | Séparé : Customer + Address + Contact |
| **Abonnement** | `service` = ligne active | `Subscription` génère auto les factures |
| **Prix spécial** | `hijack_price` sur service | `additional_discount` sur Subscription ou Pricing Rule |
| **Taxes** | Calculées dans PHP | Template TPS+TVQ auto-appliqué |
| **Comptabilité** | Écritures manuelles PHP | GL Entry auto depuis Sales Invoice |
| **Permissions** | `staff.rights` (PHP serialize) | Rôles + DocType permissions |
| **Fichiers** | Base64 dans mediumtext | File doctype → /files/ directory |
| **RADIUS** | Champs sur `service` | Custom fields sur `Subscription` |
| **Dates** | Unix timestamp (bigint) | ISO date (YYYY-MM-DD) |
### Ce qui reste pareil
- SKU des produits
- Structure client → N adresses → N services
- Facturation mensuelle récurrente
- Système de tickets/support
---
## 5. Flux opérationnels comparés
### Facturation mensuelle
**Legacy :**
```
CRON → task_charge_recurrent.php
→ SELECT services WHERE date_next_invoice < NOW()
→ INSERT invoice + invoice_items
→ UPDATE service.date_next_invoice + 1 month
→ task_generate_statement.php (PDF)
→ task_mail.php (envoi email)
```
**ERPNext :**
```
Scheduler → Subscription.process()
→ Auto-génère Sales Invoice (draft ou submitted)
→ Taxes auto-appliquées via template
→ Email via Print Format + Email Account
→ Payment Entry (PPA/Stripe) auto-réconcilié
```
### Nouveau client
**Legacy :**
```
account_add.php → INSERT account
delivery_add.php → INSERT delivery
service_add.php → INSERT service + UPDATE radius
invoice_add.php → première facture manuelle
```
**ERPNext :**
```
Customer (create) → Address (link) → Contact (link)
Subscription (create, plans=[Item]) → auto-generates invoices
Webhook → n8n → FreeRADIUS (create user)
```
### Ticket support
**Legacy :**
```
ticket_new.php → INSERT ticket
ticket_view.php → INSERT ticket_msg (avec base64 images)
Assignation manuelle (assign_to → staff.id)
```
**ERPNext :**
```
Issue (create) → Communication (messages)
Assignment Rule (auto-assignation par type)
SLA tracking natif
File upload pour images (pas de base64)
```
---
## 6. État de la migration
| Phase | Statut | Détail |
|---|---|---|
| 1a. Item Groups | ✅ Fait | 34 groupes, 3 parents |
| 1b. Items | ✅ Fait | 833 items, vitesses, quotas, legacy IDs |
| 1c. Custom Fields | ✅ Fait | Item (8), Customer (4), Subscription (3) |
| 2. Customers + Contacts + Addresses | ✅ Fait | 6,667 Customers + ~6,600 Contacts + ~6,700 Addresses via direct PG (migrate_direct.py, ~30s) |
| 3. Tax Templates + Subscription Plans | ✅ Fait | QC TPS 5% + TVQ 9.975%, 92 Subscription Plans |
| 4. Subscriptions | ✅ Fait | 21,876 Subscriptions avec RADIUS data. **Scheduler PAUSED** |
| 5. Opening Balance | ⏳ Planifié | Soldes calculés legacy — prochaine étape |
| 6. Tickets → Issues | ⏳ Phase 2 | 242K tickets, extraction base64 |
| 7. Sync bidirectionnelle | ⏳ Phase 2 | n8n ETL nightly |
> **⚠ ATTENTION : Le scheduler ERPNext est PAUSED. Ne pas réactiver sans instruction explicite — cela déclencherait la facturation automatique des 21,876 abonnements.**

View File

@ -1,310 +1,297 @@
# Gigafibre FSM Architecture
# Gigafibre FSM -- Architecture
## Service Map
## 1. Service Map
```
┌──────────────────┐
│ Authentik SSO │
│ id.gigafibre.ca
│ auth.targo.ca
└────────┬─────────┘
│ OIDC / Proxy Auth
│ forwardAuth
┌──────────────────┐
│ Traefik │
│ Reverse Proxy
+ Let's Encrypt │
│ :80 / :443
│ Let's Encrypt
└──┬───┬───┬───┬───┘
│ │ │ │
┌───────────────────┘ │ │ └──────────────────┐
┌──────────────────┘ │ │ └───────────────────┐
▼ ▼ ▼ ▼
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Ops App │ │ ERPNext v16 │ │ Targo-Hub │
│ erp.../ops/ │ │ erp.gigafibre.ca│ │ msg.gigafibre.ca │
│ (nginx+Quasar) │ │ (Frappe/Python) │ │ (Node.js) │
└───────┬─────────┘ └───────┬──────────┘ └──┬──────┬───────┘
│ │ │ │
│ /api/* proxy │ │ │
│ (token injected) │ │ │
└─────────────────────┘ │ │
│ │
┌──────────────────────────────────────┘ │
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ GenieACS NBI │ │ Twilio API │
│ 10.5.2.115:7557 │ │ SMS + Voice │
│ (TR-069 ACS) │ └──────────────────┘
└───────┬──────────┘
┌────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Ops App │ │ ERPNext v16 │ │ targo-hub │
│ /ops/ (nginx) │ │ erp.gigafibre.ca│ │ msg.gigafibre.ca│
└───────┬────────┘ └───────┬──────────┘ └──┬───┬───┬──────┘
│ /api/* proxy │ │ │ │
│ (token injected) │ │ │ │
└───────────────────┘ │ │ │
│ │ │
┌────────────────────────────────────┘ │ └──────┐
▼ ▼ ▼
┌──────────────────┐ ┌────────────────┐ ┌─────────┐
│ GenieACS NBI │ │ Twilio API │ │ Stripe │
│ 10.5.2.115:7557 │ │ SMS + Voice │ │ Payments│
└───────┬──────────┘ └────────────────┘ └─────────┘
│ CWMP (TR-069)
┌──────────────────┐ ┌──────────────────┐
│ CPE / ONT │ │ Oktopus (USP)
│ TP-Link XX230v │ │ oss.gigafibre.ca
│ Raisecom HT803G │ │ TR-369 (future)
│ CPE / ONT │◀──────│ modem-bridge │
│ TP-Link XX230v │ HTTPS │ :3301 (internal)
│ Raisecom HT803G │ │ Playwright
└──────────────────┘ └──────────────────┘
```
## Host: 96.125.196.67 (hubdocker)
Docker networks: `proxy` (Traefik-facing services), `erpnext` (ERPNext cluster + targo-hub).
All services run on a single Docker host. DNS records `erp.gigafibre.ca`,
`oss.gigafibre.ca`, `msg.gigafibre.ca` all resolve to this IP.
---
### Docker Containers
## 2. Docker Containers
Host: `96.125.196.67` (Proxmox VM, Ubuntu 24.04). All services on one Docker host.
| Container | Image | Port | Network | Purpose |
|-----------|-------|------|---------|---------|
| ops-frontend | nginx:alpine | 80 | proxy | Ops SPA + ERPNext API proxy |
| targo-hub | node:20-alpine | 3300 | proxy, erpnext | SSE relay, SMS, GenieACS proxy |
| erpnext-frontend | frappe/erpnext | 8080 | erpnext | ERPNext web + API |
| targo-hub | node:20-alpine | 3300 | proxy, erpnext | API gateway: SSE, SMS, devices, dispatch |
| erpnext-frontend-1 | frappe/erpnext | 8080 | erpnext | ERPNext web + API |
| erpnext-backend | frappe/erpnext | 8000 | erpnext | Frappe worker |
| erpnext-db-1 | postgres:16 | 5432 | erpnext | ERPNext database |
| erpnext-db-1 | postgres:16 | 5432 | erpnext | ERPNext + targo_cache DBs |
| modem-bridge | node:20-slim+Chromium | 3301 | proxy | Headless browser for ONU web GUI |
| oktopus-acs-1 | oktopusp/acs | 9292 | oktopus | USP/TR-369 controller |
| oktopus-mongo-1 | mongo:7 | 27017 | oktopus | Oktopus datastore |
| fn-routr | fonoster/routr-one | | fonoster | VoIP SIP routing |
| fn-asterisk | fonoster/asterisk | | fonoster | PBX media server |
| fn-postgres | postgres:16 | | fonoster | Fonoster DB |
| apps-targo-db-1 | postgres | — | apps | Targo-hub database |
| authentik-* | goauthentik | — | authentik | SSO provider |
| fn-routr | fonoster/routr-one | -- | fonoster | VoIP SIP routing |
| fn-asterisk | fonoster/asterisk | -- | fonoster | PBX media server |
| fn-postgres | postgres:16 | -- | fonoster | Fonoster DB |
| authentik-* | goauthentik | -- | authentik | SSO provider (staff + client) |
| apps-www-gigafibre-1 | nginx | -- | proxy | Marketing website |
---
## Ops App (Quasar v2 + Vite)
## 3. Ops App (Quasar v2 / Vue 3 / Vite)
**Served from:** `/opt/ops-app/` via `ops-frontend` (nginx)
**URL:** `https://erp.gigafibre.ca/ops/`
### Request Flow
```
Browser Traefik ops-frontend (nginx) ERPNext
│ │ │ │
│── GET /ops/... ──────▶ strip /ops ────────────▶ try_files ──▶ SPA │
│ │ │ │
│── GET /ops/api/... ──▶ strip /ops ────────────▶ /api/* proxy ─────▶
│ │ │ + Auth token │
│ │ │ injected │
```
Served from `/opt/ops-app/` via `ops-frontend` nginx at `erp.gigafibre.ca/ops/`.
### Directory Structure
```
apps/ops/src/
├── api/ # API clients
│ ├── auth.js # Token auth + session check
│ ├── erp.js # ERPNext CRUD (listDocs, getDoc, updateDoc...)
│ ├── dispatch.js # Dispatch jobs/techs/tags CRUD
│ ├── sms.js # SMS via n8n webhook
│ ├── traccar.js # GPS tracking
│ ├── ocr.js # Ollama Vision OCR
│ └── service-request.js # Service booking (future)
├── components/
│ ├── shared/
│ │ ├── DetailModal.vue # Right-panel detail viewer
│ │ ├── InlineField.vue # Odoo-style inline editing
│ │ ├── TagEditor.vue # Tag selector with levels
│ │ └── detail-sections/ # Per-doctype detail views
│ │ ├── EquipmentDetail # ONT diagnostics + mesh topology
│ │ ├── IssueDetail # Ticket view
│ │ ├── InvoiceDetail # Invoice + payment status
│ │ ├── PaymentDetail # Payment entry
│ │ └── SubscriptionDetail# Subscription management
│ └── customer/
│ ├── CustomerHeader.vue # Name, status, actions
│ ├── ContactCard.vue # Phone/email
│ ├── CustomerInfoCard.vue # Flags, notes, tax category
│ ├── ChatterPanel.vue # SMS/call thread
│ ├── ComposeBar.vue # Message input
│ ├── SmsThread.vue # SMS history
│ └── PhoneModal.vue # Twilio voice/SIP
├── composables/ # 24 Vue composables
│ ├── useDeviceStatus.js # GenieACS device lookup + cache
│ ├── useSSE.js # Server-sent events (targo-hub)
│ ├── useInlineEdit.js # Inline field save logic
│ ├── usePhone.js # 3CX/Twilio voice config
│ └── ... # Dispatch, map, formatting, etc.
├── modules/
│ └── dispatch/components/ # Dispatch-specific UI (timeline, calendar, map)
├── pages/ # 10 routed pages
│ ├── DashboardPage # KPI overview
│ ├── ClientsPage # Search-first customer list
│ ├── ClientDetailPage # Customer detail (subs, invoices, tickets)
│ ├── TicketsPage # Issue/ticket list
│ ├── DispatchPage # Scheduling + timeline (~1600 LOC)
│ ├── EquipePage # Equipment fleet
│ ├── OcrPage # Invoice OCR
│ ├── TelephonyPage # VoIP management
│ ├── RapportsPage # Reports (stub)
│ └── SettingsPage # Config: API, SMS, 3CX
├── stores/ # Pinia: dispatch, auth
├── config/ # erpnext.js, nav.js, ticket-config.js
└── router/index.js
```
| Directory | Files | Content |
|-----------|-------|---------|
| `api/` | 10 | ERPNext CRUD, dispatch, offers, presets, SMS, traccar, OCR, auth, reports |
| `components/` | 23 .vue | customer/, dispatch/, shared/, layout/ |
| `composables/` | 41 | Domain-specific reactive logic (see below) |
| `modules/dispatch/components/` | 13 | Timeline, calendar, map, modals, context menus |
| `pages/` | 16 | Routed page views |
| `stores/` | 2 | Pinia: auth, dispatch |
| `config/` | 8 | erpnext, nav, dispatch, ticket-config, hub, table-columns |
### Pages (16)
DashboardPage, ClientsPage, ClientDetailPage, TicketsPage, **DispatchPage**, EquipePage, NetworkPage, TelephonyPage, RapportsPage, SettingsPage, OcrPage, AgentFlowsPage, ReportARPage, ReportRevenuPage, ReportTaxesPage, ReportVentesPage
### Composables (41) -- grouped by domain
| Domain | Composables |
|--------|-------------|
| **Scheduling** | useScheduler, useDragDrop, useBottomPanel, usePeriodNavigation, useAutoDispatch, useBestTech, useJobOffers, useAbsenceResize, useSelection, useUndo, useContextMenus, useTechManagement |
| **Map / GPS** | useMap, useGpsTracking, useAddressSearch |
| **Phone / SMS** | usePhone, useConversations |
| **Data** | useClientData, useDeviceStatus, useSSE, useInlineEdit, useEquipmentActions, usePaymentActions, useSubscriptionActions, useSubscriptionGroups, useLegacySync, useCustomerNotes, usePermissions, usePermissionMatrix, useUserGroups |
| **UI** | useHelpers, useFormatters, useStatusClasses, useDetailModal, useResourceFilter, useTagManagement, useUnifiedCreate, useScanner, useWifiDiagnostic, useWizardCatalog, useWizardPublish |
---
## Targo-Hub (Node.js)
## 4. Targo-Hub (Node.js)
**Container:** `targo-hub` | **URL:** `msg.gigafibre.ca` | **Port:** 3300
Container `targo-hub` | `msg.gigafibre.ca` | Port 3300 | 40 modules in `lib/`
### Module Structure
### Modules (top 15 by size)
```
services/targo-hub/
├── server.js # 100 LOC — HTTP router, CORS, SSE setup, server.listen
├── lib/
│ ├── config.js # 42 LOC — All env var config (ERP, Twilio, 3CX, GenieACS, SIP)
│ ├── helpers.js # 129 LOC — log, json, parseBody, httpRequest, erpFetch, nbiRequest
│ ├── sse.js # 57 LOC — SSE client registry, broadcast, broadcastAll
│ ├── twilio.js # 191 LOC — SMS in/out/status, voice token/TwiML/status, SIP config
│ ├── pbx.js # 159 LOC — 3CX webhook handler + call log poller
│ ├── telephony.js # 118 LOC — Fonoster/Routr PostgreSQL CRUD (/telephony/*)
│ ├── devices.js # 417 LOC — GenieACS proxy, summarizeDevice, hosts, ACS config export
│ └── provision.js # 163 LOC — OLT pre-auth, on-scan, equipment swap
├── package.json
└── docker-compose.yml
```
| Module | Lines | Purpose |
|--------|------:|---------|
| payments.js | 1374 | Stripe checkout, PPA cron, webhooks |
| network-intel.js | 1221 | Network topology, outage correlation |
| ai.js | 719 | Gemini AI integration (tool-calling) |
| acceptance.js | 672 | Service acceptance workflows |
| outage-monitor.js | 601 | Uptime-Kuma alerts |
| reports.js | 572 | Analytics and report generation |
| tech-mobile.js | 562 | Lightweight mobile page for technicians |
| devices.js | 551 | GenieACS proxy, device summary, poller |
| contracts.js | 548 | Contract generation |
| oktopus.js | 545 | TR-369/USP controller proxy |
| agent.js | 529 | AI SMS agent with tool-calling |
| conversation.js | 499 | Conversation persistence |
| voice-agent.js | 457 | Inbound voice IVR + WebSocket media |
| olt-snmp.js | 419 | OLT SNMP polling, ONU state tracking |
| checkout.js | 399 | Customer checkout / catalog API |
### Endpoints
Plus 25 smaller modules: server, twilio, pbx, dispatch, provision, auth, telephony, ical, modem-bridge, config, helpers, sse, email, otp, magic-link, traccar, vision, device-extractors, device-hosts, oktopus-mqtt, tech-absence-sms, address-search, email-templates, project-templates.
### Endpoints -- grouped by domain
**SSE / Real-time**
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/sse?topics=customer:X` | Server-Sent Events stream |
| GET | `/sse?topics=...` | SSE stream (customer, conversations, sms-incoming) |
| POST | `/broadcast` | Push event to SSE clients |
**SMS / Voice / Telephony**
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/send/sms` | Send SMS via Twilio |
| POST | `/webhook/twilio/sms-incoming` | Receive inbound SMS |
| POST | `/webhook/twilio/sms-status` | SMS delivery status |
| POST | `/webhook/twilio/sms-incoming` | Inbound SMS |
| POST | `/webhook/twilio/sms-status` | Delivery status |
| GET | `/voice/token` | Twilio voice JWT |
| GET | `/devices/lookup?serial=X` | Find CPE in GenieACS |
| POST | `/voice/twiml`, `/voice/status` | TwiML + call status |
| POST | `/voice/inbound`, `/voice/gather`, `/voice/connect-agent` | IVR voice agent |
| WS | `/voice/ws` | Twilio Media Streams (WebSocket) |
| POST | `/webhook/3cx/call-event` | 3CX call events |
| * | `/telephony/*` | Fonoster/Routr SIP CRUD |
**Devices / Network**
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/devices/lookup?serial=X` | GenieACS device search (3 fallback strategies) |
| GET | `/devices/summary` | Fleet statistics |
| GET | `/devices/:id/hosts` | Connected clients + mesh mapping |
| POST | `/devices/:id/tasks` | Send task (reboot, refresh) |
| GET | `/health` | Health check |
| * | `/acs/*` | ACS config export |
| * | `/modem/*` | Proxy to modem-bridge |
| * | `/olt/*` | OLT SNMP stats, ONU lookup, registration |
| * | `/oktopus/*` | TR-369 USP proxy |
| * | `/network/*` | Network intelligence / topology |
### GenieACS Device Lookup (3 fallbacks)
**Dispatch / Scheduling** -- `/dispatch/*` (CRUD, iCal token + feed)
```
serial = "TPLGC4160688"
1. Exact: DeviceID.SerialNumber._value == serial → match?
2. GPON: Device.Optical...GponSn._value =~ /C4160688$/ → match?
3. ID: _id =~ /TPLGC4160688/ → match?
```
**Auth / Customer / Payments** -- `/auth/*` (RBAC), `/magic-link/*`, `/api/checkout|catalog|otp|order|address` (customer flow), `/payments/*` + `/webhook/stripe` (Stripe + PPA cron), `/accept/*`, `/contract/*`
### Hosts Endpoint Flow
**AI / Vision** -- `/ai/*` (Gemini), `/agent/*` (SMS agent), `/vision/barcodes|equipment`, `/conversations/*`
```
GET /devices/:id/hosts?refresh
├── Task 1: getParameterValues → Device.Hosts.Host.{1-20}.*
│ (connection_request, timeout=15s)
├── Task 2: getParameterValues → Device.WiFi.MultiAP.APDevice.{1-3}
│ .Radio.{1-2}.AP.{1-4}.AssociatedDevice.{1-8}.*
│ (timeout=10s)
├── Read cached data from GenieACS MongoDB
├── Build clientNodeMap: MAC → {nodeName, band, signal, speed}
└── Return { total, hosts[{name, ip, mac, band, signal, attachedNode, lease}] }
```
**Other** -- `/traccar/*` (GPS), `/provision/*` (OLT), `/reports/*`, `/t/:token` (tech mobile), `/webhook/kuma` (outage), `/health`
---
## GenieACS Provision (XX230v)
## 5. Modem-Bridge (Playwright/Chromium)
**Provision:** `xx230v_inform` / `XX230v_inform_TpLink_fix`
Internal-only service on port 3301. Provides REST access to TP-Link ONU web GUIs via headless Chromium. Required because XX230v firmware uses GDPR-encrypted communication (RSA key exchange, AES session, encrypted JSON on `/cgi_gdpr?9`). Playwright lets the modem's own JavaScript handle all crypto natively rather than re-implementing the protocol.
TP-Link requires `clear()` + `commit()` before re-reading to avoid error 9805/9806:
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/session/login` | Authenticate to modem (ip, user, pass) |
| GET | `/session/list` | List active browser sessions |
| DELETE | `/session/:ip` | Close session |
| GET | `/modem/:ip/status` | Device status summary |
| GET | `/modem/:ip/dm/:oid` | Data manager GET |
| GET | `/modem/:ip/screenshot` | PNG screenshot (debug) |
```javascript
clear("Device.Hosts.Host", Date.now());
clear("Device.WiFi.MultiAP.APDevice.*.Radio.*.AP.*.AssociatedDevice", Date.now());
commit();
// Re-read in same provision cycle
declare("Device.Hosts.Host.*.HostName", {value: now});
declare("Device.WiFi.MultiAP.APDevice.*.Radio.*.AP.*.AssociatedDevice.*.MACAddress", {value: now});
```
Configures: TR-069 credentials, remote access (HTTPS on 443), VoIP digit map,
NTP, superadmin password. Reads ~100 diagnostic parameters per inform.
Constraints: ~450MB disk (node + Chromium), ~80MB idle + 150MB per session, 512MB Docker limit, sessions auto-expire after 5 min. Bearer token auth, private IP restriction, read-only operations only.
---
## Data Model (ERPNext Doctypes)
## 6. Secondary Apps
| App | Stack | URL | Purpose |
|-----|-------|-----|---------|
| **Field App** (`apps/field/`) | Vue 3 / Quasar PWA | -- | Tech mobile: daily tasks, barcode scanner, device diagnostics, offline sync |
| **Client Portal** (`apps/client/`) | Vue 3 / Quasar PWA | client.gigafibre.ca | Self-service: invoices, subscriptions, tickets, catalog. Auth via Authentik |
| **Website** (`apps/website/`) | React / Vite / Tailwind | www.gigafibre.ca | Marketing: products, eligibility check, online ordering, FAQ |
---
## 7. Data Model (ERPNext Doctypes)
```
Customer (ERPNext native)
└─ Service Location (LOC-#####)
├─ Address + GPS coordinates
├─ Connection type (FTTH/FTTB/Cable/DSL)
├─ OLT port, VLAN, network config
├─ Service Equipment (EQP-#####)
│ ├─ Type: ONT / Router / Switch / AP / Decodeur
│ ├─ Serial number + MAC address
│ ├─ Status: Active / Inactive / En stock / Defectueux / Retourne
│ ├─ Network config (IP, firmware, credentials)
│ └─ Move history (Equipment Move Log)
└─ Service Subscription (SUB-#####)
├─ Plan: Internet / IPTV / VoIP / Bundle
├─ Billing: price, frequency, Stripe integration
└─ Status: pending → active → suspended → cancelled
Customer
├── Service Location (LOC-#####)
│ ├── Address + GPS coordinates
│ ├── OLT port, VLAN, network config
│ ├── Service Equipment (EQP-#####)
│ │ ├── Type: ONT / Router / Switch / AP / Decodeur
│ │ ├── Serial + MAC + manage IP + firmware
│ │ └── Status: Active / Inactive / En stock / Defectueux / Retourne
│ └── Service Subscription (SUB-#####)
│ ├── Plan: Internet / IPTV / VoIP / Bundle
│ └── Status: pending → active → suspended → cancelled
└── Sales Invoice → Payment Entry
Dispatch Job
├─ Customer + Service Location
├─ Assigned tech + assistants + tags
├─ Schedule: date, time, duration
├─ Equipment Items / Materials Used
└─ GPS position (Traccar)
├── Customer + Service Location
├── Dispatch Technician (assigned + assistants)
├── Dispatch Tag Link (tag + level 1-5 + required flag)
├── Schedule: date, time, duration, recurrence (RRULE)
└── Equipment Items / Materials Used
Dispatch Technician
├── weekly_schedule (JSON), extra_shifts (JSON)
└── Dispatch Tag Link (skill level per tag)
```
Custom fields on standard doctypes: Customer (`stripe_id`, `is_commercial`, `ppa_enabled`), Subscription (`actual_price`, `service_location`), Issue (linked dispatch jobs), Sales Invoice (QR code, portal link).
---
## External Services
## 8. External Integrations
| Service | URL | Used By | Purpose |
|---------|-----|---------|---------|
| ERPNext | erp.gigafibre.ca | Ops App, targo-hub | Business data |
| GenieACS | 10.5.2.115:7557 | targo-hub | CPE management (TR-069) |
| Twilio | api.twilio.com | targo-hub | SMS + Voice |
| Traccar | tracker.targointernet.com:8082 | Ops App | GPS fleet tracking |
| n8n | n8n.gigafibre.ca | Ops App | SMS workflow |
| Authentik | id.gigafibre.ca | Traefik | SSO (staff) |
| Authentik Client | auth.targo.ca | Portal | SSO (customers) |
| Mapbox | api.mapbox.com | Ops App | Maps + routing |
| 3CX PBX | targopbx.3cx.ca | targo-hub | Call logging |
| Service | Purpose | Connection |
|---------|---------|------------|
| GenieACS (10.5.2.115) | TR-069 CPE management | NBI REST, LAN, no auth |
| Twilio | SMS + voice | REST API, Basic auth |
| Stripe | Payments, checkout | API + webhooks |
| Mapbox | Maps, geocoding, routing | JS SDK + Directions API |
| Gemini AI | OCR, SMS agent, AI tools | REST, Bearer token |
| Traccar | GPS tech tracking | REST API, Basic auth |
| 3CX PBX | Call history | REST API poller (30s) |
| Fonoster/Routr | SIP trunking | Direct PostgreSQL |
| Authentik | SSO (staff + client) | Traefik forwardAuth + API |
| n8n | Workflow automation | HTTP webhooks |
| Uptime-Kuma | Outage monitoring | Webhook to targo-hub |
| Cloudflare | DNS for gigafibre.ca | REST API |
---
## Data Flow: Customer → Device Diagnostics
## 9. Data Flows
### Device Diagnostic
```
User clicks equipment chip in ClientDetailPage
EquipmentDetail.vue
├── fetchStatus([{serial_number: "TPLGC4160688"}])
│ → GET msg.gigafibre.ca/devices/lookup?serial=TPLGC4160688
│ → targo-hub → GenieACS NBI → summarizeDevice()
│ → {interfaces[], mesh[], wifi{}, opticalStatus, ethernet{}}
├── fetchHosts(serial, refresh=true)
│ → GET msg.gigafibre.ca/devices/:id/hosts?refresh
│ → 2 inline tasks to CPE → read cache → clientNodeMap
│ → {total, hosts[{name, ip, mac, band, signal, attachedNode}]}
UI renders:
┌─ Fibre [Up] Rx dBm (when available)
├─ IP Addresses (Internet, Gestion [clickable → /superadmin/], Service, LAN)
├─ WiFi (per-radio + total clients: direct + mesh)
├─ Ethernet ports
├─ General (firmware, SSID, uptime)
└─ Connected Clients (collapsible, grouped by mesh node)
├─ basement (5) — signal %, name, IP, MAC, band, lease
├─ hallway (2)
├─ living_room (4)
└─ Filaire / Autre (wired clients)
Ops App → EquipmentDetail.vue → GET /devices/lookup?serial=X
→ targo-hub → GenieACS NBI (3 fallback strategies) → summarizeDevice()
→ {interfaces, mesh, wifi, opticalStatus, ethernet}
GET /devices/:id/hosts?refresh
→ 2 tasks to CPE (connection_request) → read GenieACS cache
→ clientNodeMap: MAC → {nodeName, band, signal, speed}
→ UI: clients grouped by mesh node (basement, hallway, etc.)
```
### Dispatch Auto-Assign
```
New job with required tags (e.g., Fibre level 3)
→ useAutoDispatch → useBestTech
→ Filter techs: tag level >= required, available in time slot
→ Sort: lowest adequate skill level first (preserve experts)
→ Assign → SSE broadcast → timeline updates
```
### SMS Notification
```
Compose in ChatterPanel → POST /send/sms → targo-hub
→ Twilio API → delivery
→ /webhook/twilio/sms-status → SSE broadcast (conv:{token})
→ UI updates delivery status in thread
Inbound SMS → /webhook/twilio/sms-incoming
→ conversation.js (persist) → SSE broadcast (sms-incoming)
→ agent.js (optional AI auto-reply with tool-calling)
```
### Customer Onboarding
```
Website → /api/address (eligibility check) → /api/catalog
→ /api/checkout (Stripe session) → /webhook/stripe (payment confirmed)
→ ERPNext: create Customer + Service Location + Subscription
→ /api/otp (SMS verification) → magic-link auth
→ provision.js: OLT pre-auth → GenieACS auto-provision on connect
```

View File

@ -1,187 +0,0 @@
# Changelog
## 2026-03-31 — Ops App: Inline Editing, Notifications, Search
### Inline Editing (Odoo-style)
- **InlineField component** (`apps/ops/src/components/shared/InlineField.vue`) — universal dblclick-to-edit supporting text, number, textarea, select, date types
- **useInlineEdit composable** (`apps/ops/src/composables/useInlineEdit.js`) — save logic wrapping `updateDoc()` with error handling + Quasar Notify
- **ClientDetailPage** — all Service Location fields now inline-editable (address, city, postal code, contact, OLT port, network ID, connection type)
- **CustomerHeader** — customer_name editable via InlineField
- **DetailModal** — Issue (status, priority, type, subject), Equipment (all fields), Invoice (remarks) — all inline-editable
- **TicketsPage** — status and priority columns editable from table with badge display slot
- **ClientsPage** — customer name, type, group editable directly from the list table
### Search Enhancement
- Client search now matches `customer_name`, `name` (account ID like CUST-4), and `legacy_customer_id` (like LPB4) using `or_filters`
- Legacy ID column added to clients table
- `listDocs` and `countDocs` API helpers updated to support `or_filters` parameter
### SMS/Email Notifications
- **ContactCard** — expanding notification panel with SMS/Email toggle, recipient selector, subject (email), message body, character count
- **Server Scripts**`send_sms_notification` and `send_email_notification` API endpoints (normalize phone, route via n8n, log Comment on Customer)
- **sms.js** — new API wrapper for SMS endpoint
- Routes through n8n webhooks: `/webhook/sms-send` (Twilio) and `/webhook/email-send` (Mailjet)
### Ticket Replies
- Reply textarea + send button in DetailModal Issue view
- Creates `Communication` doc in ERPNext with proper linking
### Bug Fixes
- Fixed blur race condition in InlineField (80ms debounce + `committing` flag prevents double API calls)
- Fixed CustomerHeader double-save (InlineField saves directly, removed redundant parent emit)
- Fixed deploy script — was rsync'ing locally instead of SSH to production server
### Authentik Federation
- OAuth2 Source created on id.gigafibre.ca linking to auth.targo.ca as OIDC provider
- `email_link` user matching mode — staff from auth.targo.ca can log in to id.gigafibre.ca
- Customers register directly on id.gigafibre.ca (not added to auth.targo.ca)
---
## 2026-03-30 — Ops App V2, Field Tech App, OCR
### Ops App
- **ClientDetailPage** — full customer view with locations, subscriptions, equipment, tickets, invoices, payments, notes
- **Dispatch module** — integrated into ops app at `/dispatch` route (replaces standalone dispatch app)
- **Equipment page** — searchable list of all Service Equipment
- **OCR page** — Ollama Vision integration for invoice scanning
### Field Tech App (`apps/field/`)
- Barcode scanner (camera API), task list, diagnostics, offline support
- PWA with Workbox for field use
### Infrastructure
- Ops app served at `erp.gigafibre.ca/ops/` via nginx proxy with API token injection
- Ollama Vision routed through ops-frontend nginx proxy
---
## 2026-03-29 — Migration Phases 5-7
### Phase 5: Opening Balance + AR Analysis
- Outstanding invoice analysis and reconciliation
### Phase 6: Tickets
- **242,618 tickets** migrated as ERPNext Issues with parent/child hierarchy
- Issue types mapped: Reparation Fibre, Installation Fibre, Telephonie, Television, etc.
- Assigned staff and opened_by_staff mapped from legacy numeric IDs to ERPNext User emails
- **784,290 ticket messages** imported as Communications
### Phase 7: Staff + Memos
- **45 ERPNext Users** created from legacy staff table
- **29,000 customer memos** imported as Comments with real creation dates
- **99,000 payments** imported with invoice references and mode mapping (PPA, cheque, credit card)
---
## 2026-03-28 — Migration Phases 1-4
### Phase 1 : Données maîtres
### Infrastructure
- **Frappe Assistant Core v2.3.3** installé sur ERPNext — MCP connecté à Claude Code
- **Connexion MariaDB legacy** (10.100.80.100) → ERPNext (10.100.5.61) établie
- GRANT SELECT pour `facturation@10.100.5.61` sur `gestionclient`
- **2 Workspaces** créés sur ERPNext Desk : Dispatch (icône tool) + Gigafibre FSM (icône map)
### Item Groups (34)
Hiérarchie créée sous 3 parents :
- **Services** (16) : Mensualités fibre/sans fil/télévision, Téléphonie, IP Fixe, Hébergement, Nom de domaine, Location P2P, Internet camping, Cloud, Site internet, Services info, Location espace, Garantie, Téléchargement supp, Honoraires
- **Products** (8) : Installation fibre/sans fil/télé, Équipement fibre/sans fil, Quincaillerie, Pièces info, Technicien
- **Frais et ajustements** (10) : Intérêts, Activation, Frais divers taxables, Créances, Recouvrement, Transport, Impression, Infographie, SPECIAL
### Items (833)
- **833/833** produits importés via script Python (`/tmp/import_items.py`)
- Source : `gestionclient.product` → ERPNext `Item`
- Mapping : SKU → item_code, price → standard_rate, category → item_group
- Items sans services actifs ET inactive dans legacy → `disabled=1`
- Script exécuté depuis le container `erpnext-backend-1`
### Custom Fields créés
**Sur Item (section "ISP Settings")** :
| Fieldname | Type | Description |
|-----------|------|-------------|
| legacy_product_id | Int | ID dans gestionclient.product |
| download_speed | Int | Vitesse download en Kbps |
| upload_speed | Int | Vitesse upload en Kbps |
| quota_day_gb | Float | Quota jour en GB |
| quota_night_gb | Float | Quota nuit en GB |
| fibre_lineprofile | Data | Profil OLT ligne |
| fibre_serviceprofile | Data | Profil OLT service |
**Sur Customer (section "Legacy Settings")** :
| Fieldname | Type | Description |
|-----------|------|-------------|
| legacy_account_id | Int | ID dans gestionclient.account |
| legacy_customer_id | Data | customer_id legacy (ex: DR2, LOUIS4502470070) |
| ppa_enabled | Check | Paiement pré-autorisé AccesD actif |
| stripe_id | Data | Stripe customer ID |
**Sur Subscription (section "ISP Settings")** :
| Fieldname | Type | Description |
|-----------|------|-------------|
| radius_user | Data | Utilisateur RADIUS (ex: tci44166) |
| radius_pwd | Data | Mot de passe RADIUS |
| legacy_service_id | Int | ID dans gestionclient.service |
### Données mises à jour
- **833** Items : legacy_product_id peuplé
- **98** Items : vitesses download/upload + quotas en GB + profils fibre
---
## 2026-03-28 — Phase 2 : Customers, Contacts, Addresses
### Méthode
- **Direct PostgreSQL** (`migrate_direct.py`) au lieu de l'API REST
- Temps d'exécution : ~30 secondes vs plusieurs heures via API REST
- PostgreSQL `max_connections` augmenté à **200** pour supporter la charge
### Résultats
- **6,667 Customers** importés (comptes actifs + suspendus)
- **~6,600 Contacts** créés avec email, téléphone, cellulaire
- **~6,700 Addresses** créées avec géolocalisation (latitude/longitude)
- Liens Customer ↔ Contact ↔ Address établis
---
## 2026-03-28 — Phase 3 : Tax Templates + Subscription Plans
### Tax Templates
- **QC TPS 5%** — Sales Taxes and Charges Template
- **QC TVQ 9.975%** — Sales Taxes and Charges Template
### Subscription Plans
- **92 Subscription Plans** créés à partir des produits actifs avec services
- Mapping : Item → Subscription Plan (billing_interval=Month, currency=CAD)
---
## 2026-03-28 — Phase 4 : Subscriptions
### Résultats
- **21,876 Subscriptions** importées avec données RADIUS (radius_user, radius_pwd)
- legacy_service_id peuplé pour chaque Subscription
- Lien Customer ↔ Subscription Plan établi
### ATTENTION CRITIQUE
- **Le scheduler ERPNext est PAUSED** — les Subscriptions ne génèrent PAS de factures automatiquement
- **NE PAS réactiver le scheduler sans instruction explicite** — la réactivation déclencherait la facturation automatique pour les 21,876 abonnements
---
## Connexions et accès
| Système | Accès | Méthode |
|---------|-------|---------|
| ERPNext API | `token $ERP_SERVICE_TOKEN` (see server .env) | REST API |
| ERPNext MCP | Frappe Assistant Core | StreamableHTTP |
| Legacy MariaDB | `facturation@10.100.80.100` | pymysql depuis container ERPNext |
| Legacy SSH | `root@96.125.192.252` (clé SSH copiée) | SSH |
| DB server SSH | `root@10.100.80.100` via sshpass depuis legacy | SSH |
## Scripts de migration
- `/tmp/import_items.py` — Import 833 produits → Items
- `/tmp/update_items_speeds.py` — MAJ vitesses/quotas sur Items
- Tous exécutés depuis `erpnext-backend-1` container

View File

@ -1,97 +0,0 @@
# Competitive Analysis — ISP Field Service Platforms
## Gaiia (gaiia.com) — Primary Comparable
Canadian-founded (2021), YC-backed, $13.2M Series A (Inovia Capital, June 2024).
40+ ISP customers. "First operating system designed for ISPs."
### Modules vs Gigafibre Status
| Gaiia Module | What it does | Gigafibre Status |
|---|---|---|
| **Workforce & Scheduling** | Drag-drop scheduler, live map, GPS tracking | Done (dispatch PWA) |
| **Field Service App** | Native iOS/Android, status updates, photos, routing | PWA exists, needs mobile features |
| **Billing & Revenue** | Invoicing, proration, multiple subs, tax | ERPNext handles (basic) |
| **Customer Management** | CRM, device tracking, communications | ERPNext Customer + Service Equipment |
| **Customer Portal** | Self-service, plan changes, tickets | Not built |
| **Online Checkout** | E-commerce signup, address validation, auto-schedule install | Not built |
| **Inventory & Fulfillment** | Equipment lifecycle, auto-assignment, shipping | Service Equipment doctype (basic) |
| **Network Monitoring** | Site visibility, incident → ticket | Oktopus CE deployed |
| **Properties (MDU)** | Unit management, split billing | Not needed yet |
| **Reporting & Analytics** | Dashboards, penetration rates | Not built |
| **Workflow Builder** | No-code visual automation | n8n deployed |
### Key Gaiia UX Patterns to Adopt
1. **E-commerce checkout flow** — Oxio saw 6x conversion (20→120 signups/day)
2. **Auto travel time** on scheduling (we have Mapbox, just need to display it)
3. **One-tap status updates** for techs (en route → working → completed)
4. **Color-coded work orders** by type/priority on scheduler
5. **Equipment auto-assignment** during service provisioning
6. **Proactive customer SMS** when tech is en route
### What Gigafibre Does That Gaiia Doesn't (publicly)
- Custom drag-drop timeline (not just Gantt)
- Lasso multi-select for batch operations
- Undo/redo system on all operations
- Circular progress ring on map markers (workload visualization)
- Crew grouping badges
- Real-time WebSocket GPS (not polling)
- Auto-dispatch algorithm with weighted criteria
- Route optimization via Mapbox Directions
---
## Full Industry Comparison Matrix
| Feature | Gaiia | Odoo FS | Zuper | Salesforce FS | ServiceTitan | Gigafibre |
|---|---|---|---|---|---|---|
| **Target** | ISP-specific | General | Telecom focus | Enterprise | Home services | ISP custom |
| **Pricing** | Platform+per-sub | Free CE / $30/user | ~$50/user | ~$200/user | ~$300/mo+ | Free (OSS) |
| **Self-hosted** | No | Yes | No | No | No | Yes |
| **Drag-drop dispatch** | Yes | Yes | Yes | Yes | Yes | Yes |
| **GPS real-time** | Yes | Limited | Yes | Yes | Yes | Yes (WebSocket) |
| **Route optimization** | No public | Basic | Yes | Advanced | Basic | Yes (Mapbox) |
| **Mobile tech app** | Native | Web | Native | Native | Native | PWA |
| **Offline mode** | Unknown | Limited | Yes | Yes | Yes | Planned |
| **Barcode scanning** | Unknown | Yes | Yes | Yes | No | Planned |
| **Equipment tracking** | Yes (lifecycle) | Yes | Yes | Yes | Basic | Yes (EQP doctype) |
| **Subscriptions** | Yes (ISP-native) | Yes | Yes | Yes | Basic | Yes (SUB doctype) |
| **Customer portal** | Yes | Yes | Yes | Yes | Yes | Not yet |
| **Online checkout** | Yes (6x conversion) | No | No | No | No | Not yet |
| **MDU management** | Yes | No | No | No | No | Not yet |
| **Network monitoring** | Yes | No | No | No | No | Oktopus CE |
| **Billing automation** | Yes (ISP-native) | Yes | Via partner | Via partner | Yes | ERPNext |
| **Workflow builder** | Yes (visual) | No | No | Yes (Flow) | No | n8n |
| **SLA management** | Unknown | Yes | Yes | Yes | No | Planned |
| **CRM** | Built-in | Built-in | Via partner | Built-in | No | ERPNext |
---
## Strategic Positioning
### Gigafibre's Advantage
- **Self-hosted / sovereign** — data stays on your servers
- **No per-subscriber fee** — Gaiia charges per subscriber, costs scale with growth
- **Full ERP integration** — ERPNext gives accounting, HR, inventory for free
- **Custom UX** — dispatch board is more advanced than Gaiia's (lasso, undo, auto-dispatch)
- **Open source** — can be offered to other ISPs as a product
### Gigafibre's Gaps (vs Gaiia)
- **Customer portal** — highest impact gap for subscriber experience
- **Online checkout** — acquisition channel (Gaiia's 6x stat is compelling)
- **Mobile tech experience** — native app > PWA for field work
- **Billing proration** — ISP-specific billing logic (mid-cycle changes)
- **Auto travel time** — easy win, we have all the data
### Build Priority (ROI-based)
| Priority | Feature | Why | Effort |
|---|---|---|---|
| 1 | **Auto travel time on schedule** | Low effort, high dispatcher value | 1 day |
| 2 | **Tech mobile workflows** (status, photos, notes) | Core field experience | 3-5 days |
| 3 | **Customer SMS notifications** (Twilio) | Customer experience | 2 days |
| 4 | **Equipment scan + track** (barcode camera) | Inventory accuracy | 3 days |
| 5 | **Customer portal** (self-service) | Reduce support calls | 1-2 weeks |
| 6 | **Online checkout** (e-commerce signup) | Revenue growth | 2-3 weeks |

498
docs/CUSTOMER-360-FLOWS.md Normal file
View File

@ -0,0 +1,498 @@
# Customer 360° — Complete Data Map & Business Flows
## Goal
Every piece of customer data linked in one place. All business flows documented so they can be:
1. Displayed in the ops-app ClientDetailPage
2. Driven by natural language ("suspend this customer", "create a quote for fibre 100M", "set up a payment plan for $150/month")
3. Automated via rules/triggers
---
## Current State — What ClientDetailPage Shows
| Section | Source | Status |
|---------|--------|--------|
| Customer header (name, status, group) | Customer | ✅ |
| Contact (phone, email, cell) | Customer + Contact | ✅ |
| Info card (PPA, commercial, VIP flags) | Customer custom fields | ✅ |
| Locations + connection type + OLT | Service Location | ✅ |
| Equipment per location (live status, signal, WiFi) | Service Equipment + GenieACS | ✅ |
| Subscriptions per location (monthly/annual, price) | Service Subscription | ✅ |
| Tickets per location | Issue | ✅ |
| All tickets table | Issue | ✅ |
| Invoices table | Sales Invoice | ✅ |
| Payments table | Payment Entry | ✅ |
| Notes (internal memos) | Comment on Customer | ✅ |
| Activity timeline (sidebar) | Comment + audit | ✅ |
---
## Missing Data — To Import & Link
### 1. Soumissions (Quotes) — 908 records, 529 customers
```
Legacy: soumission
id, account_id, name, po, date, tax
materiel → PHP serialized array [{sku, desc, amt, qte, tot}, ...]
mensuel → PHP serialized array [{sku, desc, amt, qte, tot}, ...]
text → terms/notes
3 templates in soumission_template
ERPNext target: Quotation
party_type = Customer, party = C-{account_id}
items[] from deserialized materiel + mensuel
terms = text field
legacy_soumission_id = id
```
**Flow**: Sales rep creates quote → customer approves → services activated → first invoice generated
**Natural language**: "Create a quote for client X for fibre 100M + WiFi router"
**UI**: New expandable section in ClientDetailPage between Subscriptions and Tickets
### 2. Accords de paiement (Payment Arrangements) — 7,283 records, 1,687 customers
```
Legacy: accord_paiement
id, account_id, staff_id
date_accord → date agreed (unix)
date_echeance → due date (unix)
date_coupure → cutoff/disconnect date (unix)
montant → agreed payment amount
method → 0=unset, 1=portal, 2=cheque, 3=phone, 4=PPA
status → -1=pending, 0=open, 1=completed
note → staff notes ("Par téléphone avec Gen")
raison_changement → reason for change
ERPNext target: Custom DocType "Payment Arrangement"
linked_to Customer via party field
OR import as structured Comments with type="Payment Arrangement"
Status breakdown: 5,068 completed | 2,205 open | 10 pending
```
**Flow**: Customer has overdue balance → support calls → negotiates amount + date → cutoff if missed
**Natural language**: "Set up a payment plan for client X: $150 by March 26, disconnect on April 2 if missed"
**UI**: Badge on CustomerHeader showing active arrangement + section in sidebar
### 3. Bons de travail (Work Orders) — 14,486 records, 10,110 customers
```
Legacy: bon_travail
id, account_id, date (unix)
tech1, heure_arrive_t1, heure_depart_t1
tech2, heure_arrive_t2, heure_depart_t2
note, subtotal, tps, tvq, total
Legacy: bon_travail_item (only 3 records — barely used)
bon_id, product_id, qte, price, desc
ERPNext target: Linked to Dispatch Job or custom "Work Order" child
tech1/tech2 → Employee via staff mapping
Time tracking: arrive/depart for labor costing
```
**Flow**: Ticket created → tech dispatched → arrives on site → completes work → bon de travail logged
**Natural language**: "Show me all work done at this address" or "How many hours did tech X spend at client Y?"
**UI**: Timeline entry on location, or section under Tickets
### 4. Payment Methods & Tokens — Multi-provider
Active customer payment breakdown (6,681 active accounts):
| Payment Method | Count | Notes |
|----------------|-------|-------|
| No payment method | 5,274 | Manual pay via portal/cheque |
| Bank PPA (pre-authorized debit) | 655 | Legacy bank draft via Paysafe/Bambora token |
| Stripe card (no auto) | 564 | Card on file, pays manually |
| Stripe PPA (auto-charge) | 143 | Stripe auto-recurring |
| Bank PPA + Stripe card | 45 | Both methods on file |
```
Legacy: account_profile (658 records — Paysafe/Bambora tokens)
account_id, profile_id (UUID), card_id (UUID), token, initial_transaction
Legacy: account table fields
stripe_id → Stripe customer ID (cus_xxx)
stripe_ppa → 1 = Stripe auto-charge enabled
stripe_ppa_nocc → Stripe PPA without CC
ppa → 1 = bank draft PPA enabled
ppa_name/code/branch/account → bank info for PPA
ppa_amount → PPA amount limit
ppa_amount_buffer → buffer above invoice total
ppa_fixed → fixed PPA amount
ppa_cc → PPA via credit card
ERPNext target: Custom DocType "Payment Method" linked to Customer
type = stripe | paysafe | bank_draft
token/profile_id for processor reference
is_auto = PPA flag
Stripe integration: use stripe_id to pull current payment methods via API
```
**Flow**: Customer adds card (portal or phone) → token stored → PPA auto-charges on invoice
**Future**: Stripe as primary, migrate Paysafe tokens → Stripe, bank PPA stays for some
**Natural language**: "Is client X set up for auto-pay?" / "Switch client X to Stripe auto-charge"
**UI**: Payment method badge on CustomerHeader + card in sidebar
### 5. VoIP / DID / 911 — Complete Telephone Service
Three linked tables form one VoIP service per DID:
| Table | Records | Unique DIDs | Customers | Purpose |
|-------|---------|-------------|-----------|---------|
| `pbx` | 790 | 790 | 745 | SIP line config (creds, voicemail, routing) |
| `phone_addr` | 1,014 | 1,014 | 909 | 911 address (provisioned to 911 provider) |
| `phone_provisioning` | 786 | 779 | 739 | Device provisioning (ATA model, MAC, password) |
All 790 PBX lines have a matching 911 address. 224 additional 911 addresses exist without an active PBX line (decommissioned lines that still have 911 registration).
```
Legacy: pbx (SIP line)
account_id, delivery_id, service_id
phone (10-digit DID), name (caller ID display)
password (SIP auth), vm_password, has_vm, vm_email
int_code (extension), language, call_911
max_calls, call_timeout, user_context (sip.targo.ca)
country_whitelist, date_origin, date_update
Legacy: phone_addr (911 address — provisioned to external 911 provider)
account_id, phone (DID)
street_number, apt, street_name, city, state, zip
first_name, last_name, info
enhanced_capable (Y/N), code_cauca (municipality code), class_service (RES/BUS)
Legacy: phone_provisioning (ATA/device config)
account_id, delivery_id, service_id
phone (DID), app (device type: ht502, etc), mac (device MAC)
password, internationnal (intl calling flag)
ERPNext target: Custom DocType "VoIP Line"
parent: Service Location (via delivery_id)
linked_to: Subscription (via service_id)
Fields:
did (phone number), caller_id (display name)
sip_password, voicemail_enabled, vm_password, vm_email
extension, max_calls, call_timeout
e911_street, e911_city, e911_state, e911_zip
e911_cauca_code, e911_class (RES/BUS)
e911_synced (bool — matches 911 provider)
ata_model, ata_mac, ata_password
```
**Flow**:
Order → provision SIP line in PBX → register 911 address with provider → configure ATA → service active
Address change → update 911 with provider (MANDATORY) → verify sync
**Reports needed**:
- **DID Report**: All DIDs → linked customer → service location → 911 address → match status
- **911 Audit**: DIDs where service address ≠ 911 address (compliance risk)
- **Orphan 911**: 224 addresses with no active PBX line (cleanup or deregister)
**Natural language**:
- "Show all phone lines for client X"
- "Update 911 address for 450-272-2408 to 123 Rue Principale"
- "List all DIDs with mismatched 911 addresses"
- "What's the voicemail password for 450-272-2408?"
- "Generate the DID report for all active lines"
**UI**:
- Phone icon in equipment strip per location (click → VoIP detail panel)
- 911 status badge (green = synced, red = mismatch or missing)
- DID report page accessible from main nav
### 7. Account Suspension — 1,049 records
```
Legacy: account_suspension
account_id, date_start, date_end, note
(most records have date_start=0, date_end=0 — just flags)
ERPNext target: Customer custom field "is_suspended" or Comment log
```
**Flow**: Overdue → auto-suspend → customer pays → reactivate
**Natural language**: "Suspend client X" / "Reactivate client X"
**UI**: Red badge on CustomerHeader, shown in activity timeline
### 8. IP History — 20,999 records, 5,769 customers
```
Legacy: ip_history
account_id, delivery_id, service_id, ip, date (unix)
ERPNext target: Comment on Service Location (audit trail)
OR custom child table on Service Location
```
**Flow**: Service provisioned → IP assigned → IP changes logged
**Natural language**: "What IP did client X have on March 15?"
**UI**: Collapsible history under location details
### 9. Service Snapshots (Bandwidth Usage) — 45,977 records
```
Legacy: service_snapshot
account_id, service_id, date (unix)
quota_day (bytes), quota_night (bytes)
ERPNext target: Skip import — historical analytics only
Could feed a usage dashboard later
```
### 10. Delivery History (Address Changes) — 16,284 records
```
Legacy: delivery_history
account_id, date_orig (unix)
address1, address2, city, state, zip
(previous addresses before changes)
ERPNext target: Comment on Customer or Service Location
```
---
## Complete Business Flows
### Flow 1: Sales → Activation
```
Quote created (soumission)
↓ Customer approves
Quote → Sales Order (or direct to Subscription)
↓ Address confirmed
Service Location created/selected
↓ Equipment assigned
Service Equipment linked to location
↓ Installation scheduled
Dispatch Job (from ticket or bon_travail)
↓ Tech completes work
Subscriptions activated
↓ First billing cycle
Sales Invoice generated
```
**Natural language examples:**
- "Create a quote for client X: fibre 100M at 123 Rue Principale"
- "Convert quote SOQ-908 to active subscriptions"
- "Schedule installation for next Tuesday"
### Flow 2: Billing → Collections
```
Subscription active
↓ Monthly/annual cycle
Sales Invoice auto-generated
↓ PPA active?
YES → Auto-charge via payment token (account_profile)
NO → Invoice sent (email/portal)
↓ Payment received?
YES → Payment Entry created, outstanding reduced
NO → Overdue
↓ Collections process
Payment arrangement negotiated (accord_paiement)
↓ Cutoff date passed?
YES → Account suspended (account_suspension)
↓ Payment received
Account reactivated
```
**Natural language examples:**
- "What does client X owe?"
- "Set up a payment plan: $150 by the 15th, cut off on the 20th"
- "Suspend client X for non-payment"
- "Client X just paid, reactivate their service"
### Flow 3: Support → Resolution
```
Customer calls / portal ticket
↓ Issue created
Ticket assigned to department/tech
↓ Remote diagnosis?
YES → Check equipment live status (GenieACS/TR-069)
→ Reboot device, check signal, WiFi clients
NO → Schedule field visit
↓ Dispatch Job created
Tech dispatched → arrives → works
↓ Bon de travail logged (tech, hours, parts)
Issue resolved → closed
↓ Billable?
YES → Invoice for labor/parts
NO → Done
```
**Natural language examples:**
- "Client X has no internet — check their ONT status"
- "Send a tech to 123 Rue Principale tomorrow morning"
- "How many times have we sent a tech to this address?"
### Flow 4: Provisioning → Service Management
```
Service ordered
↓ Equipment assigned
OLT port configured (fibre table)
↓ ONT registered
GenieACS discovers device → TR-069 parameters set
↓ VoIP ordered?
YES → PBX line created (pbx table)
→ 911 address registered (phone_addr)
→ Voicemail configured
↓ IP assigned
IP logged in ip_history
↓ Service active
Subscription billing begins
```
**Natural language examples:**
- "Provision fibre for client X at port 1/2/3 on OLT-EAST"
- "Set up a phone line 819-555-1234 with voicemail"
- "What's the 911 address for this line?"
---
## ERPNext Data Model — Complete Customer Graph
```
Customer (C-{id})
├── SALES
│ ├── Quotation ←── soumission (908) ◄ TO IMPORT
│ │ └── Quotation Item (materiel + mensuel)
│ └── (future: Sales Order)
├── BILLING
│ ├── Sales Invoice (629,935) ✅ DONE
│ │ ├── SI Item → income_account (SKU-mapped) ✅ DONE
│ │ ├── SI Tax (TPS/TVQ) ✅ DONE
│ │ ├── GL Entry (4 per invoice) ✅ DONE
│ │ ├── PLE (outstanding tracking) ✅ DONE
│ │ └── Comment (invoice notes, 580K) ✅ DONE
│ ├── Payment Entry (343,684) ✅ DONE
│ │ ├── PE Reference (allocations) ✅ DONE
│ │ └── GL Entry (2 per payment) ✅ DONE
│ ├── Payment Arrangement ←── accord_paiement ◄ TO IMPORT
│ │ (7,283: amount, dates, cutoff, status)
│ └── Payment Method (custom doctype) ◄ TO IMPORT
│ ├── Stripe (752 customers, 143 auto-PPA)
│ │ └── stripe_id → Stripe API for live card info
│ ├── Paysafe/Bambora (658 tokens from account_profile)
│ │ └── profile_id + card_id + token
│ └── Bank PPA (655 with bank account info)
│ └── ppa_name, ppa_code, ppa_branch, ppa_account
├── SERVICE
│ ├── Service Location (delivery) ✅ DONE
│ │ ├── Service Equipment (device) ✅ DONE
│ │ │ ├── OLT data (fibre table) ✅ DONE
│ │ │ ├── Live status (GenieACS TR-069) ✅ LIVE
│ │ │ └── Provisioning data (WiFi/VoIP) ✅ DONE
│ │ ├── VoIP Line ←── pbx (790 DIDs) ◄ TO IMPORT
│ │ │ ├── 911 Address ←── phone_addr (1,014) ◄ TO IMPORT
│ │ │ │ └── Synced to external 911 provider (CAUCA codes)
│ │ │ │ └── 224 orphan 911 records (no active PBX line)
│ │ │ └── ATA Config ←── phone_provisioning ◄ TO IMPORT
│ │ │ (786 records: device type, MAC, SIP creds)
│ │ ├── IP History ←── ip_history (20,999) ◄ TO IMPORT
│ │ └── Address History ←── delivery_history ○ LOW PRIORITY
│ ├── Subscription (active services) ✅ DONE
│ │ ├── Subscription Plan ✅ DONE
│ │ └── Billing frequency + price ✅ DONE
│ └── Suspension Status ←── account_suspension ◄ TO IMPORT
│ (1,049 records)
├── SUPPORT
│ ├── Issue / Ticket (98,524) ✅ DONE
│ │ ├── Communication (ticket messages) ✅ DONE
│ │ ├── Dispatch Job ✅ DONE
│ │ └── Work Order ←── bon_travail (14,486) ◄ TO IMPORT
│ │ (tech, hours, parts, billing)
│ └── Comment / Memo (29,245) ✅ DONE
├── ACCOUNT
│ ├── Portal User (Website User) ✅ DONE
│ │ └── Auth bridge (MD5 → pbkdf2) ✅ DONE
│ ├── Stripe ID ✅ DONE
│ └── PPA flags + bank info ✅ DONE
└── ANALYTICS (low priority)
├── Bandwidth Usage ←── service_snapshot (46K) ○ SKIP
├── VoIP CDR ←── voicemeup (96) ○ SKIP
└── TV Wizard ←── tele_wiz (1,065) ○ SKIP
```
---
## Import Priority & Plan
### Phase 13: Remaining Customer Data
| Step | Table | Target | Records | Depends on |
|------|-------|--------|---------|------------|
| 13a | `soumission` | Quotation | 908 | Customer, Item |
| 13b | `accord_paiement` | Payment Arrangement (custom) | 7,283 | Customer |
| 13c | `bon_travail` | Work Order log on Issue/Customer | 14,486 | Customer, Employee |
| 13d | `account_profile` + `account.*ppa*` + `account.stripe_id` | Payment Method (custom doctype) | 658 + 655 + 752 | Customer |
| 13e | `pbx` + `phone_provisioning` | VoIP Line (custom doctype) linked to Location + Subscription | 790 + 786 | Customer, Service Location |
| 13f | `phone_addr` | 911 Address fields on VoIP Line + sync status flag | 1,014 | VoIP Line |
| 13f.1 | — | **DID Report**: all DIDs → customer → location → 911 address → sync status | report | VoIP Line |
| 13f.2 | — | **911 Audit Report**: mismatches + 224 orphan 911 addresses | report | VoIP Line |
| 13g | `account_suspension` | Customer.is_suspended flag | 1,049 | Customer |
| 13h | `ip_history` | Comment on Service Location | 20,999 | Service Location |
### Phase 14: UI Sections in ClientDetailPage
| Section | Position | Data |
|---------|----------|------|
| Soumissions | After subscriptions, before tickets | Quotation list with status |
| Accord de paiement | Badge on header + sidebar card | Active arrangement with countdown |
| Bons de travail | Under ticket detail or in timeline | Tech visits with hours |
| VoIP | Under equipment strip per location | Phone lines with 911 status |
| Suspension | Red banner on header | Active suspension with dates |
### Phase 15: Natural Language Actions
Each action maps to an API call on ERPNext or targo-hub:
| Intent | Action | API |
|--------|--------|-----|
| "Create a quote for..." | Parse items + customer → POST Quotation | ERPNext API |
| "Suspend client X" | Set is_suspended=1, trigger OLT port disable | targo-hub + OLT API |
| "Set up payment plan..." | Create Payment Arrangement with dates/amount | ERPNext API |
| "What does X owe?" | SUM(outstanding_amount) from Sales Invoice | ERPNext API |
| "Send a tech to..." | Create Dispatch Job with location | ERPNext API |
| "Check their internet" | GenieACS device status + signal levels | targo-hub |
| "Reboot their router" | TR-069 reboot task via GenieACS | targo-hub |
| "What IP did X have on..." | Query ip_history or Comment | ERPNext API |
| "Add a phone line" | Create VoIP Line + register 911 | PBX API + 911 provider API + targo-hub |
| "Update 911 address for..." | Update phone_addr + push to 911 provider | 911 provider API |
| "Show DID report" | All DIDs with customer, location, 911 match | ERPNext report |
| "List mismatched 911" | DIDs where service addr ≠ 911 addr | ERPNext report |
| "Switch client to Stripe" | Create Stripe customer + set PPA flag | Stripe API |
| "Is client X on auto-pay?" | Check Payment Method for active PPA | ERPNext API |
| "Convert quote to service" | Quotation → Subscriptions + Location setup | ERPNext API |
---
## Natural Language Architecture
```
User input (text or voice)
Intent classifier (LLM)
↓ extracts: action, customer, parameters
Action router
↓ maps intent → API endpoint + payload
Confirmation step (show what will change)
↓ user approves
Execute via targo-hub API
↓ returns result
Response in natural language
```
Key principle: **every action the UI can do, the NL interface can do**.
The ops-app becomes a visual confirmation layer for NL-driven changes.

View File

@ -1,493 +0,0 @@
# Gigafibre FSM — Ecosystem Overview
> Synthèse complète pour développeurs et sysadmins — Avril 2026
---
## 1. Vue d'ensemble
Gigafibre FSM est une plateforme de gestion d'opérations pour un fournisseur internet fibre (FTTH) desservant **6 600+ clients** au Québec. Elle remplace un système legacy PHP/MariaDB vieux de 15 ans par un écosystème moderne composé de :
- **4 applications web** (ops, field, client, website)
- **1 backend Node.js** (targo-hub) servant d'orchestrateur central
- **ERPNext v16** comme ERP et source de vérité des données
- **95+ scripts de migration** pour le transfert depuis le système legacy
- **Infrastructure Docker** avec Traefik, Authentik SSO, et monitoring
**Monorepo** : `git.targo.ca/louis/gigafibre-fsm`
```
gigafibre-fsm/
├── apps/
│ ├── ops/ → Quasar/Vue 3 PWA — outil interne (erp.gigafibre.ca/ops/)
│ ├── field/ → Quasar/Vue 3 PWA — app mobile techniciens
│ ├── client/ → Quasar/Vue 3 PWA — portail client (client.gigafibre.ca)
│ └── website/ → React/Vite — site vitrine (www.gigafibre.ca)
├── services/
│ └── targo-hub/ → Node.js 20 — backend API (msg.gigafibre.ca)
├── erpnext/ → Scripts de setup des doctypes custom
├── scripts/migration/ → Importation legacy → ERPNext
└── docs/ → Documentation architecture
```
---
## 2. Infrastructure serveur
### Serveur principal : 96.125.196.67 (hubdocker)
Tout roule sur un seul serveur Proxmox avec Docker. Le reverse proxy Traefik gère le TLS (Let's Encrypt) et le routage.
| Service | URL | Container | Auth |
|---------|-----|-----------|------|
| ERPNext v16 | erp.gigafibre.ca | erpnext-frontend-1 | Aucun (API token) |
| Ops App | erp.gigafibre.ca/ops/ | ops-frontend (nginx) | Authentik SSO |
| Portail client | client.gigafibre.ca | erpnext-frontend-1 | Authentik client |
| targo-hub | msg.gigafibre.ca | targo-hub | Authentik SSO |
| n8n | n8n.gigafibre.ca | erpnext-n8n-1 | Authentik (GUI) / aucun (webhooks) |
| Dispatch legacy | dispatch.gigafibre.ca | apps-dispatch-frontend-1 | Authentik SSO |
| Oktopus ACS | oss.gigafibre.ca | oktopus-frontend-1 | Authentik SSO |
| Site web | www.gigafibre.ca | apps-www-gigafibre-1 | Aucun |
| GPS tracking | tracker.targointernet.com | traccar | Basic auth |
### Réseaux Docker
| Réseau | Services connectés |
|--------|-------------------|
| `proxy` | Traefik, targo-hub, ops, dispatch, erpnext, n8n, authentik, oktopus, website, traccar |
| `erpnext_erpnext` | ERPNext (backend, DB, redis, workers), targo-hub, n8n |
| `fonoster_default` | Fonoster/Routr SIP (asterisk, postgres, nats, routr) |
| `authentik-client_authentik-client` | Authentik client (server, worker, postgresql, redis) |
### Bases de données
| DB | Host | Usage |
|----|------|-------|
| PostgreSQL (ERPNext) | erpnext-db-1:5432 | Toutes les données métier |
| PostgreSQL (targo_cache) | erpnext-db-1:5432 | Cache devices + hosts (targo-hub) |
| PostgreSQL (Fonoster) | fn-postgres:5432 | SIP routing (trunks, agents) |
| MongoDB (Oktopus) | oktopus-mongo-1:27017 | TR-069 ACS device management |
### SSO (Authentik)
- **Staff** : auth.targo.ca — protège ops, dispatch, n8n, hub
- **Client** : id.gigafibre.ca — protège le portail client
- Authentification via Traefik forwardAuth middleware
---
## 3. ERPNext — Modèle de données
ERPNext est le coeur du système. Tous les CRUD passent par son API REST.
### Doctypes custom (module FSM)
| Doctype | Rôle | Champs clés |
|---------|------|-------------|
| **Dispatch Job** | Tâche planifiée | tech, date, heure, durée, status, tags, published, is_recurring, recurrence_rule |
| **Dispatch Technician** | Ressource humaine/matérielle | fullName, phone, resource_type, weekly_schedule, extra_shifts, tags |
| **Dispatch Tag** | Compétence/catégorie | tag_name, color, category |
| **Dispatch Tag Link** | Lien tag↔job/tech | tag, level (1-5), required |
| **Dispatch Preset** | Preset de groupe partagé | preset_name, preset_type, preset_data (JSON) |
| **Dispatch Offer** | Offre de travail (pool Uber) | job, mode, pricing, status, responses |
| **Service Location** | Adresse de service | address, city, GPS, OLT port, network_id |
| **Service Equipment** | Équipement client | serial, MAC, IP, firmware, OLT info |
### Doctypes ERPNext standard enrichis
| Doctype | Champs custom ajoutés |
|---------|----------------------|
| Customer | legacy_customer_id, stripe_id, is_commercial, is_bad_payer, ppa_enabled |
| Subscription | custom_description, actual_price, service_location |
| Issue | linked dispatch jobs, legacy ticket ID |
| Sales Invoice | QR code, portal payment link |
---
## 4. Ops App — Application principale
### Stack technique
- **Framework** : Vue 3 (Composition API, `<script setup>`)
- **UI** : Quasar v2.19 (q-table, q-dialog, q-select, q-notify...)
- **State** : Pinia (2 stores : auth + dispatch)
- **Build** : Vite + Quasar CLI, output PWA
- **Cartes** : Mapbox GL JS
- **Style** : SCSS, thème adaptatif (dark pour dispatch, light pour le reste)
- **Déploiement** : `npx quasar build``scp dist/spa/* root@96.125.196.67:/opt/ops-app/`
### Pages (12)
| Page | Fonction |
|------|----------|
| DashboardPage | KPIs, contrôle scheduler, déclenchement facturation |
| ClientsPage | Liste clients avec recherche |
| ClientDetailPage | Vue 360° client : contacts, abonnements, équipements, tickets, factures |
| TicketsPage | Liste tickets avec filtres, détail en slide-over |
| **DispatchPage** | **Timeline dispatch complète (voir section 5)** |
| EquipePage | Gestion d'équipe |
| NetworkPage | Infrastructure réseau |
| TelephonyPage | Téléphonie/PBX |
| RapportsPage | Rapports et analytics |
| SettingsPage | Paramètres admin |
| OcrPage | OCR de factures (Gemini vision) |
| AgentFlowsPage | Workflows d'agent IA |
### Architecture composables (40 fichiers)
Chaque domaine métier est encapsulé dans un composable réutilisable :
```
composables/
├── useScheduler.js — Calcul des segments timeline (shifts, absences, jobs)
├── useMap.js — Intégration Mapbox, routes, géocodage
├── useDragDrop.js — Drag-and-drop sur le timeline
├── useBottomPanel.js — Panel jobs non-assignés
├── useResourceFilter.js — Filtrage ressources (tags, groupes, recherche)
├── useTagManagement.js — CRUD tags/compétences
├── usePeriodNavigation.js — Navigation jour/semaine/mois
├── useAutoDispatch.js — Dispatch automatique par compétences
├── useBestTech.js — Matching technicien optimal
├── useJobOffers.js — Pool d'offres Uber-style
├── useAbsenceResize.js — Resize absences par drag
├── useSelection.js — Multi-sélection (lasso)
├── useUndo.js — Undo/redo (Ctrl+Z)
├── useContextMenus.js — Menus contextuels
├── useHelpers.js — Utilitaires (RRULE, dates, couleurs)
├── useInlineEdit.js — Édition inline Odoo-style
├── useClientData.js — Chargement données client
├── useConversations.js — Chat/messages
├── useDeviceStatus.js — Statut devices GenieACS
├── useGpsTracking.js — Tracking GPS temps réel (Traccar)
├── useSSE.js — Server-sent events
├── usePhone.js — Intégration téléphonie
├── useAddressSearch.js — Autocomplétion adresse (Mapbox)
└── ...
```
### API modules (9 fichiers)
| Module | Cible | Fonction |
|--------|-------|----------|
| erp.js | ERPNext via nginx proxy | CRUD générique (listDocs, getDoc, updateDoc, searchDocs) |
| dispatch.js | ERPNext | Jobs, technicians, tags, publish |
| offers.js | ERPNext | Offres de travail (pool Uber) |
| presets.js | ERPNext | Presets de groupes partagés |
| sms.js | n8n webhook | Envoi SMS via Twilio |
| traccar.js | Traccar proxy | GPS tracking |
| ocr.js | Ollama proxy | OCR vision par IA |
| auth.js | ERPNext | Auth token, session |
| service-request.js | ERPNext | Requêtes de service |
### Composants partagés
| Composant | Fonction |
|-----------|----------|
| DetailModal.vue | Modal slide-over multi-doctype (facture, ticket, équipement, abonnement) |
| TagEditor.vue | Éditeur de tags universel avec couleurs, niveaux, autocomplete |
| RecurrenceSelector.vue | Sélecteur récurrence Google Calendar (RRULE) |
| InlineField.vue | Champ éditable inline (double-clic, Odoo-style) |
| UnifiedCreateModal.vue | Modal création unifiée (ticket, tâche, bon de travail) |
| ProjectWizard.vue | Assistant création projet/offre |
| ConversationPanel.vue | Panel de conversation (chat, SMS) |
---
## 5. Dispatch — Fonctionnalités détaillées
Le dispatch est le module le plus riche de l'application. C'est un planificateur de ressources complet avec timeline, cartes, et intelligence artificielle.
### 5.1 Vues
| Vue | Description |
|-----|-------------|
| **Jour** | Timeline horizontale par technicien, segments de jobs drag-and-drop |
| **Semaine** | Grille 7 jours × techniciens, résumé capacité/charge |
| **Mois** | Calendrier mensuel avec disponibilité par technicien sélectionné |
### 5.2 Timeline (vue jour)
- Chaque technicien = une rangée horizontale (6h → 22h)
- **Segments de jobs** : blocs colorés par type de service, drag-and-drop pour réassigner/redimensionner
- **Absences** : blocs rouges (vacances, maladie) — redimensionnables par drag des bords
- **Capacité** : barre de charge sous chaque rangée (vert < 80%, jaune < 100%, rouge > 100%)
- **Multi-sélection** : lasso ou Ctrl+clic pour sélectionner plusieurs jobs
- **Menu contextuel** : clic droit → déplacer, copier, désaffecter, offrir aux ressources
- **Undo/redo** : Ctrl+Z / Ctrl+Shift+Z
### 5.3 Mode planification
Activé par un toggle dans la toolbar. Ajoute une couche visuelle :
- **Shifts réguliers** : blocs de fond bleus translucides montrant les plages horaires de chaque technicien
- **Shifts de garde** : blocs ambrés avec hachures diagonales
- **Vue semaine** : bandes vertes (disponible) et jaunes (garde) sous les headers de jours
- **Vue mois** : blocs colorés par type (vert=disponible, jaune=garde, rouge=absence)
- **Clic sur un shift** → ouvre le modal d'édition d'horaire du technicien
### 5.4 Éditeur d'horaire technicien
Modal permettant de configurer pour chaque technicien :
- **Horaire hebdomadaire** : lundi → dimanche, heure début/fin, jour on/off
- **Presets rapides** : Temps plein (8-16), Soirs (16-00), Nuits (00-08)
- **Shifts de garde/urgence** : shifts additionnels avec :
- Label personnalisé
- Heures de début/fin
- **Récurrence RRULE** via le composant RecurrenceSelector (Google Calendar-style)
- Date de début
- Les shifts sont stockés en JSON dans le champ `extra_shifts` du Dispatch Technician
### 5.5 RecurrenceSelector (Google Calendar-style)
Composant réutilisable offrant des options contextuelles basées sur la date de référence :
- "Ne se répète pas"
- "Tous les jours"
- "Toutes les semaines le [jour]"
- "Tous les mois le [Nème jour]"
- "Tous les ans le [date]"
- "Tous les jours de semaine (lunven)"
- **"Personnalisé..."** → éditeur complet :
- Fréquence : jour(s) / semaine(s) / mois / an(s)
- Intervalle (chaque N)
- Sélecteur de jours (pour hebdomadaire)
- Jour du mois (pour mensuel)
- Preview en temps réel du résultat
### 5.6 Système de tags/compétences
Chaque tag a un **niveau de compétence (1-5)** :
- Sur les techniciens : niveau de maîtrise (1=base, 5=expert)
- Sur les jobs : niveau minimum requis + flag obligatoire/optionnel
- **Auto-dispatch** : cherche le technicien avec compétence >= requise, choisit le plus bas adéquat (préserver les experts pour les jobs complexes)
- Tags existants : Fibre, Téléphonie, Fusionneur, Monteur, Installation, TV, Caméra IP, Urgence, Rive-Sud, Montréal...
### 5.7 Pool d'offres (Uber-style)
Système d'offres de travail inspiré d'Uber pour les ressources internes et externes :
- **3 modes** : Broadcast (tous), Targeted (techniciens spécifiques), Pool (compétences requises)
- **Tarification** : déplacement (150$) + taux horaire (125$/h) configurable
- **Flow** : créer offre → notifier par SMS → technicien accepte/décline → assignation automatique
- **Détection de surcharge** : alerte pulsante quand un technicien dépasse sa capacité
- **Panel latéral** : vue des offres actives avec statut de chaque réponse
### 5.8 Presets de groupes de ressources
- Sauvegarde de combinaisons de filtres de ressources (ex: "Équipe Montréal", "Techniciens fibre")
- **Stockés dans ERPNext** (Dispatch Preset doctype) → partagés entre superviseurs
- Barre rapide de presets sous la recherche
- Deux types : preset de groupe (filtre par groupe) et preset d'IDs (sélection spécifique)
### 5.9 Draft/Publish (brouillon/publié)
- Nouveau champ `published` sur Dispatch Job
- Jobs créés en mode brouillon (hachures diagonales)
- **Modal "Publier & Envoyer"** : sélection période + techniciens → publication en masse + envoi SMS résumé horaire
- Légende visuelle : solide = publié, hachuré = brouillon
### 5.10 Carte Mapbox
- Affichage des jobs géolocalisés avec couleurs par statut
- Routes optimisées par technicien (Mapbox Directions API)
- Géofixation : clic sur la carte pour positionner un job
- Tracking GPS temps réel via Traccar
### 5.11 Récurrence de jobs
- Toggle "Récurrence" sur chaque job
- Sélecteur RecurrenceSelector pour définir le pattern
- Date de fin de récurrence
- Pauses saisonnières (ex: hiver)
- Ghost blocks pour les occurrences futures (matérialisables individuellement)
---
## 6. targo-hub — Backend Node.js
Serveur Express central servant de passerelle entre les frontends et les services externes.
### Modules (30)
| Module | Connexion | Fonction |
|--------|-----------|----------|
| helpers.js | ERPNext REST | Wrapper erpFetch, httpRequest |
| sse.js | Navigateurs (EventSource) | Broadcast temps réel |
| devices.js | GenieACS NBI + PostgreSQL | Statut devices, cache, poller (5min) |
| device-extractors.js | — | Parsing TR-069 → objet résumé |
| device-hosts.js | GenieACS NBI | Topologie mesh WiFi |
| olt-snmp.js | OLT via SNMP v2c | Polling ONU, transitions d'état |
| twilio.js | Twilio API | SMS entrant/sortant, tokens voice |
| agent.js | Gemini API | Assistant IA SMS (tool-calling) |
| conversation.js | Disque local (JSON) | Persistence conversations |
| auth.js | Authentik API | Permissions RBAC (cache 60s) |
| pbx.js | 3CX API | Poller call logs (30s) |
| provision.js | ERPNext + n8n | Pré-autorisation OLT, scan/swap |
| telephony.js | Fonoster/Routr PostgreSQL | CRUD SIP (trunks, agents) |
| email.js | Nodemailer | Envoi emails |
| ical.js | — | Génération iCalendar |
| checkout.js | Stripe | Commandes, abonnements |
| magic-link.js | ERPNext | Authentification sans mot de passe |
| otp.js | Twilio SMS | Code OTP par SMS |
### SSE Topics
| Topic | Audience | Événements |
|-------|----------|------------|
| `customer:{name}` | Agents ops | Updates client temps réel |
| `conv:{token}` | Agents ops | Messages conversation |
| `conv-client:{token}` | Client web | Messages client |
| `conversations` | Agents ops | Feed global conversations |
| `sms-incoming` | Agents ops | Alerte nouveau SMS |
---
## 7. Applications secondaires
### Field App (apps/field/)
App mobile PWA pour les techniciens terrain :
- Liste des tâches du jour
- Scanner code-barres/QR (équipements)
- Diagnostic réseau et devices
- Mode offline avec sync différée
### Portail Client (apps/client/)
Self-service client :
- Consultation factures et paiements
- Gestion abonnements
- Tickets de support
- Catalogue produits/services
- Authentification via Authentik client (id.gigafibre.ca)
### Site Web (apps/website/)
Site vitrine React/Vite/Tailwind :
- Pages produits (Internet, TV, Téléphonie)
- Vérification d'éligibilité par adresse
- Commande en ligne
- FAQ et support
---
## 8. Migration depuis le legacy
### Données migrées
| Type | Volume | Script |
|------|--------|--------|
| Clients | 6 600+ | import_customers.py |
| Abonnements | ~8 000 | import_services_and_enrich.py |
| Factures | 115 000+ | import_invoices.py |
| Paiements | ~90 000 | import_payments.py |
| Tickets | 144 155 | migrate_tickets.py |
| Messages tickets | 448 379 | import_ticket_msgs.py |
| Équipements | 4 930+ | migrate_provisioning_data.py |
| Adresses | ~7 000 | migrate_locations.py |
### Pipeline de migration
```
Legacy MariaDB → Python scripts → ERPNext REST API → PostgreSQL
Ops App (lecture/écriture)
```
---
## 9. Intégrations externes
| Service | Usage | Protocole |
|---------|-------|-----------|
| **GenieACS** (10.5.2.115) | TR-069 ACS, 7 560 CPEs | NBI REST (LAN, no auth) |
| **Twilio** | SMS entrant/sortant, voix | REST API (Basic auth) |
| **Mapbox** | Cartes, géocodage, routes | JS SDK + Directions API |
| **Gemini AI** | OCR factures, assistant SMS | REST (Bearer token) |
| **Stripe** | Paiements en ligne | Checkout Sessions + webhooks |
| **Traccar** | GPS tracking techniciens | REST API (Basic auth) |
| **3CX PBX** | Historique appels | REST API (Basic auth) |
| **Fonoster/Routr** | SIP trunking, voix | PostgreSQL direct |
| **n8n** | Webhooks SMS, automatisations | HTTP webhooks |
| **DocuSeal** | Signatures électroniques | Container Docker |
| **Cloudflare** | DNS gigafibre.ca | REST API |
---
## 10. Déploiement
### Ops App
```bash
cd apps/ops
npx quasar build # Build PWA
scp -r dist/spa/* root@96.125.196.67:/opt/ops-app/ # Deploy (pas de restart nécessaire)
```
### targo-hub
```bash
scp services/targo-hub/server.js services/targo-hub/lib/*.js root@96.125.196.67:/opt/targo-hub/
ssh root@96.125.196.67 'docker restart targo-hub'
```
### ERPNext Custom Fields
```bash
python erpnext/setup_fsm_doctypes.py # Crée/met à jour les custom fields
```
---
## 11. Conventions de code
| Aspect | Convention |
|--------|-----------|
| Framework | Vue 3 Composition API, `<script setup>` exclusivement |
| State | Pinia stores (pas de Vuex) |
| UI | Composants Quasar (q-table, q-dialog, q-notify...) |
| Style | SCSS, variables CSS custom (--sb-*, --ops-*) |
| Thème | Light par défaut, dark pour dispatch uniquement |
| Langue UI | Français (labels, messages) |
| Langue code | Anglais (variables, fonctions, commentaires) |
| API | REST via authFetch (token injecté par nginx) |
| RRULE | RFC 5545 — FREQ/INTERVAL/BYDAY/BYMONTHDAY via buildRRule/parseRRule |
| Nommage | camelCase (JS), kebab-case (composants), UPPER_SNAKE (constantes) |
| Composables | Préfixe `use` (useScheduler, useMap, useResourceFilter...) |
---
## 12. Architecture des flux de données
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │────▶│ nginx │────▶│ ERPNext │
│ (Ops SPA) │ │ (proxy+ │ │ REST API │
│ │ │ token) │ │ :8000 │
└──────┬───────┘ └──────────────┘ └──────┬──────┘
│ │
│ SSE + REST PostgreSQL
▼ │
┌──────────────┐ ┌──────────────┐ ┌──────▼──────┐
│ targo-hub │────▶│ GenieACS │ │ ERPNext DB │
│ :3300 │ │ NBI :7557 │ │ :5432 │
│ │────▶│ Twilio API │ └─────────────┘
│ │────▶│ Gemini AI │
│ │────▶│ 3CX PBX │
│ │────▶│ Authentik │
└──────────────┘ └──────────────┘
```
---
## 13. Points d'attention opérationnels
1. **Traefik v2.11** — ne PAS upgrader à v3 (incompatible Docker 29)
2. **HTTP→HTTPS redirect** ne doit pas intercepter le challenge ACME
3. **MongoDB (Oktopus)** requiert AVX — CPU type "host" dans Proxmox
4. **Multi-network Docker** — conteneurs doivent avoir `traefik.docker.network=proxy`
5. **ERPNext PostgreSQL** — bugs GROUP BY/HAVING patchés manuellement
6. **Token API** — ne JAMAIS appeler `generate_keys` (invalide le token existant)
7. **Deploy SPA** — copier le CONTENU de dist/spa/*, pas le dossier
8. **Netplan** — peut overrider systemd-networkd, à supprimer si problème réseau

Binary file not shown.

View File

@ -0,0 +1,577 @@
# Analyse du système comptable legacy (facturation.targo.ca)
> Avril 2026 — Audit complet du système PHP/MariaDB pour migration vers ERPNext
---
## 1. Pages PHP activement utilisées (Apache access.log)
Classement par fréquence d'accès (logs récents) :
| Rang | Page (menu=) | Fichier PHP | Fonction | Accès |
|------|-------------|-------------|----------|-------|
| 1 | ticket_view | ticket_view.php | Consultation tickets | 47 964 |
| 2 | ticket_calendar | ticket_calendar.php | Calendrier dispatch tickets | 24 626 |
| 3 | client_view | client_view.php | Fiche client | 15 075 |
| 4 | client_list | client_list.php | Liste clients | 12 638 |
| 5 | ticket_open | ticket_open.php | Tickets ouverts | 6 571 |
| 6 | device_view | device_view.php | Fiche équipement | 3 664 |
| 7 | passwords_manager_list | passwords_manager_list.php | Gestionnaire de mots de passe | 3 662 |
| 8 | client_r | client_r.php | Recherche client | 2 731 |
| 9 | service_view | service_view.php | Détail service/abonnement | 1 898 |
| 10 | passwords_manager | passwords_manager.php | Mots de passe (détail) | 1 738 |
| 11 | invoice_view | invoice_view.php | Consultation facture | 1 231 |
| 12 | client_wizard | client_wizard.php | Wizard création client | 876 |
| 13 | pbx_list_pg | pbx_list_pg.php | Liste lignes PBX | 845 |
| 14 | ticket_new | ticket_new.php | Nouveau ticket | 839 |
| 15 | inventaire_remove | inventaire_remove.php | Retrait inventaire | 812 |
| 16 | fibre_adr_search | fibre_adr_search.php | Recherche adresse fibre | 751 |
| 17 | prorata | prorata.php | Calculateur pro-rata | 656 |
| 18 | payment_view | payment_view.php | Consultation paiement | 550 |
| 19 | payment_add | payment_add.php | Ajout paiement | 538 |
| 20 | tele_migration | tele_migration.php | Migration TV | 419 |
| 21 | invoice_add | invoice_add.php | Création facture | 338 |
| 22 | ticket_calendar_dispatch | ticket_calendar_dispatch.php | Dispatch calendrier | 291 |
| 23 | tele_channel_list | tele_channel_list.php | Liste chaînes TV | 268 |
| 24 | ticket_search | ticket_search.php | Recherche tickets | 158 |
| 25 | soumission | soumission.php | Soumissions/devis | 157 |
| 26 | inscription_add | inscription_add.php | Inscription client | 157 |
| 27 | cal_tax | rapport_tax.php | Rapport taxes | 152 |
| 28 | rapport_age_compte_payment | rapport_age_compte.php | Âge des comptes | 151 |
| 29 | raisecom_list | raisecom_list.php | Équipement Raisecom | 129 |
| 30 | client_add | client_add.php | Ajout client | 107 |
| 31 | report | report.php | Rapports généraux | 101 |
| 32 | task_generate_statement | task_generate_statement.php | Génération états de compte | 43 |
| 33 | service_add | service_add.php | Ajout service | 43 |
| 34 | accord_paiement | accord_paiement.php | Ententes de paiement | 25 |
| 35 | report_vente | report_vente.php | Rapport ventes mensuelles | 24 |
| 36 | invoice_credit | invoice_credit.php | Note de crédit | 23 |
| 37 | payment_accesd | payment_accesd.php | Paiement AccèsD | 14 |
| 38 | credit_creance | credit_creance.php | Crédit mauvaises créances | 10 |
---
## 2. Plan comptable complet (compta_comptes)
### Actifs
| # Compte | Description |
|----------|------------|
| 1000 | Encaisse PPA |
| 1001 | Encaisse Paiement direct |
| 1002 | Encaisse Carte de crédit |
| 1003 | Encaisse Comptant/Chèques |
| 1004 | Encaisse Ajustements |
| 1005 | Encaissement de crédit |
| 1050 | Petite caisse - bureau |
| 1060 | Petite caisse - service à la clientèle |
| 1100 | Caisse populaire |
| 1200 | Comptes à recevoir |
| 1205 | Provisions mauvaises créances |
### Passifs
| # Compte | Description |
|----------|------------|
| 2110 | Excédent |
| 2115 | Dépôt/Acompte client |
| 2300 | TPS perçue |
| 2305 | TPS payée |
| 2350 | TVQ perçue |
| 2355 | TVQ payée |
### Revenus (4000-4xxx)
| # Compte | Description |
|----------|------------|
| 4000 | Revenus autres |
| 4001 | Location espace cloud |
| 4010 | Honoraires |
| 4015 | Téléphonie |
| 4016 | Saisonniers (coupons-maraîchers) |
| 4017 | Installation et équipement fibre |
| 4018 | Équipement télé |
| 4019 | Mensualité télévision |
| 4020 | Mensualité fibre |
| 4021 | Mensualité Internet Haute-Vitesse sans fil |
| 4022 | Installation Internet Haute-Vitesse sans fil |
| 4023 | Équipement Internet Haute-Vitesse sans fil |
| 4024 | Téléchargement supplémentaire |
| 4025 | Garantie prolongée |
| 4026 | Section de Tour |
| 4027 | IP Fixe |
| 4028 | Frais d'activation |
| 4031 | Création de site Internet |
| 4041 | Nom de Domaine |
| 4042 | Hébergement |
| 4051 | Système Informatique |
| 4052 | Revenu - Service Informatique |
| 4054 | Pièces Informatiques |
| 4106 | Déplacement/temps technicien |
| 4250 | Intérêts et frais divers |
| 4260 | Frais divers taxables |
| 4300 | Temporaire |
### Dépenses / Charges
| # Compte | Description |
|----------|------------|
| 7575 | Frais PayPal |
| 7900 | Mauvaises créances |
| 7905 | Frais de recouvrement |
| 7910 | Déficit ou surplus de caisse |
---
## 3. Taxes — Configuration
### Taux de taxe
| ID | Nom | Taux | No. enregistrement |
|----|-----|------|-------------------|
| 1 | TPS | 5.0% | #834975559RT0001 |
| 2 | TVQ | 8.925% | #1213765929 (ancien) |
| 3 | TVQ | 8.5% | #1213765929 (ancien) |
| 4 | TVQ | 9.975% | #1213765929 (actuel) |
### Groupes de taxe
| ID | Nom | Taxes appliquées |
|----|-----|-----------------|
| 1 | Federal + Provincial (9.5%) | TPS (5%) + TVQ (9.975%) |
| 2 | Federal seulement | TPS (5%) |
| 3 | Aucune taxe | — |
| 4 | Provincial seulement | TVQ (8.5%) |
| 5 | Fed 5% + Prov 8.5% | TPS (5%) + TVQ (8.5%) |
### Comptes de taxe dans le journal
- **2300** = TPS perçue (CT sur vente, DT sur crédit)
- **2305** = TPS payée (DT sur achat)
- **2350** = TVQ perçue (CT sur vente, DT sur crédit)
- **2355** = TVQ payée (DT sur achat)
---
## 4. Codes de crédit
| ID | Description |
|----|------------|
| 1 | Raison inconnue |
| 2 | Correction d'une facture |
| 3 | Client sous garantie |
| 4 | Annulation Téléchargement supplémentaire |
| 5 | Entente prise |
---
## 5. Récurrence des services actifs
| Code | Fréquence | Services actifs |
|------|-----------|----------------|
| 0 | Annuel | 314 |
| 1 | Bi-mensuel | 43 |
| 2 | Mensuel | **37 435** |
| 3 | Charge unique | 255 |
| 4 | Aux 4 mois | 7 |
| 5 | Aux 6 mois | 1 471 |
| 6 | Aux 3 mois (trimestriel) | 98 |
---
## 6. Analyse détaillée des processus comptables
### 6.1 Facturation récurrente (task_charge_recurrent.php)
**Processus mensuel exécuté manuellement via l'interface web.**
**Étapes :**
1. L'opérateur choisit : date service ≤, date d'origine, date d'échéance
2. Option : surcharge de taxe, message à inclure sur les factures
3. Le script itère sur **tous les comptes** (`SELECT * FROM account`)
4. Pour chaque compte :
- Vérifie si une facture autobilling existe déjà pour cette date (dédoublonnage)
- Parcourt les `delivery` (points de livraison) → `service` (abonnements)
- Conditions de facturation :
- `date_next_invoice <= date_choisie`
- `service.status = 1` (actif)
- `account.status = 1` (actif)
- Calcule le prix : `quantité × prix_unitaire` (quantité basée sur la récurrence)
- Supporte le **hijack** de prix/description : prix custom par service
5. **Consommation excédentaire** (bandwidth overage) :
- Vérifie les quotas jour/nuit via `service_snapshot`
- Compare avec la consommation réelle (`conso_archive` ou `conso_radius_monthly2`)
- Si dépassement ≥ 1 GB : ajoute un item de facturation `HVGO × n GO × prix/GO`
6. **Frais de retard** :
- 2% du solde impayé des factures en souffrance
- Exception : clients VIP (1 mois de grâce), clients au recouvrement, clients avec PPA (pré-autorisé)
- Compte : 4250 (Intérêts et frais divers)
7. **Taxes** : Calculées selon le `tax_group` du client
8. **Écriture comptable** : Chaque facture génère :
- DT 1200 (Comptes à recevoir) = total
- CT [compte revenu selon produit] = sous-total par item
- CT 2300 (TPS perçue) = montant TPS
- CT 2350 (TVQ perçue) = montant TVQ
- Type écriture : `F` (Facture)
### 6.2 Création de facture manuelle (invoice_add.php)
Même logique comptable que la facturation récurrente, mais :
- L'opérateur saisit manuellement les items (SKU, description, prix, quantité)
- Le compte de revenu est déterminé par la catégorie du produit (`product_cat.num_compte`)
- Supporte un champ "dépense" (DT sur un compte spécifique, déduit du total)
- Options : sans taxe, surcharge de taxe, impression, envoi par email
- Écriture identique : DT 1200 + CT [revenus] + CT taxes
### 6.3 Note de crédit (invoice_credit.php)
**Logique inversée par rapport à la facture :**
- Items avec prix négatif (`unitary_price` = montant négatif)
- Écritures inversées :
- **CT 1200** (comptes à recevoir) = total du crédit
- **DT [compte revenu]** = montant par item
- **DT 2300** (TPS) = taxe TPS sur le crédit
- **DT 2350** (TVQ) = taxe TVQ sur le crédit
- Crée simultanément un `payment` de type `credit` pour le même montant
- `invoice.refund_status = 2` (Crédit)
- `invoice.billing_status = 1` (considéré comme payé)
- Associé à un `credit_code` pour catégoriser la raison
### 6.4 Paiement (payment_add.php)
**Méthodes de paiement :**
- PPA (pré-autorisé)
- Comptant
- Chèque
- Carte de crédit
- Paiement direct
- Crédit TARGO
**Processus :**
1. L'opérateur entre le client, montant, méthode, date
2. Le système affiche les factures impayées (via AJAX `payment_ajax_date.php`)
3. L'opérateur coche les factures à appliquer et les montants
4. L'excédent va dans le compte 2115 (Dépôt/Acompte client) ou 2110 (Excédent)
5. Met à jour `invoice.billed_amt` et `invoice.billing_status`
6. Crée les `payment_item` pour tracer l'application
### 6.5 Entente de paiement (accord_paiement.php)
**Fonctionnalités :**
- Création d'une entente avec : date accord, date paiement (échéance), montant, méthode
- Calcul automatique de la **date de coupure** basé sur la méthode :
- Comptant : +2 jours
- Chèque : +9 jours
- Carte crédit : +2 jours
- Paiement direct : +4 jours
- Indéfini : +3 jours
- Option "grâce férié" : +2 jours supplémentaires
- Envoi automatique d'email bilingue (FR/EN) au client via PHPMailer/Mailjet
- Statut : En attente / Respecté / Non respecté (forçable)
- BCC automatique à Comptabilite@targointernet.com
### 6.6 Crédit mauvaise créance (credit_creance.php)
**Pour radier les comptes irrécouvrables :**
1. L'opérateur spécifie le client et les factures à radier
2. Le système vérifie que le montant couvre exactement les factures
3. Reverse-engineer le montant hors taxe : `montant / (1 + somme_taux_taxes)`
4. Écritures :
- **CT 1200** (comptes à recevoir) = montant total
- **DT 2300** (TPS) = portion TPS
- **DT 2350** (TVQ) = portion TVQ
- **DT 7900** (mauvaises créances) = montant hors taxe
5. Marque les factures comme payées (billing_status = 1)
6. Crée un paiement de type `credit` pour tracer
### 6.7 État de compte (task_generate_statement.php)
**Processus de batch :**
1. Itère sur tous les comptes avec un solde impayé
2. Pour chaque compte : collecte les factures et paiements non soldés
3. Génère un PDF via DomPDF
4. Envoi par email (Mailjet SMTP) ou impression (lpr)
5. Message bilingue (FR/EN) avec liens vers le portail client
### 6.8 Soumissions/Devis (soumission.php)
**Fonctionnalités :**
- Création de devis avec items matériel et mensuels
- Sérialization PHP pour stocker les items (`serialize`/`unserialize`)
- Conversion en facture : crée automatiquement une facture + services mensuels
- Même logique comptable que `invoice_add.php`
- Création automatique de services récurrents pour les items mensuels
---
## 7. Rapports comptables
### 7.1 Rapport de ventes (report_vente.php)
- Sélection par intervalle de dates
- Pour chaque facture dans la période :
- Calcule le sous-total (`SUM(quantity × unitary_price)` de `invoice_item`)
- Récupère les taxes TPS/TVQ séparément de `invoice_tax`
- Sortie CSV : #Facture, #Client, SousTotal, TPS, TVQ, Total
### 7.2 Rapport de revenus par compte (report_revenu.php) — LE RAPPORT CLÉ
- Requête les comptes de revenus 4000-5000 dans `compta_comptes`
- Pour chaque mois dans l'intervalle sélectionné :
- `SELECT SUM(amount) FROM compta_journal_ecriture_detail d JOIN compta_journal_ecriture e WHERE d.num_compte = [compte] AND e.date_orig BETWEEN [début] AND [fin]`
- **CT** (crédit) = ajoute au revenu
- **DT** (débit) = soustrait du revenu (renversements, crédits)
- Génère un graphique stacked area (Google Charts) + CSV
### 7.3 Rapport de taxes (rapport_tax.php)
- Période mensuelle ou trimestrielle
- 4 comptes suivis :
- 2300 : TPS perçue (sur ventes)
- 2305 : TPS payée (sur achats)
- 2350 : TVQ perçue (sur ventes)
- 2355 : TVQ payée (sur achats)
- Revenu total des comptes 4000-6000
- Utilisé pour les déclarations TPS/TVQ
### 7.4 Rapport crédits/renversements (report_credit.php)
- Filtre les factures par `refund_status` :
- 1 = Renversement
- 2 = Crédit
- 3 = Remboursement
- Jointure avec `credit_code` pour la catégorisation
- Détails par client + totaux par type
### 7.5 Âge des comptes (rapport_age_compte.php)
- Buckets : Courant, 30 jours, 60 jours, 90 jours, 120+ jours
- Pour chaque compte actif :
- Calcule `total_amt - billed_amt` par bucket temporel
- Affiche le solde dû par tranche
### 7.6 Calculateur pro-rata (prorata.php)
- Outil manuel : saisie d'un intervalle de dates + montants
- Formule : `montant / jours_total_période × jours_dans_intervalle`
- Pas de logique automatisée — utilisé manuellement par le staff
---
## 8. Schéma de la double entrée comptable
```
FACTURE (vente):
DT 1200 (Comptes à recevoir) = TOTAL
CT [4xxx] (Revenu par catégorie) = Sous-total par item
CT 2300 (TPS perçue) = Taxe fédérale
CT 2350 (TVQ perçue) = Taxe provinciale
CRÉDIT/RENVERSEMENT:
CT 1200 (Comptes à recevoir) = TOTAL
DT [4xxx] (Revenu par catégorie) = Sous-total par item
DT 2300 (TPS perçue) = Taxe fédérale
DT 2350 (TVQ perçue) = Taxe provinciale
PAIEMENT:
CT 1200 (Comptes à recevoir) = Montant appliqué
DT [1000-1004] (Encaisse) = Selon méthode
DT 2115 (Dépôt client) = Excédent si applicable
MAUVAISE CRÉANCE:
CT 1200 (Comptes à recevoir) = TOTAL
DT 7900 (Mauvaises créances) = Montant hors taxe
DT 2300 (TPS) = Portion TPS
DT 2350 (TVQ) = Portion TVQ
```
---
## 9. Mapping vers ERPNext — Analyse de conformité
### ✅ Ce qu'ERPNext gère nativement
| Fonction legacy | Équivalent ERPNext | Notes |
|----------------|-------------------|-------|
| Invoice (facture) | Sales Invoice | Double entrée automatique |
| Invoice credit | Credit Note (return against) | Lié à la facture originale |
| Payment | Payment Entry | Application automatique aux factures |
| Tax groups | Sales Taxes and Charges Template | TPS + TVQ configurables |
| Chart of accounts | Chart of Accounts | Standard Canada (à personnaliser) |
| Account aging | Accounts Receivable report | Standard |
| Statement of Account | Statement of Account | Standard, envoi email |
| Product → revenue account | Item → Income Account (via Item Group) | Mapping par catégorie |
| Late fees | — | ⚠️ Pas natif, besoin custom |
| Pro-rata | — | Partiellement via Subscription |
### ⚠️ Ce qui nécessite du développement custom
| Fonction legacy | Complexité | Approche ERPNext |
|----------------|-----------|-----------------|
| **Facturation récurrente automatique** | Moyenne | ERPNext `Subscription` doctype — mais ne gère pas : hijack prix/desc par service, consommation bandwidth, frais de retard conditionnels |
| **Frais de retard (2% du solde)** | Moyenne | Script schedulé custom : requête AR aging, crée Sales Invoice pour frais |
| **Consommation excédentaire (bandwidth)** | Haute | Script custom : lire données RADIUS/conso, comparer quotas, créer items de facturation |
| **Hijack prix/description** | Basse | Utiliser `Subscription Plan` avec prix custom par abonnement (champ custom) |
| **Entente de paiement** | Moyenne | Doctype custom `Payment Agreement` avec workflow (statut, date coupure, email auto) |
| **Crédit mauvaise créance** | Basse | Journal Entry custom : DT 7900 + DT taxes + CT 1200 |
| **Soumissions → facture + services** | Moyenne | Quotation → Sales Invoice + création auto Subscription |
| **Rapport revenus par compte GL** | Basse | General Ledger report filtré par groupe de comptes |
| **Rapport taxes TPS/TVQ** | Basse | Tax report custom ou filtrage du GL |
| **Rapport crédits par code** | Basse | Custom report sur Credit Notes avec champ custom `credit_code` |
| **Calculateur pro-rata** | Basse | Déjà dans ERPNext pour les Subscriptions |
### ❌ Gaps critiques — ERPNext ne gère PAS nativement
1. **Facturation par point de livraison (delivery)** : Le legacy facture par "delivery" (adresse) avec des services distincts par delivery. ERPNext n'a pas ce concept — il faudrait soit un champ custom `service_location` sur les items de Subscription, soit un Subscription par adresse.
2. **Récurrences variées sur un même compte** : Un client peut avoir des services mensuels, semestriels et annuels. ERPNext `Subscription` ne supporte qu'une seule fréquence par subscription — il faudrait plusieurs Subscriptions par client.
3. **Période comptable fermée** : Le legacy a une variable `$month_close` / `$year_close` qui empêche de créer des écritures dans les périodes fermées. ERPNext a le concept d'`Accounting Period` mais la vérification est différente.
4. **Encaisse séparée par méthode de paiement** : Comptes 1000-1004 distincts (PPA, direct, carte, comptant). ERPNext utilise un seul compte bancaire par défaut — il faut créer des Mode of Payment avec des comptes distincts.
5. **Inventaire des mots de passe** (passwords_manager) : Aucun équivalent dans ERPNext — outil externe nécessaire (Vault, Bitwarden, etc.)
---
## 10. Scénarios complexes de facturation
### Scénario : Contrat 24 mois avec crédits récurrents
**Legacy (tel que décrit par l'opérateur) :**
```
Client signe Internet 100Mbps à 79.95$/mois × 24 mois
+ Crédit récurrent -20$/mois × 12 mois (promotion)
+ Crédit installation -10$/mois × 24 mois (amortissement installation)
= Mois 1-12 : 79.95 - 20 - 10 = 49.95$/mois
= Mois 13-24 : 79.95 - 10 = 69.95$/mois
= Mois 25+ : 79.95$/mois
Résiliation anticipée au mois 18 :
- Pénalité : solde restant des crédits d'installation (6 × 10$ = 60$)
- Frais de résiliation : selon conditions
```
**Implémentation dans le legacy :**
- Service principal avec `hijack_price` = 79.95
- Service crédit promotion avec `hijack_price` = -20, `actif_until` = date+12 mois
- Service crédit installation avec `hijack_price` = -10, `actif_until` = date+24 mois
- La résiliation anticipe est gérée manuellement (pas automatisée)
**Approche ERPNext proposée :**
- `Subscription` principal : item Internet 100Mbps, prix 79.95
- `Subscription Plan` avec `Pricing Rule` ou items de crédit négatifs
- Champs custom sur Subscription : `engagement_months`, `engagement_start`
- Script de résiliation custom : calcule la pénalité selon les mois restants
- ⚠️ Nécessite un développement custom significatif
### Scénario : Client avec plusieurs adresses et services mixtes
```
Client TARGO-1234 :
├── Adresse 1 (123 rue Principale)
│ ├── Internet Fibre 100Mbps (mensuel)
│ ├── Téléphonie VoIP (mensuel)
│ └── TV Premium (mensuel)
├── Adresse 2 (456 av. du Parc)
│ ├── Internet Sans-fil (semestriel)
│ └── IP Fixe (annuel)
└── Adresse 3 (789 ch. du Lac - saisonnier)
└── Internet Fibre 50Mbps (6 mois : mai-octobre)
```
**Legacy :** Chaque adresse = 1 `delivery`, chaque item = 1 `service` avec sa propre `date_next_invoice`
**ERPNext :** Nécessite 5+ Subscriptions avec des fréquences différentes, toutes liées au même Customer. Les items doivent référencer la Service Location. La facture unique mensuelle qui regroupe tout n'est pas le comportement par défaut d'ERPNext.
---
## 11. Tables legacy non migrées — Priorité
### Priorité CRITIQUE (comptabilité)
| Table | Records | Description | Migration |
|-------|---------|-------------|-----------|
| compta_journal_ecriture | ~500K+ | Écritures comptables | → GL Entry |
| compta_journal_ecriture_detail | ~1.5M+ | Détails écritures | → GL Entry |
| compta_comptes | 37 | Plan comptable | → Chart of Accounts ✅ |
| compta_setup | 1 | Période fermée | → Accounting Period |
| invoice | ~500K+ | Factures | → Sales Invoice (partiel ✅) |
| invoice_item | ~2M+ | Items de facture | → Sales Invoice Item |
| invoice_tax | ~1M+ | Taxes par facture | → Sales Taxes and Charges |
| payment | ~200K+ | Paiements | → Payment Entry |
| payment_item | ~400K+ | Application paiements | → Payment Entry Reference |
| credit_code | 5 | Codes de crédit | → Custom DocType |
| tax_group | 5 | Groupes de taxe | → Sales Taxes Template ✅ |
| tax | 4 | Taux de taxe | → Tax template ✅ |
### Priorité HAUTE (opérations)
| Table | Description | Migration |
|-------|-------------|-----------|
| service | Abonnements actifs | → Subscription |
| delivery | Points de livraison | → Service Location (partiel ✅) |
| product | Catalogue produits | → Item (partiel ✅) |
| product_cat | Catégories + comptes de revenu | → Item Group |
| product_translate | Traductions produits | → Item (description champ) |
| accord_paiement | Ententes de paiement | → Custom DocType |
| soumission | Devis/soumissions | → Quotation |
| deposit | Bordereaux de dépôt | → Bank Transaction |
### Priorité MOYENNE (support)
| Table | Description | Migration |
|-------|-------------|-----------|
| ticket / ticket_response | Tickets support | → Issue (partiel ✅) |
| account_note | Notes client | → Comment |
| attachments | Pièces jointes | → File |
| bon_travail | Bons de travail | → Work Order / Dispatch Job |
| inventaire | Inventaire équipement | → Service Equipment (partiel ✅) |
| passwords_manager | Mots de passe réseau | → Outil externe |
### Priorité BASSE (legacy/obsolète)
| Table | Description | Notes |
|-------|-------------|-------|
| conso_archive | Consommation bandwidth | Historique, pas de migration active |
| conso_radius_monthly2 | Consommation RADIUS | Historique |
| service_snapshot | Snapshots quotas | Historique |
| invoice_call | Appels interurbains | Fonction potentiellement obsolète |
| invoice_msg_template | Templates messages facture | → Print Format |
| staff | Personnel | → Employee ✅ |
| phone | Lignes téléphoniques | → Voix / PBX |
| ip | Adresses IP | → Service Equipment |
---
## 12. Recommandations — Ordre de migration
### Phase 1 : Fondations comptables
1. Mapper le plan comptable legacy → ERPNext Chart of Accounts
2. Configurer les taxes TPS/TVQ avec les bons comptes
3. Configurer les Mode of Payment avec comptes d'encaisse séparés (1000-1004)
4. Créer les Item Groups avec les comptes de revenu correspondants
### Phase 2 : Import historique
1. Importer les factures historiques (invoice → Sales Invoice)
2. Importer les paiements historiques (payment → Payment Entry)
3. Vérifier la balance de migration (comptes à recevoir)
### Phase 3 : Processus récurrents
1. Développer le module de facturation récurrente custom (basé sur Subscription mais avec les spécificités Targo)
2. Intégrer les frais de retard automatiques
3. Développer les ententes de paiement (custom doctype)
### Phase 4 : Rapports
1. Valider que le GL report reproduit le rapport de revenus
2. Développer le rapport de taxes TPS/TVQ
3. Adapter le rapport d'âge des comptes
### Phase 5 : Fonctionnalités avancées
1. Consommation excédentaire bandwidth
2. Soumissions → facturation automatique
3. Crédits mauvaises créances
4. Migration des données de consommation historiques (si nécessaire)

View File

@ -1,256 +0,0 @@
# Legacy → ERPNext Field Gap Analysis
Complete audit of every legacy database field against current ERPNext doctypes.
Goal: **Zero data loss** — every meaningful field must have a home in ERPNext.
---
## 1. Customer (legacy `account` table)
### Already Migrated
| Legacy Field | ERPNext Field | Doctype | Script |
|---|---|---|---|
| id | legacy_account_id | Customer | migrate_direct.py |
| first_name + last_name | customer_name | Customer | migrate_direct.py |
| email | email_billing | Customer (custom) | import_customer_details.py |
| customer_id | legacy_customer_id | Customer | migrate_direct.py |
| address1, city, state, zip | address_line, city, province, postal_code | Service Location | migrate_locations.py |
| invoice_delivery | invoice_delivery_method | Customer (custom) | import_customer_details.py |
| commercial | is_commercial | Customer (custom) | import_customer_details.py |
| mauvais_payeur | is_bad_payer | Customer (custom) | import_customer_details.py |
| tax_group | tax_category_legacy | Customer (custom) | import_customer_details.py |
| contact | contact_name_legacy | Customer (custom) | import_customer_details.py |
| mandataire | mandataire | Customer (custom) | import_customer_details.py |
| tel_home | tel_home | Customer (custom) | import_customer_details.py |
| tel_office | tel_office | Customer (custom) | import_customer_details.py |
| cell | cell_phone | Customer (custom) | import_customer_details.py |
| fax | fax | Customer (custom) | import_customer_details.py |
| misc | notes_internal | Customer (custom) | import_customer_details.py |
| frais | exclude_fees | Customer (custom) | import_customer_details.py |
| email_autre | email_publipostage | Customer (custom) | import_customer_details.py |
| date_orig | date_created_legacy | Customer (custom) | import_customer_details.py |
### GAP — Missing Fields (need custom fields + migration)
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|---|---|---|---|
| middle_name | Free text | `middle_name` Data | LOW |
| title | "M.", "Mme", etc. | `salutation_legacy` Data | LOW |
| ppa | 1,249 accounts | `ppa_enabled` Check | HIGH |
| ppa_name | Payer name | `ppa_name` Data | HIGH |
| ppa_code | Bank transit | `ppa_code` Data | HIGH |
| ppa_branch | Bank branch | `ppa_branch` Data | HIGH |
| ppa_account | Bank account | `ppa_account` Data | HIGH |
| ppa_amount | Amount | `ppa_amount` Currency | HIGH |
| ppa_amount_buffer | Buffer | `ppa_amount_buffer` Currency | MEDIUM |
| ppa_fixed | Fixed payment flag | `ppa_fixed` Check | MEDIUM |
| ppa_cc | PPA via credit card | `ppa_cc` Check | MEDIUM |
| ppa_all_invoice | Apply to all invoices | `ppa_all_invoice` Check | MEDIUM |
| vip | 28 accounts | `is_vip` Check | MEDIUM |
| stripe_id | 785 accounts | `stripe_customer_id` Data | HIGH |
| stripe_ppa | Stripe auto-pay flag | `stripe_ppa_enabled` Check | HIGH |
| land_owner | Property owner flag | `is_land_owner` Check | LOW |
| keyword | Search keyword | `search_keyword` Data | LOW |
| pub | Marketing opt-in | `marketing_optin` Check | MEDIUM |
| call | Call contact flag | `call_contact` Check | LOW |
| username | Portal login | `portal_username` Data | LOW |
| password | Portal password (MD5) | `portal_password_hash` Data | LOW |
| terminate_reason | Reason for termination | `terminate_reason` Small Text | MEDIUM |
| terminate_cie | Competitor they left for | `terminate_cie` Data | MEDIUM |
| terminate_note | Termination notes | `terminate_note` Small Text | MEDIUM |
| terminate_date | Date terminated | `terminate_date` Date | MEDIUM |
| notes_client | Client-visible notes | `notes_client` Small Text | MEDIUM |
| address2 | Address line 2 | *Already in Service Location* | — |
---
## 2. Service Subscription (legacy `service` table)
### Already Migrated
| Legacy Field | ERPNext Field | Script |
|---|---|---|
| id | legacy_service_id | import_services_and_enrich_customers.py |
| delivery_id | service_location (via map) | import_services_and_enrich_customers.py |
| device_id | device (Link to Service Equipment) | import_services_and_enrich_customers.py |
| product_id | product_sku | import_services_and_enrich_customers.py |
| status | status (mapped to Actif/Suspendu/Annulé) | import_services_and_enrich_customers.py |
| comment | notes | import_services_and_enrich_customers.py |
| payment_recurrence | billing_cycle | import_services_and_enrich_customers.py |
| hijack_price | monthly_price (when hijack=1) | import_services_and_enrich_customers.py |
| hijack_download_speed | speed_down (when hijack=1) | import_services_and_enrich_customers.py |
| hijack_upload_speed | speed_up (when hijack=1) | import_services_and_enrich_customers.py |
| date_orig | start_date | import_services_and_enrich_customers.py |
| date_end_contract | end_date | import_services_and_enrich_customers.py |
| radius_user | radius_user | import_services_and_enrich_customers.py |
| radius_pwd | radius_password | import_services_and_enrich_customers.py |
### GAP — Missing Fields
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|---|---|---|---|
| hijack | Override flag (bool) | `is_custom_pricing` Check | MEDIUM |
| hijack_desc | Override description | `custom_pricing_desc` Data | MEDIUM |
| hijack_quota_day | Day bandwidth quota | `quota_day_gb` Float | LOW |
| hijack_quota_night | Night bandwidth quota | `quota_night_gb` Float | LOW |
| date_suspended | Suspension date | `date_suspended` Date | HIGH |
| actif_until | Active-until date | `active_until` Date | MEDIUM |
| date_next_invoice | Next invoice date | `next_invoice_date` Date | HIGH |
| forfait_internet | Internet bundle flag | `forfait_internet` Check | LOW |
| radius_conso | RADIUS consumption tracking | `radius_consumption` Data | LOW |
| ip_fixe | Static IP address | `static_ip` Data | MEDIUM |
---
## 3. Service Equipment (legacy `device` table)
### Already Migrated
| Legacy Field | ERPNext Field | Script |
|---|---|---|
| id | legacy_device_id | import_devices_and_enrich.py |
| delivery_id | service_location (via map) | import_devices_and_enrich.py |
| category | equipment_type (mapped) | import_devices_and_enrich.py |
| sn | serial_number | import_devices_and_enrich.py |
| mac | mac_address | import_devices_and_enrich.py |
| manufacturier | brand | import_devices_and_enrich.py |
| model | model | import_devices_and_enrich.py |
| manage / manage_cli | ip_address | import_devices_and_enrich.py |
| user | login_user | import_devices_and_enrich.py |
| pass | login_password | import_devices_and_enrich.py |
### GAP — Missing Fields
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|---|---|---|---|
| manage | Full management URL/IP | `manage_url` Data | HIGH |
| port | Management port | `manage_port` Int | HIGH |
| protocol | Management protocol (http/https/ssh) | `manage_protocol` Select | HIGH |
| manage_cli | CLI access IP | `cli_ip` Data | HIGH |
| port_cli | CLI port | `cli_port` Int | HIGH |
| protocol_cli | CLI protocol (ssh/telnet) | `cli_protocol` Select | HIGH |
| parent | Parent device ID | `parent_device` Link (Service Equipment) | HIGH |
| category | Exact legacy category string | `legacy_category` Data | MEDIUM |
| name (legacy) | Device display name | `device_name` Data | LOW |
### GAP — Provisioning Fields (from GenieACS MariaDB)
| Source | Field | Proposed ERPNext Field | Priority |
|---|---|---|---|
| GenieACS wifi table | ssid | `wifi_ssid` Data | HIGH |
| GenieACS wifi table | password | `wifi_password` Password | HIGH |
| GenieACS voip table | username | `sip_username` Data | HIGH |
| GenieACS voip table | password | `sip_password` Password | HIGH |
| GenieACS devices | CWMP serial | `cwmp_serial` Data | HIGH |
| GenieACS devices | GPON serial | `gpon_serial` Data | HIGH |
| Fibre table | line_profile | `fibre_line_profile` Data | MEDIUM |
| Fibre table | service_profile | `fibre_service_profile` Data | MEDIUM |
### GAP — ACS Integration Fields
| Purpose | Proposed ERPNext Field | Priority |
|---|---|---|
| GenieACS/Oktopus device ID | `acs_device_id` Data | HIGH |
| Last ACS inform time | `acs_last_inform` Datetime | MEDIUM |
| ACS online status | `acs_online` Check | MEDIUM |
| WAN IP from ACS | `wan_ip` Data | MEDIUM |
---
## 4. Service Location (legacy `delivery` + `fibre` tables)
### Already Migrated
| Legacy Field | ERPNext Field | Script |
|---|---|---|
| delivery.id | legacy_delivery_id | migrate_locations.py |
| delivery.address1 | address_line | migrate_locations.py |
| delivery.city | city | migrate_locations.py |
| delivery.state | province | migrate_locations.py |
| delivery.zip | postal_code | migrate_locations.py |
| delivery.account_id | customer (via map) | migrate_locations.py |
| fibre.frame/slot/port/ontid | olt_port | import_devices_and_enrich.py |
| fibre.vlan_* | network_id (concatenated) | import_devices_and_enrich.py |
| *connection_type inferred* | connection_type | import_devices_and_enrich.py |
### GAP — Missing Fields from `delivery`
| Legacy Field | Proposed ERPNext Field | Priority |
|---|---|---|
| address2 | `address_line_2` Data | MEDIUM |
| contact | `contact_name` (already exists) | — |
| phone | `contact_phone` (already exists) | — |
| note | `delivery_notes` Small Text | MEDIUM |
| appartement | `apartment_number` Data | MEDIUM |
### GAP — Missing Fields from `fibre`
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|---|---|---|---|
| id | 16,056 entries | `legacy_fibre_id` Int | MEDIUM |
| sn | ONT serial number | `ont_serial` Data | HIGH |
| olt_ip (info_connect) | OLT management IP | `olt_ip` Data | HIGH |
| olt_name (from fibre_olt) | OLT display name | `olt_name` Data | MEDIUM |
| ontid | ONT ID on OLT port | `ont_id` Int | HIGH |
| terrain | Property type | `terrain_type` Data | LOW |
| distance | Distance from OLT | `fibre_distance_m` Float | LOW |
| nb_portees | Number of spans | `fibre_spans` Int | LOW |
| temps_estim | Estimated install time | `install_time_estimate` Data | LOW |
| suite | Apartment indicator | `is_apartment` Check | LOW |
| boitier_pas_install | Box not installed flag | `box_not_installed` Check | LOW |
| vlan_manage | Management VLAN (individual) | `vlan_manage` Int | MEDIUM |
| vlan_internet | Internet VLAN | `vlan_internet` Int | MEDIUM |
| vlan_telephone | Telephone VLAN | `vlan_telephone` Int | MEDIUM |
| vlan_tele | TV VLAN | `vlan_tv` Int | MEDIUM |
| manage_service_id | Mgmt service link | `manage_service_id` Int | LOW |
| internet_service_id | Internet service link | `internet_service_id` Int | LOW |
| telephone_service_id | Phone service link | `telephone_service_id` Int | LOW |
| tele_service_id | TV service link | `tv_service_id` Int | LOW |
| placemarks_id | Map placement ID | `placemarks_id` Data | LOW |
| appartements_id | Apartment building ID | `apartment_building_id` Data | LOW |
---
## 5. device_attr (key-value pairs, 497 entries)
No ERPNext equivalent exists. Contains per-device extended attributes:
- **MAC addresses per interface** (eth0 through eth5) — multiple MACs per device
- **stb_id** — Ministra/IPTV subscription ID
- **Custom OLT references**
- **Router routes** (static routes configured on device)
### Proposed Solution
| Attribute Key | Proposed ERPNext Field | On Doctype | Priority |
|---|---|---|---|
| mac_ethX | `mac_addresses_json` Long Text (JSON) | Service Equipment | MEDIUM |
| stb_id | `iptv_subscription_id` Data | Service Equipment | HIGH |
| OLT refs | Already captured via fibre table | Service Location | — |
---
## 6. Dispatch Job (legacy `bon_travail` table)
### Already Migrated
Dispatch Job doctype exists with custom FSM fields. However:
### GAP — Missing Fields from `bon_travail`
| Legacy Field | Proposed ERPNext Field | Priority |
|---|---|---|
| tech1_id | Already `assigned_to` on Dispatch Job | — |
| tech2_id | `second_technician` Data | MEDIUM |
| tech1_arrive | `tech1_arrival` Datetime | MEDIUM |
| tech1_depart | `tech1_departure` Datetime | MEDIUM |
| tech2_arrive | `tech2_arrival` Datetime | MEDIUM |
| tech2_depart | `tech2_departure` Datetime | MEDIUM |
| line items (bon_travail_item) | Already `equipment_items` + `materials_used` child tables | — |
---
## Summary: Custom Fields to Add
### Customer — 23 new fields
PPA section (8), Stripe (2), Termination (4), VIP/flags (3), Portal (2), Other (4)
### Service Subscription — 10 new fields
Custom pricing (3), Dates (3), Quotas (2), IP (1), Other (1)
### Service Equipment — 17 new fields
Management (6), Provisioning (8), ACS (4), Legacy (1)
### Service Location — 18 new fields
Fibre infrastructure (10), VLANs (4), Legacy IDs (4)
### Dispatch Job — 5 new fields
Second tech + time tracking (5)
**Total: ~73 custom fields across 5 doctypes**

View File

@ -1,223 +0,0 @@
# Plan de migration — Legacy Facturation → ERPNext
## 1. Portrait du système legacy
### Base de données : `gestionclient` (MariaDB sur 10.100.80.100)
| Table | Records | Description |
|-------|---------|-------------|
| account | 15,303 | Clients (6,510 actifs, 8,594 terminés) |
| delivery | 17,114 | Adresses d'installation (GPS inclus) |
| service | 66,879 | Abonnements (39,624 actifs) |
| product | 833 | Catalogue forfaits/produits |
| product_cat | 34 | Catégories produits |
| invoice | 629,944 | Factures |
| invoice_item | 1,859,260 | Lignes de facture |
| payment | 540,522 | Paiements |
| payment_item | 684,778 | Application paiements → factures |
| compta_journal_ecriture | 1,211,991 | Écritures comptables |
| compta_comptes | 48 | Plan comptable |
| ticket | 242,618 | Tickets support/install/facturation |
| ticket_msg | 784,290 | Messages tickets (8,123 avec images base64) |
| device | 10,377 | Équipements réseau |
| fibre | 16,057 | Raccordements fibre (OLT, VLAN, ports) |
| staff | 155 | Employés |
| bon_travail | 14,472 | Bons de travail technicien |
| tax | 4 | TPS/TVQ |
### Revenu mensuel actif (~$742K/mois)
| Catégorie | Services actifs | Revenu mensuel |
|-----------|----------------|----------------|
| Mensualités fibre | 17,050 | $454,364 |
| Mensualités sans fil | 4,200 | $189,799 |
| Mensualités télévision | 2,001 | $46,360 |
| Téléphonie | 2,236 | $34,004 |
| Installation fibre | 10,926 | $7,285 |
| Adresse IP fixe | 138 | $3,485 |
| Autres | ~70 | ~$7K |
### Relations entre tables
```
account (client)
├── 1:N delivery (adresses d'installation, GPS)
│ └── 1:N fibre (raccordement OLT, VLAN, ports)
├── 1:N service (abonnements actifs)
│ ├── N:1 product (forfait, prix, vitesses)
│ ├── N:1 delivery (lieu du service)
│ └── N:1 device (équipement assigné)
├── 1:N invoice (factures)
│ └── 1:N invoice_item (lignes → product)
├── 1:N payment (paiements)
│ └── 1:N payment_item (répartition sur factures)
└── 1:N ticket (support, install, réparation)
└── 1:N ticket_msg (messages, images base64)
product
├── N:1 product_cat (catégorie → num_compte)
└── vitesses (download_speed, upload_speed, quota)
compta_comptes (plan comptable, 48 comptes)
└── 1:N compta_journal_ecriture (1.2M écritures)
staff (155 employés)
└── ticket.assign_to, bon_travail.tech1/tech2
```
---
## 2. Mapping Legacy → ERPNext
### Phase A : Données maîtres (référence)
| Legacy | ERPNext | Notes |
|--------|---------|-------|
| `account` | **Customer** | customer_id, company, group_id, statut, PPA, Stripe |
| `account` (adresse) | **Address** | address1, city, zip, linked to Customer |
| `account` (contact) | **Contact** | first_name, last_name, email, tel, cell |
| `delivery` | **Address** (type=Shipping) + **Service Location** (FSM) | GPS, name, lié au Customer |
| `product` | **Item** (type=Service) | SKU, prix, vitesses dans custom fields |
| `product_cat` | **Item Group** | Hiérarchie catégories, num_compte pour comptabilité |
| `tax` / `tax_group` | **Tax Template** | TPS 5% + TVQ (8.925% → 9.975%) |
| `staff` | **Employee** | username, email, dept |
| `device` | **Asset** ou **Service Equipment** (FSM) | SN, MAC, modèle, parent device |
| `fibre` | Champs dans **Service Location** | OLT info, VLAN, frame/slot/port |
| `compta_comptes` | **Chart of Accounts** | 48 comptes → plan comptable ERPNext |
| `ticket_dept` | **Issue Type** ou **Department** | 21 départements de tickets |
### Phase B : Données transactionnelles
| Legacy | ERPNext | Volume | Notes |
|--------|---------|--------|-------|
| `invoice` + `invoice_item` | **Sales Invoice** + Items | 630K factures | Importer seulement les 2-3 dernières années ? |
| `payment` + `payment_item` | **Payment Entry** + References | 540K | Idem — soldes historiques en opening balance |
| `service` (actifs) | **Subscription** | 39,624 | Critique — abonnements récurrents en cours |
| `compta_journal_ecriture` | **Journal Entry** | 1.2M | Opening balance uniquement — pas d'import ligne par ligne |
| `ticket` + `ticket_msg` | **Issue** ou import dans HD | 242K | Les fermés → archive, ouverts → migration |
| `bon_travail` | **Dispatch Job** (FSM) | 14K | Historique tech |
### Phase C : Fichiers et images
| Source | Volume | Stratégie |
|--------|--------|-----------|
| `ticket_msg` base64 images | ~8,123 messages | Extraire → fichier → upload ERPNext File → remplacer dans texte |
| `intranet_doc` attachments | ~33 docs | Copier fichiers → ERPNext File |
| `uploads/` directory | À vérifier | Copier vers ERPNext sites/[site]/public/files/ |
---
## 3. Stratégie de migration
### Principes
1. **Ne pas tout migrer** — Les données historiques fermées (factures payées, tickets clos) restent consultables dans le legacy en lecture seule
2. **Opening balance** — Un seul Journal Entry avec les soldes de chaque compte au jour J de migration
3. **Services actifs = priorité #1** — Les 39,624 abonnements actifs doivent être fonctionnels dans ERPNext avant le cutover
4. **Coexistence temporaire** — Les deux systèmes roulent en parallèle pendant la phase de validation (1-2 mois)
5. **Images base64** — Extraites en fichiers, uploadées dans ERPNext, lien URL dans le champ texte
### Phases d'exécution
#### Phase 1 : Fondations (Semaine 1-2)
- [ ] Créer le Chart of Accounts dans ERPNext (48 comptes)
- [ ] Configurer les Tax Templates (TPS 5% + TVQ 9.975%)
- [ ] Créer les Item Groups (34 catégories produits)
- [ ] Importer les 833 Items (produits/forfaits) avec custom fields (vitesses, quota, profils OLT)
- [ ] Configurer les Pricing Rules si nécessaire
#### Phase 2 : Clients et adresses (Semaine 2-3)
- [ ] Importer les Customers actifs (6,510) avec mapping group_id → Customer Group
- [ ] Importer les Addresses (delivery) avec GPS → Service Location (FSM)
- [ ] Importer les Contacts (email, téléphones)
- [ ] Lier fibre data aux Service Locations (OLT, VLAN, ports)
- [ ] Importer les Employees (155 staff)
#### Phase 3 : Abonnements actifs (Semaine 3-4)
- [ ] Créer les Subscription Plans dans ERPNext
- [ ] Importer les 39,624 services actifs comme Subscriptions
- [ ] Valider : chaque service → bon Customer, bon Item, bon prix, bonne adresse
- [ ] Gérer les `hijack` (prix spéciaux par service) → Pricing Rule ou custom field
#### Phase 4 : Soldes et comptabilité (Semaine 4-5)
- [ ] Calculer les soldes de chaque compte client (factures - paiements)
- [ ] Créer l'Opening Balance dans ERPNext (1 Journal Entry)
- [ ] Valider que le total des comptes à recevoir correspond
- [ ] Importer les factures des 12 derniers mois (optionnel, pour historique)
#### Phase 5 : Cutover (Semaine 5-6)
- [ ] Freeze le legacy (lecture seule)
- [ ] Run final delta sync (nouvelles transactions depuis dernier import)
- [ ] Activer la facturation récurrente dans ERPNext
- [ ] Rediriger les flux PPA/Stripe vers ERPNext
- [ ] Formation utilisateurs
- [ ] Go-live
#### Phase 6 : Post-migration (Semaine 7+)
- [ ] Migrer les tickets ouverts (904 open + 487 pending)
- [ ] Extraire et uploader les images base64 des ticket_msg
- [ ] Legacy en mode archive (consultation seulement)
- [ ] Supprimer les accès écriture au legacy
---
## 4. Risques et décisions
### Décisions à prendre
1. **Historique des factures** — Combien d'années importer ? Suggestion : 2 ans pour référence, opening balance pour le reste
2. **Tickets historiques** — Garder le legacy en consultation ou tout migrer vers Issue ?
3. **PPA (pré-autorisé bancaire)** — ERPNext supporte GoCardless/Stripe mais pas AccesD directement. Middleware requis ?
4. **Numérotation** — Garder les IDs legacy (customer_id, invoice_id) dans un champ `legacy_id` pour traçabilité ?
5. **Portail client** — account_profile (659 profils) → ERPNext Portal ou www.gigafibre.ca custom ?
6. **Camping** — Tables `camping_*` encore utilisées ?
7. **RADIUS** — Les services ont `radius_user/radius_pwd`. Intégration FreeRADIUS → ERPNext ?
### Risques
| Risque | Impact | Mitigation |
|--------|--------|------------|
| Soldes incorrects après migration | Clients facturés en double ou manquant | Réconciliation avant/après avec rapport |
| PPA cassé pendant cutover | Perte de revenus | Coexistence : legacy facture, ERPNext prend le relais progressivement |
| Abonnements manquants | Client sans service | Script de validation : service legacy actif → Subscription ERPNext existe |
| Images base64 perdues | Contexte tickets perdu | Extraction batch avant cutover |
| Performance ERPNext avec 630K factures | Lenteur | Import seulement 2 ans, opening balance pour le reste |
---
## 5. Scripts de migration (à développer)
```
migration/
01_chart_of_accounts.py # compta_comptes → Chart of Accounts
02_tax_templates.py # tax → Tax Templates
03_item_groups.py # product_cat → Item Group
04_items.py # product → Item
05_customers.py # account → Customer + Address + Contact
06_service_locations.py # delivery + fibre → Service Location
07_employees.py # staff → Employee
08_equipment.py # device → Service Equipment
09_subscriptions.py # service (actifs) → Subscription
10_opening_balance.py # Soldes calculés → Journal Entry
11_recent_invoices.py # invoice (2 ans) → Sales Invoice
12_recent_payments.py # payment (2 ans) → Payment Entry
13_open_tickets.py # ticket (ouverts) → Issue
14_extract_base64_images.py # ticket_msg base64 → fichiers
15_validation.py # Réconciliation legacy vs ERPNext
```
Chaque script utilise le **MCP ERPNext** (Frappe Assistant Core) pour créer les documents directement, avec validation et rollback en cas d'erreur.
---
## 6. Données à NE PAS migrer
- `compta_journal_ecriture` complet (1.2M) → opening balance seulement
- `invoice` > 2 ans → opening balance
- `ticket` fermés (241K) → consultables dans legacy
- `ticket_msg` fermés → consultables dans legacy
- `conso_*` (consommation RADIUS) → données opérationnelles, pas comptables
- `phonecall_log_*` → historique téléphonique
- `tmp*`, `*_bk`, `*_archive` → backup tables
- `camping_*` → à confirmer si encore utilisé
- `passwords_manager*` → ne jamais migrer les mots de passe tiers

135
docs/MIGRATION.md Normal file
View File

@ -0,0 +1,135 @@
# Legacy to ERPNext Migration
## 1. Legacy System
Source database: **gestionclient** (MariaDB on 10.100.80.100). Monthly recurring revenue ~$742K across fibre, wireless, TV, and telephony.
| Table | Records | Description |
|---|---|---|
| account | 15,303 | Customers (6,510 active, 8,594 terminated) |
| delivery | 17,114 | Installation addresses with GPS |
| service | 66,879 | Subscriptions (39,624 active) |
| product / product_cat | 833 / 34 | Catalogue items and categories |
| invoice / invoice_item | 630K / 1.86M | Invoices and line items |
| payment / payment_item | 541K / 685K | Payments and allocations |
| compta_comptes / ecritures | 48 / 1.2M | Chart of accounts and journal entries |
| ticket / ticket_msg | 243K / 784K | Support tickets and messages (8K with base64 images) |
| device / fibre | 10,377 / 16,057 | Network equipment and fibre connections |
| staff / bon_travail | 155 / 14,472 | Employees and work orders |
## 2. Legacy to ERPNext Mapping
| Legacy Table | ERPNext DocType | Records | Status |
|---|---|---|---|
| account | Customer + Contact + Address | 6,667 + 6,600 + 6,700 | Migrated |
| delivery + fibre | Service Location | 17,114 | Migrated |
| product | Item (type=Service) | 833 | Migrated |
| product_cat | Item Group | 34 | Migrated |
| service (active) | Subscription | 21,876 | Migrated (scheduler paused) |
| invoice + invoice_item | Sales Invoice | 630K | Opening balance only |
| payment + payment_item | Payment Entry | 99K imported | Migrated (recent) |
| compta_comptes | Account (Chart) | 48 | Mapped to ERPNext CoA |
| compta_journal_ecriture | Journal Entry | 1.2M | Opening balance only |
| ticket + ticket_msg | Issue + Communication | 243K + 784K | Migrated |
| device | Service Equipment | 10,377 | Migrated |
| staff | Employee / User | 45 users | Migrated |
| bon_travail | Dispatch Job | 14,472 | Pending |
| tax | Sales Taxes Template | 4 | Migrated (TPS 5% + TVQ 9.975%) |
## 3. Key Architecture Differences
- **Naming**: Legacy uses auto-increment IDs; ERPNext uses naming series (CUST-.YYYY.-, LOC-.#####)
- **Customer/Address**: Legacy stores everything in `account`; ERPNext separates Customer, Address, and Contact as linked doctypes
- **Subscriptions**: Legacy `service` is a static row; ERPNext `Subscription` auto-generates Sales Invoices each billing cycle
- **Custom Pricing**: Legacy `hijack_price` override on service; ERPNext uses `additional_discount` on Subscription or Pricing Rules
- **Taxes**: Legacy calculates TPS/TVQ in PHP code; ERPNext applies Sales Taxes Templates automatically
- **Accounting**: Legacy writes journal entries via PHP; ERPNext auto-creates GL Entries from submitted Sales Invoices
- **Permissions**: Legacy uses `staff.rights` (PHP serialized); ERPNext uses Role-based DocType permissions
- **Files**: Legacy stores images as base64 in mediumtext columns; ERPNext uses File doctype with `/files/` directory
- **Dates**: Legacy uses Unix timestamps (bigint); ERPNext uses ISO dates (YYYY-MM-DD)
- **RADIUS**: Legacy stores credentials on `service`; ERPNext uses custom fields on `Subscription`
## 4. Migration Phases
1. **Foundation** -- Done (2026-03-28)
- 34 Item Groups, 833 Items with ISP custom fields (speeds, quotas, fibre profiles)
- Tax Templates: TPS 5% + TVQ 9.975%
- 15 custom fields created across Item, Customer, and Subscription doctypes
2. **Customers & Addresses** -- Done (2026-03-28)
- 6,667 Customers, ~6,600 Contacts, ~6,700 Addresses via direct PostgreSQL (~30s)
- Customer-Contact-Address links established with GPS coordinates
3. **Subscriptions & Items** -- Done (2026-03-28)
- 92 Subscription Plans created from active products
- 21,876 Subscriptions imported with RADIUS data
- **Scheduler PAUSED** to prevent automatic invoice generation
4. **Invoices & Payments** -- Done (2026-03-29)
- Outstanding invoice analysis and AR reconciliation
- 99,000 payments imported with invoice references and mode mapping
5. **Opening Balances** -- Done (2026-03-29)
- Customer balance reconciliation (invoices minus payments)
- Opening balance journal entries created
6. **Tickets** -- Done (2026-03-29, 242K)
- 242,618 tickets migrated as Issues with parent/child hierarchy
- 784,290 ticket messages imported as Communications
- Issue types mapped from 21 legacy departments
- Staff assignment mapped from legacy IDs to ERPNext User emails
7. **Staff & Memos** -- Done (2026-03-29)
- 45 ERPNext Users created from legacy staff table
- 29,000 customer memos imported as Comments with original creation dates
## 5. Field Gap Analysis
~73 custom fields needed across 5 doctypes to reach zero data loss.
| DocType | Gaps | Key Missing Areas |
|---|---|---|
| Customer | ~23 | PPA banking (8 fields), Stripe (2), termination reason/date (4), VIP/flags (3), portal login (2), other (4) |
| Subscription | ~10 | Custom pricing flag/desc (3), suspension/active-until dates (3), quotas (2), static IP (1), other (1) |
| Service Equipment | ~17 | Management access (6: URL, port, protocol for GUI+CLI), provisioning from GenieACS (8: WiFi, VoIP, GPON/CWMP serials), ACS integration (4) |
| Service Location | ~18 | Fibre infrastructure (10: ONT serial, OLT IP/name, ONT ID, distance, spans), individual VLANs (4), legacy service IDs (4) |
| Dispatch Job | ~5 | Second technician + arrival/departure timestamps (5) |
Already migrated fields per doctype: Customer ~18, Subscription ~14, Equipment ~10, Location ~9.
## 6. Execution Log
- **2026-03-28**: Frappe Assistant Core v2.3.3 installed, legacy MariaDB access granted. 34 Item Groups + 833 Items imported. 15 custom fields created (Item 7, Customer 4, Subscription 3). 6,667 Customers + Contacts + Addresses via direct PG in ~30s. 92 Subscription Plans + 21,876 Subscriptions with RADIUS data. Scheduler paused.
- **2026-03-29**: Opening balance + AR analysis. 242K tickets + 784K messages migrated as Issues/Communications. 45 Users created. 29K customer memos imported. 99K payments imported.
- **2026-03-30**: Ops App V2 with ClientDetailPage, Dispatch module, Equipment page, OCR. Field Tech App (PWA with barcode scanner, offline support).
- **2026-03-31**: Inline editing (InlineField component), client search with legacy ID + or_filters, SMS/Email notifications via n8n/Twilio/Mailjet, ticket reply via Communication docs, Authentik federation (auth.targo.ca to id.gigafibre.ca).
## 7. Data NOT Migrated
- **Journal entries** (1.2M) -- opening balance only, no line-by-line import
- **Invoices > 2 years** -- covered by opening balance
- **Closed tickets** (241K) -- available in legacy read-only archive
- **RADIUS consumption** (`conso_*`) -- operational data, not financial
- **Phone call logs** (`phonecall_log_*`) -- historical records
- **Temp/backup tables** (`tmp*`, `*_bk`, `*_archive`)
- **Camping tables** (`camping_*`) -- confirm if still in use
- **Password manager** (`passwords_manager*`) -- never migrate third-party credentials
- **Work orders** (`bon_travail`, 14K) -- historical, Dispatch Job doctype ready for future import
## 8. Risks & Decisions
**Risks**:
- Incorrect balances after migration -- mitigated by before/after reconciliation reports
- PPA payment disruption during cutover -- mitigated by parallel operation (legacy bills, ERPNext takes over gradually)
- Missing subscriptions -- validation script compares active legacy services against ERPNext Subscriptions
- Lost base64 images -- batch extraction scheduled before cutover
- ERPNext performance with 630K invoices -- only 2 years imported, rest as opening balance
- **Scheduler is PAUSED** -- reactivation would trigger billing for 21,876 subscriptions
**Open Decisions**:
- Invoice history depth: currently 2 years, rest as opening balance
- PPA (AccesD Desjardins): ERPNext has no native AccesD support, middleware needed
- Legacy ID retention: `legacy_*` fields on all doctypes for traceability
- Customer portal: 659 portal profiles use unsalted MD5 passwords, force reset via email/SMS OTP required
- RADIUS integration: webhook from Subscription to FreeRADIUS via n8n
- Camping module: confirm if `camping_*` tables are still active

384
docs/PLATFORM-STRATEGY.md Normal file
View File

@ -0,0 +1,384 @@
# Gigafibre Platform Strategy
## Vision unifiée par département — Inspiré Calix CommandIQ / SmartTown
> Avril 2026 — Document de planification stratégique
---
## 1. Inventaire des applications actuelles
### Applications à CONSERVER (core platform)
| App | URL | Rôle | Stack |
|-----|-----|------|-------|
| **ERPNext v16** | erp.gigafibre.ca | Source de vérité (clients, facturation, équipement, tickets) | Python/PostgreSQL |
| **Targo Ops** | erp.gigafibre.ca/ops/ | Console opérations internes (dispatch, clients, tickets, réseau) | Vue 3 / Quasar PWA |
| **Targo Hub** | msg.gigafibre.ca | Backend API, SSE temps réel, SMS, vision AI, magic links, page mobile tech | Node.js 20 |
| **Client Portal** | client.gigafibre.ca | Self-service client (factures, support, catalogue) | Vue 3 / Quasar PWA |
| **Website** | www.gigafibre.ca | Site vitrine marketing, vérification d'adresse | React / Vite |
| **Authentik (Client)** | id.gigafibre.ca | SSO clients et staff | Docker |
| **Traefik** | — | Reverse proxy, TLS, routing | Docker |
| **n8n** | n8n.gigafibre.ca | Workflows automatisés (SMS, email, webhooks) | Docker |
### Applications à RETIRER (legacy / redondantes)
| App | URL | Raison du retrait | Fonctionnalités à migrer | Statut migration |
|-----|-----|-------------------|-------------------------|-----------------|
| **dispatch-app** (legacy) | dispatch.gigafibre.ca | Remplacé par Ops `/dispatch` + page mobile `/t/{token}` | ~~Timeline dispatch~~ ✅, ~~Mobile tech~~ ✅, ~~Magic links~~ ✅, Catalogue équipement dans mobile ✅, Scan barcode ✅ | **Prêt à retirer** |
| **Targo Field App** | (apps/field/) | Remplacé par page mobile lightweight `/t/{token}` servi par targo-hub | ~~Job list~~ ✅, ~~Job detail~~ ✅, ~~Scan barcode~~ ✅, ~~Diagnostic~~ (à évaluer) | **Prêt à retirer** |
| **Oktopus CE** (TR-069 ACS) | oss.gigafibre.ca | ⚠️ Fait du TR-369 — pas d'alternative connue pour big data CPE temps réel | — | **Conserver** |
### Applications à NE PAS RETIRER
| App | Raison |
|-----|--------|
| **Authentik (Staff)** auth.targo.ca | Activement utilisé — remplacement impossible à court terme |
| **Legacy DB** (MariaDB) | Migration incomplète (~20/160 tables). Données critiques encore exclusives : comptabilité, soumissions, accords, télécom, projets, consommation, fibre/IP |
| **Oktopus CE** | Seule solution TR-369 pour statut temps réel et big data des CPE clients |
### Applications PARALLÈLES (hors scope Gigafibre FSM)
| App | Rôle | Note |
|-----|------|------|
| **Targo Backend/Frontend** | RH/Feuilles de temps employés Targo | Projet séparé, pas de chevauchement |
| **Infra-Map-Vue** | Cartographie fibre topologique (poteaux, routes optiques) | À intégrer éventuellement dans Ops comme module réseau |
| **Device Monitor** | Monitoring devices (prototype) | Fonctionnalités à absorber par targo-hub OLT/SNMP |
---
## 2. Fonctionnalités par département
### MARKETING — Segmentation dynamique et ventes contextuelles
> *"Patterns and contextual signals are analyzed to dynamically segment audiences and identify upsell and cross-sell opportunities."*
**Données disponibles dans ERPNext aujourd'hui :**
- Historique de facturation (115K+ factures) — panier moyen, ancienneté, churn risk
- Tickets de support (242K+) — fréquence des appels, types de problèmes récurrents
- Équipement installé (7 500+) — âge du modem, capacité Wi-Fi, nombre d'appareils
- Abonnements actifs (21K+) — services manquants (pas de TV, pas de VoIP)
- Données réseau (OLT, signal optique, débit) — qualité de service réelle
**Stratégie upsell/cross-sell contextuelle :**
| Signal contextuel | Action marketing | Canal |
|-------------------|-----------------|-------|
| Client avec Internet seulement + ticket "lenteur Wi-Fi" | Proposer routeur Wi-Fi 6 mesh + bornes extérieures | SMS personnalisé via Twilio |
| Client sans TV + visionnement Netflix détecté (DPI/QoS) | Offrir IPTV bundle à prix réduit | Email via Mailjet + bannière portail |
| Client avec ancien modem (>3 ans) | Upgrade gratuit vers ONT dernière gen | Notification push portail client |
| Client avec >3 tickets/mois | Offrir Support Prioritaire (10$/mois) | Appel proactif CSR |
| Déménagement détecté (changement adresse) | Offrir installation complète maison intelligente | Visite tech + catalogue domotique |
| Client fidèle >5 ans, 0 ticket | Programme fidélité : caméra d'entrée offerte | Lettre personnalisée + portail |
**Modules à développer :**
| Module | Description | Où |
|--------|-------------|-----|
| **Segment Engine** | Requêtes ERPNext automatisées qui tagguent les clients par segment (à risque, upsell TV, upgrade Wi-Fi, fidèle, nouveau) | n8n workflow + ERPNext Custom Script |
| **Campaign Manager** | Interface dans Ops pour créer/envoyer des campagnes SMS/email ciblées par segment | Ops app — nouveau module `/campaigns` |
| **Smart Banners** | Bannières contextuelles dans le portail client basées sur le profil | Client Portal — composant dynamique |
| **QR Modem → Offre** | QR code sur le modem → URL → détecte le client → affiche offres personnalisées | targo-hub page servie (comme `/t/{token}`) |
**Produits domotique à offrir :**
- Bornes Wi-Fi extérieures (mesh outdoor)
- Caméras d'entrée connectées (Doorbell IP)
- Thermostats intelligents
- Détecteurs de fumée/CO connectés
- Serrures intelligentes
- Hub domotique Zigbee/Z-Wave
---
### OPERATIONS — Proactivité et résolution automatique
> *"Operations agent workforce works around the clock to proactively uncover and resolve issues, reducing outages and improving the subscriber experience."*
**Monitoring proactif actuel :**
- OLT SNMP polling (targo-hub `olt-snmp.js`) — signal optique, statut ONU
- GenieACS TR-069 — paramètres CPE, reboots, firmware
- Traccar GPS — position techniciens en temps réel
**Vision proactive à implémenter :**
| Détection | Action automatique | Notification |
|-----------|-------------------|-------------|
| Signal optique ONU dégradé (<-25 dBm) | Créer ticket prioritaire + dispatch auto tech le plus proche | SMS client : "Nous avons détecté un problème, un technicien est en route" |
| ONT hors ligne >30 min (pas panne secteur) | Vérifier statut OLT, si OK → créer ticket | SMS client : "Votre connexion semble interrompue, nous investiguons" |
| Wi-Fi congestionné (>20 clients, canal saturé) | Push config optimale via TR-069 (changement canal auto) | Notification portail : "Nous avons optimisé votre Wi-Fi" |
| Latence >50ms vers gateway | Alert dispatch + diagnostic réseau auto | Rien (résolution silencieuse si possible) |
| Firmware CPE obsolète | Schedule mise à jour nocturne via GenieACS | Email : "Mise à jour de sécurité appliquée" |
| Panne OLT (multiple ONU down) | Créer incident majeur, notifier tous clients affectés en masse | SMS masse : "Panne détectée dans votre secteur, résolution en cours" |
**Modules existants vs à développer :**
| Module | Statut | Prochaine étape |
|--------|--------|----------------|
| Dispatch Timeline (drag-drop, Gantt) | ✅ Complet | — |
| Tags/Compétences (match auto) | ✅ Complet | — |
| Horaires + RRULE (garde, shifts) | ✅ Complet | — |
| Publication SMS + magic links | ✅ Complet | — |
| Page mobile tech lightweight | ✅ Complet | — |
| Scan barcode + gestion équipement | ✅ Complet | — |
| Pool d'offres Uber-style | ✅ Complet | — |
| Confirmation unassign jobs publiés | ✅ Complet | — |
| OLT SNMP monitoring | ✅ Basique | Ajouter alertes automatiques |
| Proactive ticket creation | ❌ À faire | n8n workflow : SNMP alert → create Issue |
| Auto-dispatch (matching algo) | ❌ À faire | Algorithme basé sur tags + distance + charge |
| Outage detection + mass notify | ❌ À faire | Corréler ONU down par OLT → SMS masse |
| CPE auto-config via TR-069 | ❌ À faire | GenieACS presets + targo-hub proxy |
---
### SERVICE — Intelligence contextuelle pour CSR et résolution au premier appel
> *"Specialized service agents share best practices and contextual insights to CSRs to solve problems faster and on the first call."*
**Données contextuelles disponibles pour le CSR (dans Ops app) :**
- Historique complet du client (tickets, factures, paiements)
- Équipement installé avec diagnostics en temps réel (signal, Wi-Fi, devices connectés)
- Notes des techniciens sur les visites précédentes
- Statut réseau du secteur (panne en cours ?)
- Historique des interventions sur cette adresse
**Outils d'aide CSR à développer :**
| Outil | Description | Impact |
|-------|-------------|--------|
| **Client Context Card** | Vue unifiée en 1 écran : abo actifs, dernier ticket, équipement, santé réseau, paiements en retard | Réduction temps d'appel de 40% |
| **Diagnostic Auto** | Bouton "Diagnostiquer" sur la fiche client → lance ping, speedtest, check signal ONU, vérifie firmware | Résolution 1er appel +30% |
| **Knowledge Base contextuelle** | Suggestions automatiques basées sur le type de problème : "Ce client a un HG8245H → vérifier le port LAN 4 connu pour défaillance" | Partage best practices |
| **Script d'appel guidé** | Flow interactif : symptôme → questions → diagnostic → solution → escalation si nécessaire | Uniformité du service |
| **Historique interactions** | Timeline unifiée : appels, SMS, emails, visites tech, modifications de compte | Contexte complet |
**État actuel vs cible :**
| Fonctionnalité | Statut |
|---------------|--------|
| Fiche client avec équipement et tickets | ✅ Ops `/clients/:id` |
| Détail équipement avec diagnostic OLT | ✅ EquipmentDetail.vue |
| Envoi SMS/email depuis Ops | ✅ Via targo-hub/Twilio |
| Historique thread de tickets | ✅ Ops tickets module |
| Diagnostic auto (1 clic) | ❌ À intégrer (GenieACS + SNMP) |
| Suggestions contextuelles AI | ❌ À faire (Gemini sur historique tickets similaires) |
| Scripts d'appel guidés | ❌ À faire |
---
### SUBSCRIBER — L'intelligence du support dans les mains du client
> *"Subscriber agents extend the reach of support organizations directly to subscribers with personalized upsell opportunities, optimization techniques, and outage information through the subscriber's app."*
**Portail client actuel (client.gigafibre.ca) :**
- ✅ Consultation factures et historique
- ✅ Paiement Stripe
- ✅ Création/suivi de tickets
- ✅ Catalogue produits avec panier
- ✅ Auth SSO (id.gigafibre.ca)
**Fonctionnalités Subscriber à ajouter :**
| Feature | Description | Priorité |
|---------|-------------|----------|
| **QR Code sur modem** | Étiquette QR collée sur le modem → URL `msg.gigafibre.ca/q/{mac}` → identifie le compte → envoie token SMS/email au propriétaire → accès gestion compte | 🔴 Haute |
| **Dashboard santé réseau** | Vitesse actuelle, latence, uptime 30 jours, appareils connectés | 🔴 Haute |
| **Contrôle parental (langage naturel)** | "Bloquer TikTok pour les enfants après 21h" → traduit en règles TR-069 → push au routeur | 🟡 Moyenne |
| **Notifications outage** | Push/SMS automatique quand panne détectée dans le secteur, avec ETA résolution | 🔴 Haute |
| **Optimisation Wi-Fi** | Conseils personnalisés : "Votre routeur est dans le sous-sol, déplacez-le au rez-de-chaussée pour +40% de couverture" | 🟡 Moyenne |
| **Self-diagnostic** | Bouton "Tester ma connexion" → speedtest + vérification signal → rapport | 🔴 Haute |
| **Offres personnalisées** | Bannières contextuelles : "Ajoutez la TV IPTV à votre forfait pour 20$/mois" basé sur profil | 🟡 Moyenne |
| **Gestion appareils** | Liste des appareils connectés, renommer, bloquer, prioritiser | 🟡 Moyenne |
| **Historique consommation** | Graphique de bande passante utilisée par jour/semaine/mois | 🟢 Basse |
---
## 3. QR Code Modem — Flow technique détaillé
Le QR code sur le modem est un game-changer pour l'expérience client. Flow :
```
┌─────────────────────────────────────────────────────────┐
│ ÉTIQUETTE QR SUR LE MODEM │
│ URL: msg.gigafibre.ca/q/{MAC_ADDRESS} │
└──────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ targo-hub GET /q/{mac} │
│ 1. Lookup Service Equipment par MAC │
│ 2. Trouver le Customer lié │
│ 3. Récupérer téléphone/email du Customer │
│ 4. Envoyer OTP 6 chiffres par SMS (Twilio) │
│ 5. Afficher page : "Code envoyé au 514-***-**89" │
└──────────────────────┬──────────────────────────────────┘
│ Client entre le code OTP
┌─────────────────────────────────────────────────────────┐
│ POST /q/{mac}/verify { otp: "123456" } │
│ 1. Vérifier OTP (Redis, TTL 5 min) │
│ 2. Générer JWT session (24h) │
│ 3. Servir page subscriber : │
│ - Santé réseau (signal, vitesse, uptime) │
│ - Appareils connectés │
│ - Contrôle parental │
│ - Offres personnalisées │
│ - Lien vers portail complet (client.gigafibre.ca) │
└─────────────────────────────────────────────────────────┘
```
**Avantages :**
- Zéro mot de passe à retenir
- Accessible même par des clients non technologiques
- Le QR est physiquement chez le client → preuve de possession
- Upsell contextuel immédiat (le client est devant son modem)
- Support proactif : "Votre signal est faible, voulez-vous un diagnostic ?"
---
## 4. Architecture cible — Plateforme unifiée
```
┌─────────────────────────┐
│ www.gigafibre.ca │ Marketing
│ (React — vitrine) │ Vérification adresse
└────────────┬────────────┘
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Ops │ │ Client Portal │ │ Mobile Tech │
│ App │ │ (Vue/Quasar) │ │ /t/{token} │
│ (PWA) │ │ client.gigafibre│ │ (HTML pur) │
│ │ │ │ │ │
│ Dispatch│ │ Factures │ │ Jobs du jour │
│ Clients │ │ Paiements │ │ Scan barcode │
│ Tickets │ │ Support │ │ GPS navigation │
│ Réseau │ │ Catalogue │ │ Status update │
│ Campagns│ │ QR Modem portal │ │ Équipement CRUD │
└────┬────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
└─────────────────────────┼─────────────────────────────┘
┌──────────▼──────────┐
│ targo-hub │
│ (Node.js 20) │
│ │
│ SSE temps réel │
│ SMS (Twilio) │
│ Vision AI (Gemini) │
│ Magic links / OTP │
│ OLT SNMP polling │
│ QR modem → auth │
│ TR-069 proxy │
│ Segment engine │
└──────────┬──────────┘
┌──────────▼──────────┐
│ ERPNext v16 │
│ (PostgreSQL) │
│ │
│ Customers 6,600+ │
│ Invoices 115K+ │
│ Equipment 7,500+ │
│ Tickets 242K+ │
│ Subscriptions 21K+ │
└──────────┬──────────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ GenieACS │ │ Traccar │ │ id.giga │
│ TR-069 │ │ GPS │ │ Authentik │
│ 7,500 CPE│ │ 46 techs │ │ SSO │
└──────────┘ └──────────┘ └──────────┘
```
---
## 5. Plan de retrait des applications legacy
### Phase 1 — Immédiat (avril 2026)
| Action | Risque | Validation |
|--------|--------|-----------|
| Retirer **dispatch-app** du docker-compose apps | Bas — toutes les features sont dans Ops + /t/{token} | Vérifier que les magic links SMS pointent vers msg.gigafibre.ca/t/ |
| Retirer le container **apps-dispatch-frontend-1** | Bas | Confirmer qu'aucun bookmark/lien externe pointe vers dispatch.gigafibre.ca |
| Supprimer le repo dispatch-app de la CI | Aucun | — |
### Phase 2 — Court terme (mai 2026)
| Action | Risque | Validation |
|--------|--------|-----------|
| Retirer **apps/field/** du monorepo | Bas — /t/{token} couvre tout | Comparer features field vs /t/{token} |
| Migrer **auth.targo.ca****id.gigafibre.ca** pour n8n et Hub | Moyen — tester les flows n8n | Tester chaque workflow n8n post-migration |
| Éteindre **legacy-db** MariaDB | Bas — migration 100% complétée | Garder un dump SQL en backup |
| Évaluer retrait **Oktopus CE** vs intégration GenieACS | Moyen | Comparer fonctionnalités monitoring |
### Phase 3 — Moyen terme (été 2026)
| Action | Risque | Validation |
|--------|--------|-----------|
| Absorber **device-monitor** dans targo-hub OLT SNMP | Bas | Vérifier couverture fonctionnelle |
| Intégrer **infra-map-vue** comme module Ops `/network/map` | Moyen | iframe ou migration Vue |
| Unifier **Targo Backend** avec targo-hub (si pertinent) | Élevé — évaluer | Audit des fonctionnalités RH |
---
## 6. Métriques de succès par département
| Département | KPI | Cible |
|-------------|-----|-------|
| **Marketing** | Taux de conversion upsell via portail/SMS | 5% des clients ciblés |
| **Marketing** | Revenu additionnel par client (ARPU lift) | +8$/mois moyen |
| **Operations** | Tickets proactifs (créés avant appel client) | 30% des incidents réseau |
| **Operations** | Temps moyen de résolution dispatch | <4h (actuellement ~8h) |
| **Service** | Résolution au premier appel (FCR) | 75% (actuellement ~55%) |
| **Service** | Temps moyen d'appel CSR | <6 min (actuellement ~10 min) |
| **Subscriber** | Adoption portail self-service | 40% des clients actifs |
| **Subscriber** | Réduction appels support via self-diagnostic | -25% volume appels |
---
## 7. Analyse concurrentielle
### Gaiia (gaiia.com) — Comparable principal
Canadian-founded (2021), YC-backed, $13.2M Series A. 40+ ISP customers.
| Gaiia Module | Gigafibre Status |
|---|---|
| Workforce & Scheduling | Done (dispatch PWA) |
| Field Service App | Done (/t/{token} mobile) |
| Billing & Revenue | ERPNext (basic) |
| Customer Portal | Not built |
| Online Checkout | Not built |
| Network Monitoring | Oktopus CE + OLT SNMP |
| Workflow Builder | n8n |
### Avantages Gigafibre vs Gaiia
- Self-hosted / souverain (pas de frais par abonné)
- Full ERP (accounting, HR, inventory inclus)
- Dispatch board avancé (lasso, undo, auto-dispatch, route optimization)
- Open source — offrable à d'autres ISPs
### Gaps à combler (vs Gaiia)
- Customer portal self-service
- Online checkout (Gaiia rapporte 6x conversion)
- Billing proration (mid-cycle changes)
- Auto travel time on dispatch
### Industrie — Matrice comparative
| Feature | Gaiia | Odoo FS | Zuper | Salesforce FS | Gigafibre |
|---|---|---|---|---|---|
| Target | ISP-specific | General | Telecom | Enterprise | ISP custom |
| Self-hosted | No | Yes | No | No | Yes |
| Pricing | Per-subscriber | $30/user | $50/user | $200/user | Free (OSS) |
| Dispatch drag-drop | Yes | Yes | Yes | Yes | Yes |
| GPS real-time | Yes | Limited | Yes | Yes | Yes (WS) |
| Equipment tracking | Yes | Yes | Yes | Yes | Yes |
| Customer portal | Yes | Yes | Yes | Yes | Not yet |
| Online checkout | Yes | No | No | No | Not yet |
---
*Document vivant — dernière mise à jour : 12 avril 2026*

View File

@ -1,59 +1,50 @@
# Gigafibre FSM — Roadmap
## Phase 1 — Foundation (Done)
- [x] ERPNext v16 setup with PostgreSQL
## Phase 1 — Foundation (Done, March 2026)
- [x] ERPNext v16 + PostgreSQL
- [x] Custom FSM doctypes (Service Location, Equipment, Subscription)
- [x] Dispatch doctypes (Job, Technician, Tag with skill levels)
- [x] Dispatch PWA with timeline, drag-drop, Mapbox map
- [x] GPS tracking (Traccar hybrid REST + WebSocket)
- [x] Authentik SSO (forwardAuth) for all apps
- [x] ERPNext API proxy (same-origin via nginx)
- [x] Legacy data migration (6,667 customers, 21K subscriptions, 115K invoices, 242K tickets)
- [x] Gitea repo at git.targo.ca
- [x] Authentik SSO (forwardAuth)
- [x] ERPNext API proxy (nginx same-origin)
- [x] Legacy migration (6,667 customers, 21K subs, 115K invoices, 242K tickets)
## Phase 2 — Ops App (Done)
- [x] Unified ops PWA at erp.gigafibre.ca/ops/
- [x] Client list with search (name, account ID, legacy ID)
- [x] Client detail page (contact, billing KPIs, locations, subscriptions, equipment, tickets, invoices, payments, notes)
- [x] Inline editing on all fields (Odoo-style dblclick, InlineField component)
- [x] Dispatch module integrated into ops app
- [x] Ticket management with inline status/priority editing
- [x] Equipment tracking with detail modal
- [x] SMS/Email notifications via n8n webhooks (Twilio + Mailjet)
- [x] Ticket reply thread (Communication docs)
## Phase 2 — Ops App (Done, March 2026)
- [x] Unified ops PWA (erp.gigafibre.ca/ops/)
- [x] Client list/detail with inline editing (Odoo-style)
- [x] Dispatch module + ticket management
- [x] Equipment tracking with OLT/SNMP diagnostics
- [x] SMS/Email notifications (Twilio + Mailjet)
- [x] Invoice OCR (Ollama Vision)
- [x] Field tech mobile app (barcode, diagnostics, offline)
- [x] Authentik federation (auth.targo.ca staff -> id.gigafibre.ca)
- [x] Field tech mobile (/t/{token})
- [x] Authentik federation (staff → client SSO)
- [x] Modem-bridge (Playwright headless for TP-Link ONU diagnostics)
- [x] WiFi diagnostic panel (mesh topology, client signal, packet loss)
## Phase 3 — Workflows & Automation (In Progress)
- [ ] Tag technicians with skills (assign Fibre/TV/Telephonie tags + levels to 46 techs)
- [ ] Wire auto-dispatch logic (cost-optimization matching in useAutoDispatch.js)
- [ ] Issue -> Dispatch Job creation (button in ticket detail)
- [ ] Job completion -> equipment status update
- [ ] Job completion -> close helpdesk ticket
- [ ] Equipment swap -> inventory move log
- [ ] n8n workflows for escalation rules
- [ ] Activate n8n SMS/email workflows via UI
- [ ] Twilio upgrade to production (10DLC or Toll-Free)
- [ ] SLA tracking on subscriptions
- [ ] Tag technicians with skills (46 techs to tag)
- [ ] Wire auto-dispatch (cost-optimization matching)
- [ ] Issue → Dispatch Job creation
- [ ] Job completion → equipment status + close ticket
- [ ] Equipment swap → inventory log
- [ ] n8n escalation workflows
- [ ] Twilio 10DLC production upgrade
- [ ] SLA tracking
## Phase 4 — Customer Portal
- [ ] Customer-facing web app (service status, invoices)
- [ ] Stripe payment integration
- [ ] Self-service app (invoices, tickets, equipment)
- [ ] Stripe payments
- [ ] Online appointment booking
- [ ] Real-time tech tracking ("On my way" SMS)
- [ ] Equipment list per location
- [ ] Service request submission
- [ ] Legacy password migration (MD5 -> PBKDF2 via auth bridge)
- [ ] Real-time tech tracking SMS
- [ ] Legacy password migration (MD5 → PBKDF2)
- [ ] QR code modem → subscriber dashboard
## Phase 5 — Advanced Features
- [ ] Van stock inventory per technician
- [ ] Part usage -> auto-reorder
- [ ] Multi-day project tracking (fiber builds)
- [ ] Tech performance dashboards
- [ ] Van stock inventory per tech
- [ ] Revenue analytics (MRR, churn, ARPU)
- [ ] Proactive monitoring (auto-ticketing)
- [ ] Online checkout (e-commerce signup)
- [ ] Marketing segmentation + campaigns
- [ ] Tech performance dashboards
- [ ] Preventive maintenance scheduling
- [ ] Customer/location picker in job creation modal
- [ ] Photo capture with annotations
- [ ] Customer signature pad
- [ ] Time tracking (start/pause/stop on job)

489
docs/build-pptx.js Normal file
View File

@ -0,0 +1,489 @@
const pptxgen = require("pptxgenjs");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const sharp = require("sharp");
// Icons from react-icons
const {
FaCalendarAlt, FaUsers, FaSms, FaMagic, FaShoppingCart, FaUber,
FaMobileAlt, FaQrcode, FaRoute, FaCogs, FaChartLine, FaLock,
FaServer, FaDatabase, FaDocker, FaCloudDownloadAlt,
FaMapMarkerAlt, FaClock, FaBell, FaCheckCircle, FaTags,
FaNetworkWired, FaToolbox, FaUserShield, FaPaperPlane
} = require("react-icons/fa");
const {
MdScheduleSend, MdDragIndicator, MdOutlineSmartphone,
MdOutlineAssignment, MdOutlineQrCodeScanner
} = require("react-icons/md");
function renderIconSvg(IconComponent, color = "#FFFFFF", size = 256) {
return ReactDOMServer.renderToStaticMarkup(
React.createElement(IconComponent, { color, size: String(size) })
);
}
async function iconToBase64Png(IconComponent, color, size = 256) {
const svg = renderIconSvg(IconComponent, color, size);
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
return "image/png;base64," + pngBuffer.toString("base64");
}
// Color palette — Gigafibre purple theme
const C = {
darkBg: "0D0F18",
cardBg: "181C2E",
purple: "5C59A8",
purpleDk: "3F3D7A",
accent: "818CF8",
green: "22C55E",
amber: "F59E0B",
red: "EF4444",
white: "FFFFFF",
text: "E2E4EF",
muted: "7B80A0",
lightBg: "F5F7FA",
indigo: "6366F1",
};
const mkShadow = () => ({ type: "outer", blur: 8, offset: 3, angle: 135, color: "000000", opacity: 0.3 });
async function build() {
const pres = new pptxgen();
pres.layout = "LAYOUT_16x9";
pres.author = "Targo";
pres.title = "Gigafibre FSM — Plateforme Operations";
// Pre-render icons
const icons = {};
const iconMap = {
calendar: FaCalendarAlt, users: FaUsers, sms: FaSms, magic: FaMagic,
cart: FaShoppingCart, uber: FaRoute, mobile: FaMobileAlt,
qr: FaQrcode, route: FaRoute, cogs: FaCogs, chart: FaChartLine,
lock: FaLock, server: FaServer, db: FaDatabase, docker: FaDocker,
cloud: FaCloudDownloadAlt, marker: FaMapMarkerAlt, clock: FaClock,
bell: FaBell, check: FaCheckCircle, tags: FaTags,
network: FaNetworkWired, toolbox: FaToolbox, shield: FaUserShield,
send: FaPaperPlane,
};
for (const [k, v] of Object.entries(iconMap)) {
icons[k] = await iconToBase64Png(v, "#" + C.white, 256);
icons[k + "_purple"] = await iconToBase64Png(v, "#" + C.accent, 256);
icons[k + "_dark"] = await iconToBase64Png(v, "#" + C.purpleDk, 256);
}
// ═══════════════════════════════════════
// SLIDE 1 — Title
// ═══════════════════════════════════════
let s = pres.addSlide();
s.background = { color: C.darkBg };
// Purple accent bar top
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
// Logo area
s.addShape(pres.shapes.RECTANGLE, { x: 0.8, y: 1.2, w: 0.12, h: 1.8, fill: { color: C.indigo } });
s.addText("GIGAFIBRE", { x: 1.15, y: 1.2, w: 8, h: 0.7, fontSize: 42, fontFace: "Arial Black", color: C.white, bold: true, margin: 0 });
s.addText("FSM", { x: 1.15, y: 1.85, w: 8, h: 0.6, fontSize: 36, fontFace: "Arial Black", color: C.accent, margin: 0 });
s.addText("Plateforme de gestion des operations terrain", { x: 1.15, y: 2.7, w: 7, h: 0.5, fontSize: 18, fontFace: "Calibri", color: C.muted, margin: 0 });
// Stats row
const stats = [
{ val: "6 600+", lbl: "Clients" },
{ val: "46", lbl: "Techniciens" },
{ val: "7 500+", lbl: "Equipements" },
{ val: "115K", lbl: "Factures" },
];
stats.forEach((st, i) => {
const sx = 1.15 + i * 2.1;
s.addShape(pres.shapes.RECTANGLE, { x: sx, y: 3.7, w: 1.8, h: 1.0, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addText(st.val, { x: sx, y: 3.72, w: 1.8, h: 0.55, fontSize: 22, fontFace: "Arial Black", color: C.accent, align: "center", valign: "middle", margin: 0 });
s.addText(st.lbl, { x: sx, y: 4.25, w: 1.8, h: 0.35, fontSize: 11, fontFace: "Calibri", color: C.muted, align: "center", valign: "top", margin: 0 });
});
s.addText("Presentation technique — Avril 2026", { x: 0.8, y: 5.1, w: 9, h: 0.3, fontSize: 10, fontFace: "Calibri", color: C.muted, margin: 0 });
// ═══════════════════════════════════════
// SLIDE 2 — Architecture Overview
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Architecture de la plateforme", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
s.addText("Monorepo • Docker • Traefik • ERPNext • Vue 3", { x: 0.6, y: 0.8, w: 9, h: 0.35, fontSize: 12, fontFace: "Calibri", color: C.muted, margin: 0 });
const archBoxes = [
{ icon: "mobile", title: "Ops App", desc: "Vue 3 / Quasar PWA\n12 pages, 40 composables\n9 modules API", x: 0.4, y: 1.5 },
{ icon: "server", title: "targo-hub", desc: "Node.js 20\n30 modules\nSSE temps reel", x: 2.65, y: 1.5 },
{ icon: "db", title: "ERPNext v16", desc: "PostgreSQL\nDoctypes custom\nAPI REST", x: 4.9, y: 1.5 },
{ icon: "shield", title: "Authentik SSO", desc: "Staff + Client\nForwardAuth\nOAuth/OIDC", x: 7.15, y: 1.5 },
];
for (const b of archBoxes) {
s.addShape(pres.shapes.RECTANGLE, { x: b.x, y: b.y, w: 2.05, h: 2.3, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addShape(pres.shapes.RECTANGLE, { x: b.x, y: b.y, w: 2.05, h: 0.06, fill: { color: C.indigo } });
s.addImage({ data: icons[b.icon], x: b.x + 0.75, y: b.y + 0.25, w: 0.5, h: 0.5 });
s.addText(b.title, { x: b.x + 0.1, y: b.y + 0.85, w: 1.85, h: 0.35, fontSize: 14, fontFace: "Calibri", color: C.white, bold: true, align: "center", margin: 0 });
s.addText(b.desc, { x: b.x + 0.1, y: b.y + 1.2, w: 1.85, h: 1.0, fontSize: 10, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 });
}
// Integration row
const integ = [
{ name: "GenieACS", sub: "TR-069 / 7560 CPE" },
{ name: "Twilio", sub: "SMS / Voix" },
{ name: "Mapbox", sub: "Cartes / Routes" },
{ name: "Gemini AI", sub: "OCR / Agent" },
{ name: "Stripe", sub: "Paiements" },
{ name: "Traccar", sub: "GPS temps reel" },
];
s.addText("INTEGRATIONS", { x: 0.6, y: 4.05, w: 9, h: 0.3, fontSize: 10, fontFace: "Calibri", color: C.muted, charSpacing: 3, margin: 0 });
integ.forEach((it, i) => {
const ix = 0.4 + i * 1.55;
s.addShape(pres.shapes.RECTANGLE, { x: ix, y: 4.4, w: 1.4, h: 0.85, fill: { color: "111422" } });
s.addText(it.name, { x: ix, y: 4.42, w: 1.4, h: 0.4, fontSize: 10, fontFace: "Calibri", color: C.accent, bold: true, align: "center", margin: 0 });
s.addText(it.sub, { x: ix, y: 4.78, w: 1.4, h: 0.35, fontSize: 8, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 });
});
// ═══════════════════════════════════════
// SLIDE 3 — Dispatch Timeline
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Dispatch & planification", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
s.addText("Timeline interactive avec drag-and-drop, carte Mapbox, et vues jour/semaine/mois", { x: 0.6, y: 0.8, w: 9, h: 0.35, fontSize: 12, fontFace: "Calibri", color: C.muted, margin: 0 });
// Feature grid 2x3
const dispFeats = [
{ icon: "calendar", title: "Timeline Gantt", desc: "Vue jour : rangees par technicien, blocs de jobs colores, drag-and-drop pour assigner ou reordonner" },
{ icon: "route", title: "Routes optimisees", desc: "Calcul d'itineraires Mapbox, temps de deplacement, geo-fixation sur carte" },
{ icon: "tags", title: "Tags / Competences", desc: "Niveaux 1-5 sur techs et jobs. Auto-dispatch : match minimum adequat, preserver les experts" },
{ icon: "clock", title: "Mode planification", desc: "Shifts reguliers en fond bleu, garde en ambre, absences en rouge. Editeur d'horaire inline" },
{ icon: "check", title: "Draft / Publish", desc: "Jobs en brouillon (hachures) puis publication en masse + envoi SMS du resume horaire" },
{ icon: "bell", title: "Recurrence Google", desc: "Selecteur RRULE style Google Calendar avec options contextuelles + editeur personnalise" },
];
dispFeats.forEach((f, i) => {
const col = i % 3, row = Math.floor(i / 3);
const fx = 0.4 + col * 3.1, fy = 1.35 + row * 2.05;
s.addShape(pres.shapes.RECTANGLE, { x: fx, y: fy, w: 2.9, h: 1.8, fill: { color: C.cardBg }, shadow: mkShadow() });
// Icon circle
s.addShape(pres.shapes.OVAL, { x: fx + 0.2, y: fy + 0.2, w: 0.55, h: 0.55, fill: { color: C.purpleDk } });
s.addImage({ data: icons[f.icon], x: fx + 0.3, y: fy + 0.3, w: 0.35, h: 0.35 });
s.addText(f.title, { x: fx + 0.9, y: fy + 0.2, w: 1.85, h: 0.4, fontSize: 13, fontFace: "Calibri", color: C.white, bold: true, margin: 0, valign: "middle" });
s.addText(f.desc, { x: fx + 0.2, y: fy + 0.85, w: 2.5, h: 0.85, fontSize: 9.5, fontFace: "Calibri", color: C.muted, margin: 0 });
});
// ═══════════════════════════════════════
// SLIDE 4 — Gestion des horaires
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Gestion des horaires techniciens", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
// Left: schedule editor mockup
s.addShape(pres.shapes.RECTANGLE, { x: 0.5, y: 1.1, w: 4.3, h: 4.1, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addText("Editeur d'horaire", { x: 0.7, y: 1.2, w: 3.9, h: 0.4, fontSize: 14, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
const days = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
const schedActive = [true, true, true, true, true, false, false];
days.forEach((d, i) => {
const dy = 1.75 + i * 0.48;
s.addShape(pres.shapes.RECTANGLE, { x: 0.7, y: dy, w: 3.9, h: 0.38, fill: { color: schedActive[i] ? "1E2338" : "141726" } });
s.addText(d, { x: 0.8, y: dy, w: 0.6, h: 0.38, fontSize: 10, fontFace: "Calibri", color: schedActive[i] ? C.green : C.muted, bold: true, valign: "middle", margin: 0 });
if (schedActive[i]) {
s.addText("08:00 → 16:00", { x: 1.5, y: dy, w: 2.0, h: 0.38, fontSize: 10, fontFace: "Calibri", color: C.text, valign: "middle", margin: 0 });
} else {
s.addText("Repos", { x: 1.5, y: dy, w: 2.0, h: 0.38, fontSize: 10, fontFace: "Calibri", color: C.muted, italic: true, valign: "middle", margin: 0 });
}
});
// Garde shift
s.addText("Shifts de garde", { x: 0.7, y: 5.2 - 0.85, w: 3.9, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.amber, bold: true, margin: 0 });
s.addShape(pres.shapes.RECTANGLE, { x: 0.7, y: 5.2 - 0.48, w: 3.9, h: 0.38, fill: { color: "2A2510" } });
s.addText("Garde 08:00 → 16:00 1 fin de semaine sur 4", { x: 0.8, y: 5.2 - 0.48, w: 3.7, h: 0.38, fontSize: 9, fontFace: "Calibri", color: C.amber, valign: "middle", margin: 0 });
// Right: features
s.addShape(pres.shapes.RECTANGLE, { x: 5.2, y: 1.1, w: 4.3, h: 4.1, fill: { color: C.cardBg }, shadow: mkShadow() });
const schedFeats = [
{ title: "Horaire hebdomadaire", desc: "Configuration par jour, presets (temps plein, soirs, nuits)" },
{ title: "Shifts de garde (RRULE)", desc: "Recurrence flexible : 1 weekend sur N, soirs de semaine, personnalise" },
{ title: "RecurrenceSelector", desc: "Composant Google Calendar : options contextuelles + editeur custom RRULE" },
{ title: "Visualisation timeline", desc: "Blocs bleus (regulier), ambres (garde) en fond du timeline" },
{ title: "Presets partages", desc: "Groupes de ressources sauvegardes dans ERPNext, partages entre superviseurs" },
];
schedFeats.forEach((f, i) => {
const fy = 1.3 + i * 0.78;
s.addShape(pres.shapes.OVAL, { x: 5.4, y: fy + 0.05, w: 0.28, h: 0.28, fill: { color: C.purpleDk } });
s.addText(String(i + 1), { x: 5.4, y: fy + 0.05, w: 0.28, h: 0.28, fontSize: 9, fontFace: "Calibri", color: C.white, bold: true, align: "center", valign: "middle", margin: 0 });
s.addText(f.title, { x: 5.85, y: fy, w: 3.4, h: 0.3, fontSize: 12, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
s.addText(f.desc, { x: 5.85, y: fy + 0.3, w: 3.4, h: 0.35, fontSize: 9, fontFace: "Calibri", color: C.muted, margin: 0 });
});
// ═══════════════════════════════════════
// SLIDE 5 — Attribution de jobs & SMS
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Attribution de jobs & communication", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
// Flow diagram
const flowSteps = [
{ icon: "calendar", title: "1. Planifier", desc: "Drag-and-drop sur\nle timeline", color: C.indigo },
{ icon: "send", title: "2. Publier", desc: "Selection en masse\n+ confirmation", color: C.purple },
{ icon: "sms", title: "3. SMS", desc: "Resume horaire\nenvoye via Twilio", color: C.green },
{ icon: "mobile", title: "4. Lien tech", desc: "Vue mobile /j\navec bottom sheet", color: C.amber },
];
flowSteps.forEach((f, i) => {
const fx = 0.4 + i * 2.4;
s.addShape(pres.shapes.RECTANGLE, { x: fx, y: 1.2, w: 2.15, h: 1.8, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addShape(pres.shapes.RECTANGLE, { x: fx, y: 1.2, w: 2.15, h: 0.06, fill: { color: f.color } });
s.addShape(pres.shapes.OVAL, { x: fx + 0.75, y: 1.4, w: 0.6, h: 0.6, fill: { color: f.color, transparency: 70 } });
s.addImage({ data: icons[f.icon], x: fx + 0.85, y: 1.5, w: 0.4, h: 0.4 });
s.addText(f.title, { x: fx + 0.1, y: 2.15, w: 1.95, h: 0.35, fontSize: 13, fontFace: "Calibri", color: C.white, bold: true, align: "center", margin: 0 });
s.addText(f.desc, { x: fx + 0.1, y: 2.5, w: 1.95, h: 0.45, fontSize: 10, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 });
});
// SMS example mockup
s.addShape(pres.shapes.RECTANGLE, { x: 0.5, y: 3.3, w: 4.3, h: 2.0, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addImage({ data: icons.sms_purple, x: 0.7, y: 3.45, w: 0.35, h: 0.35 });
s.addText("Envoi SMS (Twilio)", { x: 1.15, y: 3.45, w: 3.3, h: 0.35, fontSize: 13, fontFace: "Calibri", color: C.white, bold: true, margin: 0, valign: "middle" });
s.addShape(pres.shapes.RECTANGLE, { x: 0.7, y: 3.95, w: 3.9, h: 1.15, fill: { color: "111422" } });
s.addText([
{ text: "Bonjour Louis-Paul,\n", options: { fontSize: 9, color: C.text, breakLine: true } },
{ text: "Votre horaire pour le 9 avril :\n", options: { fontSize: 9, color: C.text, breakLine: true } },
{ text: "08h00 - SUP-003 Panne IP Phone\n", options: { fontSize: 9, color: C.accent, breakLine: true } },
{ text: "10h30 - INS-047 Installation Fibre\n", options: { fontSize: 9, color: C.accent, breakLine: true } },
{ text: "erp.gigafibre.ca/ops/#/j", options: { fontSize: 8, color: C.green } },
], { x: 0.85, y: 4.0, w: 3.6, h: 1.05, margin: 0 });
// Magic links
s.addShape(pres.shapes.RECTANGLE, { x: 5.2, y: 3.3, w: 4.3, h: 2.0, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addImage({ data: icons.magic_purple, x: 5.4, y: 3.45, w: 0.35, h: 0.35 });
s.addText("Magic Links & OTP", { x: 5.85, y: 3.45, w: 3.3, h: 0.35, fontSize: 13, fontFace: "Calibri", color: C.white, bold: true, margin: 0, valign: "middle" });
const mlFeats = [
"Authentification sans mot de passe",
"Lien unique par SMS ou email",
"OTP 6 chiffres via Twilio",
"Migration transparente legacy MD5",
"Session SSO via Authentik",
];
mlFeats.forEach((f, i) => {
s.addImage({ data: icons.check_purple, x: 5.4, y: 3.95 + i * 0.22, w: 0.15, h: 0.15 });
s.addText(f, { x: 5.7, y: 3.95 + i * 0.22, w: 3.5, h: 0.22, fontSize: 10, fontFace: "Calibri", color: C.text, margin: 0, valign: "middle" });
});
// ═══════════════════════════════════════
// SLIDE 6 — Uber-style offer pool
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Pool d'offres — Style Uber", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
s.addText("Proposer des taches aux ressources internes et externes avec tarification dynamique", { x: 0.6, y: 0.8, w: 9, h: 0.35, fontSize: 12, fontFace: "Calibri", color: C.muted, margin: 0 });
// 3 mode cards
const modes = [
{ title: "Broadcast", desc: "Envoyer a toutes les\nressources disponibles", icon: "bell", color: C.indigo },
{ title: "Targeted", desc: "Selectionner des\ntechniciens specifiques", icon: "users", color: C.green },
{ title: "Pool", desc: "Match par competences\net disponibilite", icon: "tags", color: C.amber },
];
modes.forEach((m, i) => {
const mx = 0.4 + i * 3.15;
s.addShape(pres.shapes.RECTANGLE, { x: mx, y: 1.3, w: 2.95, h: 1.5, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addShape(pres.shapes.OVAL, { x: mx + 0.2, y: 1.5, w: 0.5, h: 0.5, fill: { color: m.color, transparency: 70 } });
s.addImage({ data: icons[m.icon], x: mx + 0.28, y: 1.58, w: 0.35, h: 0.35 });
s.addText(m.title, { x: mx + 0.85, y: 1.45, w: 1.9, h: 0.35, fontSize: 14, fontFace: "Calibri", color: C.white, bold: true, margin: 0, valign: "middle" });
s.addText(m.desc, { x: mx + 0.85, y: 1.8, w: 1.9, h: 0.5, fontSize: 10, fontFace: "Calibri", color: C.muted, margin: 0 });
});
// Pricing section
s.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: 3.1, w: 4.5, h: 2.2, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addText("Tarification", { x: 0.6, y: 3.2, w: 4.1, h: 0.4, fontSize: 14, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
const pricing = [
{ label: "Deplacement (base)", val: "150 $", color: C.accent },
{ label: "Taux horaire", val: "125 $/h", color: C.accent },
{ label: "Majoration urgence", val: "+50%", color: C.red },
{ label: "Weekend / ferie", val: "+75%", color: C.amber },
];
pricing.forEach((p, i) => {
const py = 3.7 + i * 0.4;
s.addText(p.label, { x: 0.7, y: py, w: 2.5, h: 0.35, fontSize: 11, fontFace: "Calibri", color: C.text, margin: 0, valign: "middle" });
s.addText(p.val, { x: 3.2, y: py, w: 1.5, h: 0.35, fontSize: 13, fontFace: "Arial Black", color: p.color, margin: 0, valign: "middle", align: "right" });
});
// Flow
s.addShape(pres.shapes.RECTANGLE, { x: 5.2, y: 3.1, w: 4.3, h: 2.2, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addText("Flux d'offre", { x: 5.4, y: 3.2, w: 3.9, h: 0.4, fontSize: 14, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
const offerFlow = [
{ step: "1", text: "Superviseur cree l'offre (cout estime)" },
{ step: "2", text: "Notification SMS aux techniciens cibles" },
{ step: "3", text: "Technicien accepte ou decline" },
{ step: "4", text: "Job auto-assigne au premier accepte" },
{ step: "5", text: "Alerte surcharge si capacite > 100%" },
];
offerFlow.forEach((f, i) => {
const fy = 3.7 + i * 0.32;
s.addShape(pres.shapes.OVAL, { x: 5.4, y: fy + 0.03, w: 0.24, h: 0.24, fill: { color: C.indigo } });
s.addText(f.step, { x: 5.4, y: fy + 0.03, w: 0.24, h: 0.24, fontSize: 8, fontFace: "Calibri", color: C.white, bold: true, align: "center", valign: "middle", margin: 0 });
s.addText(f.text, { x: 5.8, y: fy, w: 3.5, h: 0.28, fontSize: 10, fontFace: "Calibri", color: C.text, margin: 0, valign: "middle" });
});
// ═══════════════════════════════════════
// SLIDE 7 — Portail client & Catalogue
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Portail client & catalogue", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
s.addText("Self-service client avec Stripe, catalogue produits, et gestion de compte", { x: 0.6, y: 0.8, w: 9, h: 0.35, fontSize: 12, fontFace: "Calibri", color: C.muted, margin: 0 });
// Client portal features — left
s.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: 1.3, w: 4.5, h: 3.7, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addImage({ data: icons.cart_purple, x: 0.6, y: 1.45, w: 0.4, h: 0.4 });
s.addText("Portail client (client.gigafibre.ca)", { x: 1.1, y: 1.45, w: 3.5, h: 0.4, fontSize: 14, fontFace: "Calibri", color: C.white, bold: true, margin: 0, valign: "middle" });
const portalFeats = [
"Consultation factures et historique",
"Paiement en ligne via Stripe Checkout",
"Gestion des abonnements actifs",
"Tickets de support (creation + suivi)",
"Catalogue produits avec panier",
"Checkout + soumission commande",
"Authentification SSO (id.gigafibre.ca)",
"OTP SMS pour recuperation de compte",
];
portalFeats.forEach((f, i) => {
s.addImage({ data: icons.check_purple, x: 0.7, y: 2.05 + i * 0.35, w: 0.18, h: 0.18 });
s.addText(f, { x: 1.0, y: 2.05 + i * 0.35, w: 3.7, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.text, margin: 0, valign: "middle" });
});
// Catalogue — right
s.addShape(pres.shapes.RECTANGLE, { x: 5.2, y: 1.3, w: 4.3, h: 3.7, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addImage({ data: icons.toolbox_purple, x: 5.4, y: 1.45, w: 0.4, h: 0.4 });
s.addText("Catalogue & wizard", { x: 5.9, y: 1.45, w: 3.3, h: 0.4, fontSize: 14, fontFace: "Calibri", color: C.white, bold: true, margin: 0, valign: "middle" });
const catalogItems = [
{ name: "Internet Fibre", price: "a partir de 55$/mois" },
{ name: "Television IPTV", price: "20$/mois" },
{ name: "Telephonie VoIP", price: "15$/mois" },
{ name: "Support Prioritaire", price: "10$/mois" },
];
catalogItems.forEach((it, i) => {
const cy = 2.1 + i * 0.7;
s.addShape(pres.shapes.RECTANGLE, { x: 5.4, y: cy, w: 3.9, h: 0.55, fill: { color: "1E2338" } });
s.addText(it.name, { x: 5.6, y: cy, w: 2.4, h: 0.55, fontSize: 12, fontFace: "Calibri", color: C.white, bold: true, margin: 0, valign: "middle" });
s.addText(it.price, { x: 7.8, y: cy, w: 1.4, h: 0.55, fontSize: 11, fontFace: "Calibri", color: C.green, margin: 0, valign: "middle", align: "right" });
});
// ═══════════════════════════════════════
// SLIDE 8 — Vue mobile technicien
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Vue mobile technicien", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
s.addText("Integree dans l'ops app a /j — envoyee par SMS apres publication de l'horaire", { x: 0.6, y: 0.8, w: 9, h: 0.35, fontSize: 12, fontFace: "Calibri", color: C.muted, margin: 0 });
// Phone mockup - left
s.addShape(pres.shapes.RECTANGLE, { x: 0.8, y: 1.3, w: 3.0, h: 4.0, fill: { color: "1A1D30" }, shadow: mkShadow() });
s.addShape(pres.shapes.RECTANGLE, { x: 0.8, y: 1.3, w: 3.0, h: 0.06, fill: { color: C.purple } });
// Phone header
s.addShape(pres.shapes.RECTANGLE, { x: 0.8, y: 1.36, w: 3.0, h: 1.0, fill: { color: C.purpleDk } });
s.addText("jeudi 9 avril", { x: 0.95, y: 1.4, w: 2.5, h: 0.25, fontSize: 8, fontFace: "Calibri", color: C.muted, margin: 0 });
s.addText("Louis-Paul Bourdon", { x: 0.95, y: 1.6, w: 2.5, h: 0.3, fontSize: 12, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
// Stats mini
const miniStats = [{ v: "3", l: "Total" }, { v: "3", l: "A faire" }, { v: "0", l: "Faits" }];
miniStats.forEach((ms, i) => {
s.addShape(pres.shapes.RECTANGLE, { x: 0.95 + i * 0.85, y: 2.0, w: 0.75, h: 0.42, fill: { color: "4A4880" } });
s.addText(ms.v, { x: 0.95 + i * 0.85, y: 2.0, w: 0.75, h: 0.25, fontSize: 11, fontFace: "Arial Black", color: C.white, align: "center", margin: 0 });
s.addText(ms.l, { x: 0.95 + i * 0.85, y: 2.22, w: 0.75, h: 0.18, fontSize: 6, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 });
});
// Job cards mini
const miniJobs = [
{ id: "SUP-003", title: "Panne IP Phone", loc: "Vieux-Port", time: "08h09", urgent: true },
{ id: "INS-047", title: "Installation Fibre", loc: "Plateau", time: "10h30", urgent: false },
];
miniJobs.forEach((mj, i) => {
const my = 2.6 + i * 0.85;
s.addShape(pres.shapes.RECTANGLE, { x: 0.95, y: my, w: 2.5, h: 0.7, fill: { color: C.cardBg } });
s.addShape(pres.shapes.RECTANGLE, { x: 0.95, y: my, w: 0.06, h: 0.7, fill: { color: mj.urgent ? C.red : C.purple } });
s.addText(mj.id, { x: 1.1, y: my + 0.03, w: 1.0, h: 0.2, fontSize: 7, fontFace: "Calibri", color: C.accent, bold: true, margin: 0 });
s.addText(mj.time, { x: 2.85, y: my + 0.03, w: 0.55, h: 0.2, fontSize: 7, fontFace: "Calibri", color: C.text, margin: 0, align: "right" });
s.addText(mj.title, { x: 1.1, y: my + 0.22, w: 2.2, h: 0.22, fontSize: 8, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
s.addText(mj.loc, { x: 1.1, y: my + 0.45, w: 2.2, h: 0.2, fontSize: 7, fontFace: "Calibri", color: C.muted, margin: 0 });
});
// Features — right
const techFeats = [
{ icon: "check", title: "Actions rapides", desc: "En route / Terminer depuis la liste ou le bottom sheet. GPS navigation integree." },
{ icon: "qr", title: "Scanner equipement", desc: "Recherche par SN/MAC, creation inline, liaison automatique au job en cours." },
{ icon: "cogs", title: "Edition inline", desc: "Modifier sujet, heure, duree, notes directement depuis le telephone." },
{ icon: "chart", title: "Diagnostic reseau", desc: "Speed test, verification d'hotes, latence — directement depuis le terrain." },
];
techFeats.forEach((f, i) => {
const fy = 1.3 + i * 1.0;
s.addShape(pres.shapes.RECTANGLE, { x: 4.5, y: fy, w: 5.1, h: 0.85, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addShape(pres.shapes.OVAL, { x: 4.7, y: fy + 0.15, w: 0.5, h: 0.5, fill: { color: C.purpleDk } });
s.addImage({ data: icons[f.icon], x: 4.8, y: fy + 0.25, w: 0.3, h: 0.3 });
s.addText(f.title, { x: 5.35, y: fy + 0.05, w: 4.0, h: 0.3, fontSize: 13, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
s.addText(f.desc, { x: 5.35, y: fy + 0.35, w: 4.0, h: 0.45, fontSize: 10, fontFace: "Calibri", color: C.muted, margin: 0 });
});
// ═══════════════════════════════════════
// SLIDE 9 — Stack technique
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addText("Stack technique", { x: 0.6, y: 0.25, w: 9, h: 0.6, fontSize: 28, fontFace: "Arial Black", color: C.white, margin: 0 });
const techStack = [
{ cat: "Frontend", items: "Vue 3 (Composition API)\nQuasar v2.19 (PWA)\nPinia stores\nMapbox GL JS\nSCSS + CSS variables" },
{ cat: "Backend", items: "Node.js 20 (targo-hub)\nERPNext v16 (Python)\nn8n (automations)\nPostgreSQL 14\nRedis (cache/queue)" },
{ cat: "Infra", items: "Docker Compose\nTraefik v2.11 (TLS)\nAuthentik SSO\nLet's Encrypt\nProxmox VM" },
{ cat: "Integrations", items: "Twilio (SMS/Voix)\nGenieACS (TR-069)\nStripe (Paiements)\nGemini AI (OCR)\nCloudflare (DNS)" },
];
techStack.forEach((ts, i) => {
const tx = 0.4 + i * 2.35;
s.addShape(pres.shapes.RECTANGLE, { x: tx, y: 1.1, w: 2.15, h: 3.5, fill: { color: C.cardBg }, shadow: mkShadow() });
s.addShape(pres.shapes.RECTANGLE, { x: tx, y: 1.1, w: 2.15, h: 0.06, fill: { color: i === 0 ? C.indigo : i === 1 ? C.green : i === 2 ? C.amber : C.red } });
s.addText(ts.cat, { x: tx + 0.15, y: 1.3, w: 1.85, h: 0.4, fontSize: 15, fontFace: "Calibri", color: C.white, bold: true, margin: 0 });
s.addText(ts.items, { x: tx + 0.15, y: 1.8, w: 1.85, h: 2.6, fontSize: 11, fontFace: "Calibri", color: C.muted, margin: 0 });
});
// Deployment
s.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: 4.85, w: 9.2, h: 0.55, fill: { color: C.cardBg } });
s.addText([
{ text: "Deploy: ", options: { bold: true, color: C.white, fontSize: 11 } },
{ text: "npx quasar build", options: { color: C.green, fontSize: 11, fontFace: "Consolas" } },
{ text: " → ", options: { color: C.muted, fontSize: 11 } },
{ text: "scp dist/spa/* root@96.125.196.67:/opt/ops-app/", options: { color: C.accent, fontSize: 10, fontFace: "Consolas" } },
{ text: " (no restart needed)", options: { color: C.muted, fontSize: 10 } },
], { x: 0.6, y: 4.85, w: 8.8, h: 0.55, margin: 0, valign: "middle" });
// ═══════════════════════════════════════
// SLIDE 10 — Closing
// ═══════════════════════════════════════
s = pres.addSlide();
s.background = { color: C.darkBg };
s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } });
s.addShape(pres.shapes.RECTANGLE, { x: 0.8, y: 1.8, w: 0.12, h: 1.8, fill: { color: C.indigo } });
s.addText("GIGAFIBRE FSM", { x: 1.15, y: 1.8, w: 8, h: 0.7, fontSize: 36, fontFace: "Arial Black", color: C.white, margin: 0 });
s.addText("Questions ?", { x: 1.15, y: 2.5, w: 8, h: 0.5, fontSize: 22, fontFace: "Calibri", color: C.accent, margin: 0 });
const urls = [
{ lbl: "Ops App", url: "erp.gigafibre.ca/ops/" },
{ lbl: "ERPNext", url: "erp.gigafibre.ca" },
{ lbl: "Vue Tech", url: "erp.gigafibre.ca/ops/#/j" },
{ lbl: "Portail", url: "client.gigafibre.ca" },
{ lbl: "Git", url: "git.targo.ca/louis/gigafibre-fsm" },
];
urls.forEach((u, i) => {
s.addText(u.lbl, { x: 1.15, y: 3.4 + i * 0.35, w: 1.5, h: 0.3, fontSize: 12, fontFace: "Calibri", color: C.muted, margin: 0, valign: "middle" });
s.addText(u.url, { x: 2.6, y: 3.4 + i * 0.35, w: 5, h: 0.3, fontSize: 12, fontFace: "Consolas", color: C.accent, margin: 0, valign: "middle" });
});
s.addText("Targo — Avril 2026", { x: 0.8, y: 5.1, w: 9, h: 0.3, fontSize: 10, fontFace: "Calibri", color: C.muted, margin: 0 });
// Write file
await pres.writeFile({ fileName: "/Users/louispaul/Documents/testap/gigafibre-fsm/docs/Gigafibre-FSM-Features.pptx" });
console.log("Presentation created!");
}
build().catch(e => { console.error(e); process.exit(1); });

803
docs/package-lock.json generated Normal file
View File

@ -0,0 +1,803 @@
{
"name": "docs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"pptxgenjs": "^4.0.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-icons": "^5.6.0",
"sharp": "^0.34.5"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/pptxgenjs": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.8.1",
"https": "^1.0.0",
"image-size": "^1.2.1",
"jszip": "^3.10.1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.5"
}
},
"node_modules/react-icons": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
}
}
}

9
docs/package.json Normal file
View File

@ -0,0 +1,9 @@
{
"dependencies": {
"pptxgenjs": "^4.0.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-icons": "^5.6.0",
"sharp": "^0.34.5"
}
}

View File

@ -25,6 +25,8 @@ def create_all():
_create_job_photo()
_create_checklist_template()
_extend_dispatch_job()
_create_contract_benefit()
_create_service_contract()
frappe.db.commit()
print("✓ FSM doctypes created successfully.")
@ -493,3 +495,172 @@ def _extend_dispatch_job():
print(f" + Dispatch Technician.{fieldname}")
print(" ✓ Dispatch Technician extended with resource type fields.")
# ─────────────────────────────────────────────────────────────────────────────
# Contract Benefit — Child table for benefits granted at installation
# Example: "Installation 0$ sur entente 24 mois (prix régulier 288$)"
# ─────────────────────────────────────────────────────────────────────────────
def _create_contract_benefit():
if frappe.db.exists("DocType", "Contract Benefit"):
print(" Contract Benefit already exists — skipping.")
return
doc = frappe.get_doc({
"doctype": "DocType",
"name": "Contract Benefit",
"module": "Dispatch",
"custom": 1,
"istable": 1,
"fields": [
{"fieldname": "description", "fieldtype": "Data", "label": "Description",
"reqd": 1, "in_list_view": 1, "columns": 3},
{"fieldname": "regular_price", "fieldtype": "Currency", "label": "Prix régulier",
"reqd": 1, "in_list_view": 1, "columns": 1,
"description": "Coût réel du service/installation (ex: 288$)"},
{"fieldname": "granted_price", "fieldtype": "Currency", "label": "Prix accordé",
"in_list_view": 1, "columns": 1, "default": "0",
"description": "Prix facturé au client (ex: 0$ = gratuit)"},
{"fieldname": "benefit_value", "fieldtype": "Currency", "label": "Valeur avantage",
"read_only": 1, "in_list_view": 1, "columns": 1,
"description": "regular_price - granted_price (calculé automatiquement)"},
{"fieldname": "monthly_recognition", "fieldtype": "Currency",
"label": "Reconnaissance mensuelle", "read_only": 1, "in_list_view": 1, "columns": 1,
"description": "benefit_value / durée contrat mois (compensé par mois)"},
{"fieldname": "months_recognized", "fieldtype": "Int",
"label": "Mois reconnus", "read_only": 1, "default": "0",
"description": "Nombre de mois d'abonnement écoulés depuis le début"},
{"fieldname": "remaining_value", "fieldtype": "Currency",
"label": "Valeur résiduelle", "read_only": 1,
"description": "Montant restant non compensé (pour résiliation anticipée)"},
],
})
doc.insert(ignore_permissions=True)
print(" ✓ Contract Benefit child table created.")
# ─────────────────────────────────────────────────────────────────────────────
# Service Contract — Engagement/Entente de service
# Handles both residential and commercial contracts with different
# termination penalty rules:
# - Résidentiel: max mois courant + pro-rata des avantages non compensés
# - Commercial: paiement de toutes les mensualités restantes
# ─────────────────────────────────────────────────────────────────────────────
def _create_service_contract():
if frappe.db.exists("DocType", "Service Contract"):
print(" Service Contract already exists — skipping.")
return
doc = frappe.get_doc({
"doctype": "DocType",
"name": "Service Contract",
"module": "Dispatch",
"custom": 1,
"autoname": "CTR-.#####",
"track_changes": 1,
"fields": [
# ── Parties ─────────────────────────────────────────────────────
{"fieldname": "customer", "fieldtype": "Link", "label": "Client",
"options": "Customer", "reqd": 1, "in_list_view": 1},
{"fieldname": "customer_name", "fieldtype": "Data", "label": "Nom du client",
"fetch_from": "customer.customer_name", "read_only": 1},
{"fieldname": "col_contract1", "fieldtype": "Column Break"},
{"fieldname": "contract_type", "fieldtype": "Select", "label": "Type de contrat",
"options": "Résidentiel\nCommercial", "default": "Résidentiel",
"reqd": 1, "in_list_view": 1,
"description": "Résidentiel: pénalité = avantages non compensés. Commercial: totalité des mensualités restantes."},
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
"options": "Brouillon\nEnvoyé\nActif\nRésilié\nComplété\nExpiré",
"default": "Brouillon", "in_list_view": 1, "reqd": 1},
# ── Durée ───────────────────────────────────────────────────────
{"fieldname": "sec_duration", "fieldtype": "Section Break", "label": "Durée de l'engagement"},
{"fieldname": "start_date", "fieldtype": "Date", "label": "Début du contrat"},
{"fieldname": "duration_months", "fieldtype": "Int", "label": "Durée (mois)",
"default": "24", "reqd": 1,
"description": "Durée de l'engagement en mois (ex: 24)"},
{"fieldname": "col_duration", "fieldtype": "Column Break"},
{"fieldname": "end_date", "fieldtype": "Date", "label": "Fin prévue",
"read_only": 1, "description": "Calculé: start_date + duration_months"},
{"fieldname": "months_elapsed", "fieldtype": "Int", "label": "Mois écoulés",
"read_only": 1, "default": "0"},
{"fieldname": "months_remaining", "fieldtype": "Int", "label": "Mois restants",
"read_only": 1},
# ── Abonnement mensuel ─────────────────────────────────────────
{"fieldname": "sec_subscription", "fieldtype": "Section Break", "label": "Abonnement"},
{"fieldname": "monthly_rate", "fieldtype": "Currency", "label": "Mensualité",
"reqd": 1, "description": "Montant mensuel de l'abonnement (ex: 79.95$)"},
{"fieldname": "subscription", "fieldtype": "Link", "label": "Abonnement ERPNext",
"options": "Subscription",
"description": "Lien vers le Subscription ERPNext associé"},
{"fieldname": "col_sub", "fieldtype": "Column Break"},
{"fieldname": "service_location", "fieldtype": "Link", "label": "Lieu de service",
"options": "Service Location"},
{"fieldname": "quotation", "fieldtype": "Link", "label": "Devis d'origine",
"options": "Quotation"},
# ── Avantages accordés (child table) ──────────────────────────
{"fieldname": "sec_benefits", "fieldtype": "Section Break",
"label": "Avantages accordés",
"description": "Avantages gratuits compensés au pro-rata sur la durée du contrat"},
{"fieldname": "benefits", "fieldtype": "Table", "label": "Avantages",
"options": "Contract Benefit"},
{"fieldname": "total_benefit_value", "fieldtype": "Currency",
"label": "Valeur totale des avantages", "read_only": 1},
{"fieldname": "total_remaining_value", "fieldtype": "Currency",
"label": "Valeur résiduelle totale", "read_only": 1,
"description": "Somme des avantages non encore compensés"},
# ── Mention première facture ──────────────────────────────────
{"fieldname": "sec_invoice_note", "fieldtype": "Section Break",
"label": "Mention sur facture"},
{"fieldname": "invoice_note", "fieldtype": "Small Text",
"label": "Mention sur la première facture",
"description": "Ex: Installation 0$ sur entente de 24 mois. (Prix régulier 288$) Chaque mois d'abonnement reconnaît 12$ de compensé."},
# ── Signature / Acceptation ───────────────────────────────────
{"fieldname": "sec_signature", "fieldtype": "Section Break", "label": "Acceptation"},
{"fieldname": "acceptance_method", "fieldtype": "Select",
"label": "Méthode d'acceptation",
"options": "\nDocuSeal\nJWT SMS\nSignature physique",
"description": "DocuSeal = e-signature via sign.gigafibre.ca, JWT SMS = confirmation par lien SMS"},
{"fieldname": "docuseal_template_id", "fieldtype": "Int",
"label": "Template DocuSeal ID"},
{"fieldname": "docuseal_submission_id", "fieldtype": "Int",
"label": "DocuSeal Submission ID", "read_only": 1},
{"fieldname": "col_sig", "fieldtype": "Column Break"},
{"fieldname": "signed_at", "fieldtype": "Datetime", "label": "Signé le", "read_only": 1},
{"fieldname": "signed_by", "fieldtype": "Data", "label": "Signé par", "read_only": 1},
{"fieldname": "signature_proof", "fieldtype": "Small Text",
"label": "Preuve d'acceptation", "read_only": 1,
"description": "IP, user-agent, horodatage, lien DocuSeal"},
# ── Résiliation ───────────────────────────────────────────────
{"fieldname": "sec_termination", "fieldtype": "Section Break",
"label": "Résiliation anticipée", "collapsible": 1},
{"fieldname": "terminated_at", "fieldtype": "Date", "label": "Date de résiliation",
"read_only": 1},
{"fieldname": "termination_reason", "fieldtype": "Small Text",
"label": "Raison de résiliation"},
{"fieldname": "col_term", "fieldtype": "Column Break"},
{"fieldname": "termination_fee_benefits", "fieldtype": "Currency",
"label": "Pénalité avantages (résidentiel)", "read_only": 1,
"description": "Valeur résiduelle des avantages non compensés"},
{"fieldname": "termination_fee_remaining", "fieldtype": "Currency",
"label": "Pénalité mensualités (commercial)", "read_only": 1,
"description": "Mois restants × mensualité"},
{"fieldname": "termination_fee_total", "fieldtype": "Currency",
"label": "Pénalité totale", "read_only": 1, "bold": 1},
{"fieldname": "termination_invoice", "fieldtype": "Link",
"label": "Facture de résiliation", "options": "Sales Invoice", "read_only": 1},
],
"permissions": [
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1},
{"role": "Dispatch User", "read": 1, "write": 1, "create": 1},
],
})
doc.insert(ignore_permissions=True)
print(" ✓ Service Contract doctype created.")

162
patches/add_churn_fields.py Normal file
View File

@ -0,0 +1,162 @@
"""
Add churn/cancel reason tracking fields to Customer and Issue doctypes.
Enables AI-powered retention intelligence and win-back campaigns.
Run: docker cp add_churn_fields.py erpnext-backend-1:/tmp/
docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute "exec(open('/tmp/add_churn_fields.py').read())"
"""
import frappe
def add_custom_fields(doctype, fields):
count = 0
for f in fields:
fieldname = f["fieldname"]
exists = frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": fieldname})
if exists:
print(f" EXISTS: {doctype}.{fieldname}")
continue
doc = frappe.get_doc({"doctype": "Custom Field", "dt": doctype, **f})
doc.insert(ignore_permissions=True)
count += 1
print(f" ADDED: {doctype}.{fieldname}")
return count
# ── Customer: Churn/Retention fields ─────────────────────────────────────────
customer_fields = [
# Section break for churn tracking
{"fieldname": "churn_section", "fieldtype": "Section Break",
"label": "Rétention / Désabonnement", "insert_after": "notes_internal",
"collapsible": 1},
# Cancel status
{"fieldname": "churn_status", "fieldtype": "Select",
"label": "Statut rétention",
"options": "\nActif\nÀ risque\nDésabonné\nRécupéré",
"insert_after": "churn_section"},
{"fieldname": "churn_cb1", "fieldtype": "Column Break",
"insert_after": "churn_status"},
# Cancel date
{"fieldname": "cancel_date", "fieldtype": "Date",
"label": "Date désabonnement",
"insert_after": "churn_cb1"},
# Cancel reason
{"fieldname": "cancel_reason", "fieldtype": "Select",
"label": "Raison désabonnement",
"options": "\nCompétiteur - Promotion\nCompétiteur - Prix\nCompétiteur - Service\nPrix trop élevé\nQualité WiFi\nPannes fréquentes\nService client\nDéménagement hors zone\nDéménagement dans zone\nDécès\nAutre",
"insert_after": "cancel_date"},
{"fieldname": "churn_cb2", "fieldtype": "Column Break",
"insert_after": "cancel_reason"},
# Competitor info
{"fieldname": "cancel_competitor", "fieldtype": "Data",
"label": "Compétiteur",
"description": "Bell, Vidéotron, Fizz, etc.",
"insert_after": "churn_cb2",
"depends_on": "eval:['Compétiteur - Promotion','Compétiteur - Prix','Compétiteur - Service'].includes(doc.cancel_reason)"},
{"fieldname": "cancel_competitor_offer", "fieldtype": "Small Text",
"label": "Offre compétiteur",
"description": "Ex: 6 mois gratuit, 49.99$/mois fibre 1Gbps",
"insert_after": "cancel_competitor",
"depends_on": "eval:['Compétiteur - Promotion','Compétiteur - Prix','Compétiteur - Service'].includes(doc.cancel_reason)"},
# Notes
{"fieldname": "cancel_notes", "fieldtype": "Small Text",
"label": "Notes rétention",
"description": "Contexte: ce que le client a dit, offre proposée, etc.",
"insert_after": "cancel_competitor_offer"},
# Win-back tracking
{"fieldname": "winback_section", "fieldtype": "Section Break",
"label": "Récupération", "insert_after": "cancel_notes",
"collapsible": 1,
"depends_on": "eval:doc.churn_status=='Désabonné'"},
{"fieldname": "winback_attempts", "fieldtype": "Int",
"label": "Tentatives récupération", "default": "0",
"insert_after": "winback_section"},
{"fieldname": "winback_last_date", "fieldtype": "Date",
"label": "Dernière tentative",
"insert_after": "winback_attempts"},
{"fieldname": "winback_cb", "fieldtype": "Column Break",
"insert_after": "winback_last_date"},
{"fieldname": "winback_date", "fieldtype": "Date",
"label": "Date récupération",
"insert_after": "winback_cb",
"depends_on": "eval:doc.churn_status=='Récupéré'"},
{"fieldname": "winback_offer", "fieldtype": "Small Text",
"label": "Offre accordée",
"description": "Offre qui a convaincu le client de revenir",
"insert_after": "winback_date",
"depends_on": "eval:doc.churn_status=='Récupéré'"},
# Risk score (AI-populated)
{"fieldname": "churn_risk_score", "fieldtype": "Int",
"label": "Score risque désabonnement",
"description": "0-100, calculé par l'IA basé sur le comportement",
"insert_after": "winback_offer", "read_only": 1},
]
# ── Issue: Enhanced categorization ───────────────────────────────────────────
issue_fields = [
# Customer link (if not already present)
{"fieldname": "customer", "fieldtype": "Link",
"label": "Client", "options": "Customer",
"insert_after": "naming_series"},
# Outage tracking
{"fieldname": "outage_section", "fieldtype": "Section Break",
"label": "Panne / Outage", "insert_after": "resolution_details",
"collapsible": 1},
{"fieldname": "outage_type", "fieldtype": "Select",
"label": "Type de panne",
"options": "\nPanne isolée\nPanne secteur\nCoupure fibre\nPanne OLT\nPanne backbone\nPanne courant",
"insert_after": "outage_section"},
{"fieldname": "outage_cb", "fieldtype": "Column Break",
"insert_after": "outage_type"},
{"fieldname": "affected_count", "fieldtype": "Int",
"label": "Clients affectés",
"insert_after": "outage_cb"},
{"fieldname": "olt_name", "fieldtype": "Data",
"label": "OLT",
"insert_after": "affected_count"},
{"fieldname": "olt_port", "fieldtype": "Data",
"label": "Port OLT",
"insert_after": "olt_name"},
# AI diagnosis
{"fieldname": "ai_diagnosis", "fieldtype": "Small Text",
"label": "Diagnostic IA",
"insert_after": "olt_port", "read_only": 1},
# Cancel reason (for support tickets about cancellation)
{"fieldname": "cancel_intent", "fieldtype": "Check",
"label": "Intention de désabonnement",
"insert_after": "ai_diagnosis"},
]
# ── Apply ────────────────────────────────────────────────────────────────────
print("Adding churn/retention fields to Customer...")
c1 = add_custom_fields("Customer", customer_fields)
print("\nAdding outage/categorization fields to Issue...")
c2 = add_custom_fields("Issue", issue_fields)
frappe.db.commit()
print(f"\nDone: {c1} Customer fields + {c2} Issue fields added")

147
patches/fix_pg_groupby.py Normal file
View File

@ -0,0 +1,147 @@
"""
ERPNext v16 PostgreSQL GROUP BY fixes.
PostgreSQL requires all non-aggregated SELECT columns in GROUP BY.
MySQL is lenient, ERPNext was built for MySQL.
Run: docker cp fix_pg_groupby.py erpnext-backend-1:/tmp/
docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute "exec(open('/tmp/fix_pg_groupby.py').read())"
"""
import os
BENCH = "/home/frappe/frappe-bench/apps/erpnext/erpnext"
patches = []
# ─── 1. controllers/trends.py — based_on_group_by for Customer, Supplier, Item ───
patches.append((
f"{BENCH}/controllers/trends.py",
[
# Customer (non-Quotation): group by needs customer_name, territory
(
'based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"',
'based_on_details["based_on_group_by"] = "t1.party_name, t1.customer_name, t1.territory" if trans == "Quotation" else "t1.customer, t1.customer_name, t1.territory"',
),
# Supplier: group by needs supplier_name, supplier_group
(
'based_on_details["based_on_group_by"] = "t1.supplier"',
'based_on_details["based_on_group_by"] = "t1.supplier, t1.supplier_name, t3.supplier_group"',
),
# Item: group by needs item_name
(
'based_on_details["based_on_group_by"] = "t2.item_code"',
'based_on_details["based_on_group_by"] = "t2.item_code, t2.item_name"',
),
]
))
# ─── 2. trial_balance.py — account_currency missing from groupby ───
patches.append((
f"{BENCH}/accounts/report/trial_balance/trial_balance.py",
[
(
".groupby(closing_balance.account)",
".groupby(closing_balance.account, closing_balance.account_currency)",
),
]
))
# ─── 3. process_period_closing_voucher.py — account_currency ───
patches.append((
f"{BENCH}/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py",
[
# Add account_currency to groupby after account
(
"query = query.groupby(gle.account)",
"query = query.groupby(gle.account, gle.account_currency)",
),
]
))
# ─── 4. exchange_rate_revaluation.py — account_currency ───
patches.append((
f"{BENCH}/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py",
[
(
'.groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""))',
'.groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""), gle.account_currency)',
),
]
))
# ─── 5. pos_closing_entry.py — account missing ───
patches.append((
f"{BENCH}/accounts/doctype/pos_closing_entry/pos_closing_entry.py",
[
(
".groupby(SalesInvoicePayment.mode_of_payment)",
".groupby(SalesInvoicePayment.mode_of_payment, SalesInvoicePayment.account)",
),
]
))
# ─── 6. voucher_wise_balance.py — voucher_type missing ───
patches.append((
f"{BENCH}/accounts/report/voucher_wise_balance/voucher_wise_balance.py",
[
(
".groupby(gle.voucher_no)",
".groupby(gle.voucher_no, gle.voucher_type)",
),
]
))
# ─── 7. total_stock_summary.py — item.description missing ───
patches.append((
f"{BENCH}/stock/report/total_stock_summary/total_stock_summary.py",
[
(
".groupby(item.item_code)",
".groupby(item.item_code, item.description)",
),
]
))
# ─── 8. stock_entry.py — job_card_scrap_item missing cols ───
patches.append((
f"{BENCH}/stock/doctype/stock_entry/stock_entry.py",
[
(
".groupby(job_card_scrap_item.item_code)",
".groupby(job_card_scrap_item.item_code, job_card_scrap_item.item_name, job_card_scrap_item.description, job_card_scrap_item.stock_uom)",
),
]
))
# ─── Apply patches ───
applied = 0
skipped = 0
errors = 0
for filepath, replacements in patches:
if not os.path.exists(filepath):
print(f"SKIP (not found): {filepath}")
skipped += 1
continue
with open(filepath, "r") as f:
content = f.read()
modified = False
for old, new in replacements:
if old in content:
content = content.replace(old, new, 1)
modified = True
print(f" PATCHED: {old[:60]}...")
elif new in content:
print(f" ALREADY: {new[:60]}...")
else:
print(f" NOT FOUND in {filepath}: {old[:60]}...")
errors += 1
if modified:
with open(filepath, "w") as f:
f.write(content)
applied += 1
print(f"OK: {filepath}")
print(f"\nDone: {applied} files patched, {skipped} skipped, {errors} not found")

View File

@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* Import legacy chart of accounts into ERPNext.
* Maps the 37 accounts from facturation.targo.ca's compta_comptes table
* to the existing ERPNext account tree structure.
*
* Usage: node scripts/import-legacy-accounts.js
* Requires: ERP_URL and ERP_TOKEN env vars (or defaults to targo-hub's config)
*/
'use strict'
const ERP_URL = process.env.ERP_URL || 'https://erp.gigafibre.ca'
const ERP_TOKEN = process.env.ERP_TOKEN || ''
const COMPANY = 'TARGO'
const ABBR = 'T'
if (!ERP_TOKEN) {
console.error('ERP_TOKEN env var required. Get it from ERPNext > Settings > API Access')
console.error('Or: ssh root@96.125.196.67 "docker exec erpnext-backend bench --site erp.gigafibre.ca show-config" | grep api')
process.exit(1)
}
async function erpFetch(path, opts = {}) {
const url = ERP_URL + path
const headers = {
'Authorization': 'token ' + ERP_TOKEN,
'Content-Type': 'application/json',
...opts.headers,
}
const res = await fetch(url, { ...opts, headers })
const data = await res.json().catch(() => null)
return { status: res.status, data }
}
async function accountExists(accountName) {
const res = await erpFetch(`/api/resource/Account/${encodeURIComponent(accountName)}`)
return res.status === 200
}
async function createAccount(doc) {
const name = `${doc.account_number ? doc.account_number + ' - ' : ''}${doc.account_name} - ${ABBR}`
// Check if already exists
if (await accountExists(name)) {
console.log(` ✓ EXISTS: ${name}`)
return { existed: true, name }
}
const payload = {
doctype: 'Account',
company: COMPANY,
...doc,
}
const res = await erpFetch('/api/resource/Account', {
method: 'POST',
body: JSON.stringify(payload),
})
if (res.status === 200 || res.status === 201) {
console.log(` ✅ CREATED: ${name}`)
return { created: true, name: res.data?.data?.name || name }
} else {
const err = res.data?.exc || res.data?._server_messages || res.data?.message || JSON.stringify(res.data)
console.error(` ❌ FAILED: ${name}${res.status}${err}`)
return { error: true, name, status: res.status, message: err }
}
}
// ── Account definitions mapped to ERPNext parent groups ──────────────
// First, create parent groups that don't exist yet
const PARENT_GROUPS = [
// Revenue sub-groups under "Revenus de ventes - T"
{
account_name: 'Revenus Télécom',
parent_account: `Revenus de ventes - ${ABBR}`,
root_type: 'Income',
is_group: 1,
account_type: '',
},
{
account_name: 'Revenus Internet & Installation',
parent_account: `Revenus de ventes - ${ABBR}`,
root_type: 'Income',
is_group: 1,
account_type: '',
},
{
account_name: 'Revenus Services TI',
parent_account: `Revenus de ventes - ${ABBR}`,
root_type: 'Income',
is_group: 1,
account_type: '',
},
{
account_name: 'Revenus Divers',
parent_account: `Revenus de ventes - ${ABBR}`,
root_type: 'Income',
is_group: 1,
account_type: '',
},
// Expense sub-group for legacy expense accounts
{
account_name: 'Frais divers legacy',
parent_account: `Frais fixes - ${ABBR}`,
root_type: 'Expense',
is_group: 1,
account_type: '',
},
// Taxes sub-group under Duties and Taxes
{
account_name: 'Taxes de vente legacy',
parent_account: `Duties and Taxes - ${ABBR}`,
root_type: 'Liability',
is_group: 1,
account_type: '',
},
]
// ── ASSET accounts (1000-1205) ──────────────────────────────────────
const ASSET_ACCOUNTS = [
{ account_number: '1000', account_name: 'Encaisse PPA', parent_account: `Encaisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1001', account_name: 'Encaisse Paiement direct', parent_account: `Encaisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1002', account_name: 'Encaisse Carte de crédit', parent_account: `Encaisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1003', account_name: 'Encaisse Comptant/Chèques', parent_account: `Encaisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1004', account_name: 'Encaisse Ajustements', parent_account: `Encaisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1005', account_name: 'Encaissement de crédit', parent_account: `Encaisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1050', account_name: 'Petite caisse - bureau', parent_account: `Petite caisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1060', account_name: 'Petite caisse - service à la clientèle', parent_account: `Petite caisse - ${ABBR}`, root_type: 'Asset', account_type: 'Cash', is_group: 0 },
{ account_number: '1100', account_name: 'Caisse populaire', parent_account: `Banque - ${ABBR}`, root_type: 'Asset', account_type: 'Bank', is_group: 0 },
{ account_number: '1200', account_name: 'Comptes à recevoir legacy', parent_account: `Comptes à recevoir - ${ABBR}`, root_type: 'Asset', account_type: 'Receivable', is_group: 0 },
{ account_number: '1205', account_name: 'Provisions mauvaises créances', parent_account: `Comptes à recevoir - ${ABBR}`, root_type: 'Asset', account_type: '', is_group: 0 },
]
// ── LIABILITY accounts (2110-2355) ──────────────────────────────────
const LIABILITY_ACCOUNTS = [
{ account_number: '2110', account_name: 'Excédent', parent_account: `Passif à court terme - ${ABBR}`, root_type: 'Liability', account_type: '', is_group: 0 },
{ account_number: '2115', account_name: 'Dépôt/Acompte client', parent_account: `Passif à court terme - ${ABBR}`, root_type: 'Liability', account_type: '', is_group: 0 },
{ account_number: '2300', account_name: 'TPS perçue', parent_account: `Taxes de vente legacy - ${ABBR}`, root_type: 'Liability', account_type: 'Tax', is_group: 0 },
{ account_number: '2305', account_name: 'TPS payée', parent_account: `Taxes à recevoir - ${ABBR}`, root_type: 'Asset', account_type: 'Tax', is_group: 0 },
{ account_number: '2350', account_name: 'TVQ perçue', parent_account: `Taxes de vente legacy - ${ABBR}`, root_type: 'Liability', account_type: 'Tax', is_group: 0 },
{ account_number: '2355', account_name: 'TVQ payée', parent_account: `Taxes à recevoir - ${ABBR}`, root_type: 'Asset', account_type: 'Tax', is_group: 0 },
]
// ── INCOME/REVENUE accounts (4000-4300) ─────────────────────────────
const INCOME_ACCOUNTS = [
// Telecom & TV
{ account_number: '4015', account_name: 'Téléphonie', parent_account: `Revenus Télécom - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4019', account_name: 'Mensualité télévision', parent_account: `Revenus Télécom - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4018', account_name: 'Équipement télé', parent_account: `Revenus Télécom - ${ABBR}`, root_type: 'Income', is_group: 0 },
// Internet & Installation (Fibre + Sans-fil)
{ account_number: '4020', account_name: 'Mensualité fibre', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4017', account_name: 'Installation et équipement fibre', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4021', account_name: 'Mensualité Internet Haute-Vitesse sans fil', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4022', account_name: 'Installation Internet Haute-Vitesse sans fil', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4023', account_name: 'Équipement Internet Haute-Vitesse sans fil', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4024', account_name: 'Téléchargement supplémentaire', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4027', account_name: 'IP Fixe', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4028', account_name: "Frais d'activation", parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4025', account_name: 'Garantie prolongée', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4026', account_name: 'Section de Tour', parent_account: `Revenus Internet & Installation - ${ABBR}`, root_type: 'Income', is_group: 0 },
// Services TI
{ account_number: '4031', account_name: 'Création de site Internet', parent_account: `Revenus Services TI - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4041', account_name: 'Nom de Domaine', parent_account: `Revenus Services TI - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4042', account_name: 'Hébergement', parent_account: `Revenus Services TI - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4051', account_name: 'Système Informatique', parent_account: `Revenus Services TI - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4052', account_name: 'Revenu - Service Informatique', parent_account: `Revenus Services TI - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4054', account_name: 'Pièces Informatiques', parent_account: `Revenus Services TI - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4001', account_name: 'Location espace cloud', parent_account: `Revenus Services TI - ${ABBR}`, root_type: 'Income', is_group: 0 },
// Divers
{ account_number: '4000', account_name: 'Revenus autres', parent_account: `Revenus Divers - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4010', account_name: 'Honoraires', parent_account: `Revenus Divers - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4016', account_name: 'Saisonniers (coupons-maraîchers)', parent_account: `Revenus Divers - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4106', account_name: 'Déplacement/temps technicien', parent_account: `Revenus Divers - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4250', account_name: 'Intérêts et frais divers', parent_account: `Revenus Divers - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4260', account_name: 'Frais divers taxables', parent_account: `Revenus Divers - ${ABBR}`, root_type: 'Income', is_group: 0 },
{ account_number: '4300', account_name: 'Temporaire', parent_account: `Revenus Divers - ${ABBR}`, root_type: 'Income', is_group: 0 },
]
// ── EXPENSE accounts (7575-7910) ────────────────────────────────────
const EXPENSE_ACCOUNTS = [
{ account_number: '7575', account_name: 'Frais PayPal', parent_account: `Frais divers legacy - ${ABBR}`, root_type: 'Expense', is_group: 0 },
{ account_number: '7900', account_name: 'Mauvaises créances', parent_account: `Frais divers legacy - ${ABBR}`, root_type: 'Expense', is_group: 0 },
{ account_number: '7905', account_name: 'Frais de recouvrement', parent_account: `Frais divers legacy - ${ABBR}`, root_type: 'Expense', is_group: 0 },
{ account_number: '7910', account_name: 'Déficit ou surplus de caisse', parent_account: `Frais divers legacy - ${ABBR}`, root_type: 'Expense', is_group: 0 },
]
async function main() {
console.log('═══════════════════════════════════════════════════')
console.log(' Import Legacy Chart of Accounts into ERPNext')
console.log(` Company: ${COMPANY} (${ABBR})`)
console.log(` ERP: ${ERP_URL}`)
console.log('═══════════════════════════════════════════════════')
// Test connection
const test = await erpFetch('/api/resource/Company/' + encodeURIComponent(COMPANY))
if (test.status !== 200) {
console.error('Cannot connect to ERPNext or company not found. Status:', test.status)
process.exit(1)
}
console.log(`\n✓ Connected to ERPNext — Company: ${test.data?.data?.name}\n`)
let created = 0, existed = 0, errors = 0
// Step 1: Create parent groups
console.log('── Creating parent groups ────────────────────────')
for (const grp of PARENT_GROUPS) {
const r = await createAccount(grp)
if (r.created) created++
else if (r.existed) existed++
else errors++
}
// Step 2: Asset accounts
console.log('\n── Asset accounts (1000-1205) ────────────────────')
for (const acc of ASSET_ACCOUNTS) {
const r = await createAccount(acc)
if (r.created) created++
else if (r.existed) existed++
else errors++
}
// Step 3: Liability accounts
console.log('\n── Liability accounts (2110-2355) ────────────────')
for (const acc of LIABILITY_ACCOUNTS) {
const r = await createAccount(acc)
if (r.created) created++
else if (r.existed) existed++
else errors++
}
// Step 4: Income/Revenue accounts
console.log('\n── Revenue accounts (4000-4300) ──────────────────')
for (const acc of INCOME_ACCOUNTS) {
const r = await createAccount(acc)
if (r.created) created++
else if (r.existed) existed++
else errors++
}
// Step 5: Expense accounts
console.log('\n── Expense accounts (7575-7910) ──────────────────')
for (const acc of EXPENSE_ACCOUNTS) {
const r = await createAccount(acc)
if (r.created) created++
else if (r.existed) existed++
else errors++
}
console.log('\n═══════════════════════════════════════════════════')
console.log(` Results: ${created} created, ${existed} already existed, ${errors} errors`)
console.log('═══════════════════════════════════════════════════')
}
main().catch(e => { console.error('Fatal:', e); process.exit(1) })

View File

@ -3,7 +3,7 @@
## Overview
Migration from legacy PHP/MariaDB billing system (`gestionclient`) to ERPNext v16 on PostgreSQL.
- **Source**: MariaDB at `10.100.80.100`, database `gestionclient`
- **Source**: MariaDB in Docker container `legacy-db` on `96.125.196.67` (port 3307 on host), database `gestionclient`
- **Target**: ERPNext at `erp.gigafibre.ca`, company **TARGO**, currency **CAD**
- **Scope**: All historical data (no date cutoff)
- **Method**: Bulk SQL INSERT (bypasses Frappe ORM for speed — 4 min vs ~120 hours)
@ -22,6 +22,7 @@ The legacy system had several non-standard practices that broke standard account
| 4 | **Invoices both paid and reversed** — customer pays, then invoice also gets a credit note reversal | Invoice shows negative outstanding (overpaid) | Extra reversal entries delinked; invoice marked as settled |
| 5 | **Tax-inclusive totals** — legacy stores total with tax included, no separate net amount | ERPNext needs both net and gross | Tax amounts back-calculated from `invoice_tax` table |
| 6 | **No due dates** — most invoices have no due date | Cannot determine overdue status | `posting_date` used as fallback |
| 7 | **All revenue in one GL account** — initial import put all invoice income into generic `Autres produits d'exploitation - T` | No revenue breakdown by service type; P&L unusable for analysis | Post-import fix maps SKU → `product_cat.num_compte` → ERPNext named account. GL entries updated to dominant account per invoice. Scripts: `fix_income_accounts.py` (row-by-row), `fix_income_sql.py` (bulk via temp table), `fix_gl_entries.py` (GL-only pass) |
---
@ -91,6 +92,110 @@ These prefixes are used in ERPNext document names to identify records created du
---
## Entity Relationships (FK Map)
Every entity references others by `name` (ERPNext primary key). The rename scripts update all these FKs in a single pass.
```
Customer (C-{legacy_account_id})
├── Sales Invoice.customer ─────────────► Customer.name
│ ├── SI Item.parent ─────────────────► Sales Invoice.name
│ ├── SI Tax.parent ──────────────────► Sales Invoice.name
│ ├── GL Entry.voucher_no ────────────► Sales Invoice.name (4 per invoice)
│ ├── PLE.voucher_no ─────────────────► Sales Invoice.name (1 per invoice)
│ ├── PLE.against_voucher_no ─────────► Sales Invoice.name (from payments/credits)
│ ├── Payment Entry Reference.reference_name ► Sales Invoice.name
│ ├── Comment.reference_name ─────────► Sales Invoice.name (invoice notes)
│ └── return_against ─────────────────► Sales Invoice.name (credit note → original)
├── Payment Entry.party ────────────────► Customer.name
│ ├── PE Reference.parent ────────────► Payment Entry.name
│ ├── GL Entry.voucher_no ────────────► Payment Entry.name (2 per payment)
│ ├── PLE.voucher_no ─────────────────► Payment Entry.name (1 per allocation)
│ └── Comment.reference_name ─────────► Payment Entry.name
├── Subscription.party ─────────────────► Customer.name
│ ├── Subscription Plan Detail.parent ► Subscription.name
│ └── Subscription.service_location ──► Service Location.name
├── Service Subscription.customer ──────► Customer.name
│ └── Service Subscription.service_location ► Service Location.name
├── Issue.customer ─────────────────────► Customer.name
│ ├── Communication.reference_name ───► Issue.name
│ ├── Comment.reference_name ─────────► Issue.name (ticket messages)
│ ├── Dispatch Job.issue ─────────────► Issue.name
│ └── Issue.service_location ─────────► Service Location.name
├── Comment.reference_name ─────────────► Customer.name (memos)
├── Service Location ───────────────────► (linked via Subscription/Issue)
│ └── Service Equipment.service_location ► Service Location.name
└── Employee ───────────────────────────► (linked via Dispatch Technician)
└── Dispatch Technician.employee ───► Employee.name
```
### GL Entry ↔ PLE Linking
```
Sales Invoice SINV-{id}
├── GL: gir-SINV-{id} → Receivable (debit grand_total)
├── GL: gii-SINV-{id} → Income* (credit net_total)
├── GL: glt-SINV-{id} → TPS (credit tps)
├── GL: glq-SINV-{id} → TVQ (credit tvq)
└── PLE: ple-SINV-{id} → voucher_no=SINV-{id}, against_voucher_no=SINV-{id}
Payment Entry PE-{id} allocated to SINV-{target}
├── GL: gpb-PE-{id} → Bank (debit paid_amount)
├── GL: gpr-PE-{id} → Receivable (credit paid_amount)
└── PLE: ple-PER-{id}-{idx} → voucher_no=PE-{id}, against_voucher_no=SINV-{target}
Credit Note SINV-{credit} → SINV-{target}
└── PLE: plc-{serial} → voucher_no=SINV-{credit}, against_voucher_no=SINV-{target}
```
*Income account = SKU-mapped account from `product_cat.num_compte` (dominant per invoice)
---
## Naming Series & New ID Allocation
Migrated records use legacy IDs. New records use autoincrement series set **above** the max legacy ID to avoid collisions.
| DocType | Legacy Format | Zero-padded | New Format (post-migration) | Series Counter |
|---------|--------------|-------------|----------------------------|----------------|
| Customer | `C-{legacy_account_id}` | `C-{id:014d}` | `C-10000000034941+` | `C-` → 10000000034940 |
| Sales Invoice | `SINV-{legacy_id}` | `SINV-{id:010d}` | `SINV-0000100001+` | `SINV-` → 100000 |
| Payment Entry | `PE-{legacy_id}` | `PE-{id:010d}` | `PE-0000100001+` | `PE-` → 100000 |
| Issue | `ISS-{legacy_ticket_id}` | `ISS-{id:010d}` | `ISS-0000250001+` | `ISS-` → 250000 |
| Service Location | `LOC-{legacy_delivery_id}` | `LOC-{id:010d}` | `LOC-0000100001+` | `LOC-` → 100000 |
| Service Equipment | `EQP-{legacy_device_id}` | `EQP-{id:010d}` | `EQP-0000100001+` | `EQP-` → 100000 |
| Service Subscription | `SUB-{legacy_service_id}` | `SUB-{id:010d}` | `SUB-0000100001+` | `SUB-` → 100000 |
| Subscription (native) | `ASUB-{legacy_service_id}` | `ASUB-{id:010d}` | `ASUB-0000100001+` | `ASUB-` → 100000 |
| Employee | `HR-EMP-{seq}` | — | `HR-EMP-{N+1}+` | `HR-EMP-` → max legacy |
| SI Item | `SII-{inv_id}-{idx}` | — | (child, no series) | — |
| PE Reference | `PER-{pmt_id}-{idx}` | — | (child, no series) | — |
| GL Entry | `gir-/gii-/glt-/glq-/gpb-/gpr-` | — | (child, no series) | — |
| PLE | `ple-/plc-/plr-` | — | (child, no series) | — |
### Rename Process (rename_all_doctypes.py)
Two-phase rename to avoid PK collisions:
1. **Phase A**: `SINV-638567``_TEMP_SINV-0000638567`
2. **Phase B**: `_TEMP_SINV-0000638567``SINV-0000638567`
3. **FK cascade**: All child tables updated via temp mapping table + `UPDATE ... FROM` JOIN before rename
FK tables updated per doctype:
- **Sales Invoice** → SI Item, SI Tax, GL Entry, PLE, PE Reference, Comment, Version, Dynamic Link
- **Payment Entry** → PE Reference, GL Entry, PLE, Comment, Version, Dynamic Link
- **Issue** → Dispatch Job, Comment, Version, Communication, Dynamic Link
- **Service Location** → Service Subscription, Subscription, Service Equipment, Issue, Dispatch Job
- **Service Equipment** → Comment, Dynamic Link
- **Service/Native Subscription** → Subscription Plan Detail, Comment, Dynamic Link
---
## Entity Mapping
### Customer
@ -135,7 +240,8 @@ unitary_price → rate
quantity × unitary_price → amount
name = SII-{legacy_id}-{idx}
item_code = 'SVC'
income_account = "Autres produits d'exploitation - T"
income_account = mapped from SKU → product_cat.num_compte → ERPNext account
(fallback: "Autres produits d'exploitation - T" if SKU unmapped)
```
### Invoice Taxes
@ -220,9 +326,14 @@ ERPNext:
Debit Credit
───── ──────
Comptes clients - T (AR) grand_total
Autres produits d'expl. (Rev) net_total
Income account* (Revenue) net_total
TPS à payer - T (Tax) tps_amount
TVQ à payer - T (Tax) tvq_amount
* Income account = dominant SKU-mapped account for the invoice
(from product_cat.num_compte). Falls back to "Autres produits d'exploitation - T".
Note: GL uses ONE income account per invoice (the dominant/highest-amount one).
Individual SI Items keep their per-SKU accounts for drill-down reporting.
───────── ─────────
grand_total = grand_total ✓
```
@ -259,7 +370,11 @@ Outstanding = SUM(PLE.amount WHERE against_voucher = this invoice)
TARGO (Company, abbr: T)
├── Comptes clients - T Receivable (debit_to for all invoices)
├── Banque - T Bank (paid_to for all payments)
├── Autres produits d'exploitation - T Income (all invoice revenue)
├── Income accounts (mapped from legacy product_cat.num_compte):
│ ├── Autres produits d'exploitation - T Income (fallback for unmapped SKUs)
│ ├── [4020] Mensualite fibre - T Income (fibre monthly)
│ ├── [4xxx] Other mapped accounts... Income (per product category)
│ └── (run fix_income_sql.py to see full distribution)
├── TPS à payer - T Liability/Tax (5% GST)
└── TVQ à payer - T Liability/Tax (9.975% QST)
```
@ -561,13 +676,59 @@ The migration is split into phases. Each phase can be re-run independently (most
| **9a** | `rename_to_readable_ids.py` | Rename hex IDs → human-readable (CUST-xxx, LOC-addr, EQ-dev) | **DONE** |
| **9b** | `geocode_locations.py` | Geocode Service Locations via rqa_addresses (Quebec address DB) | **DONE** |
| **9c** | `update_item_descriptions.py` | Update Item descriptions from legacy French translations | **DONE** |
| **10a** | `fix_invoice_customer_names.py` | Fix customer_name on 630K invoices + 344K payments (was CUST-xxx, now real names) | **DONE** |
| **10b** | `import_invoice_notes.py` | Import 580,949 legacy invoice.notes as Comments on Sales Invoice | **DONE** |
| **10c** | `import_ticket_msgs.py` | Import legacy ticket_msg as Comments on Issue (784,290 messages) | **DONE** |
| **10d** | `update_assigned_staff.py` | Update assigned_staff on Issues from legacy ticket.assign_to | **DONE** |
| **10e** | `update_opened_by_staff.py` | Update opened_by_staff on Issues from legacy ticket.open_by | **DONE** |
| **11a** | `fix_income_accounts.py` | Fix income_account on SI Items: SKU → product_cat.num_compte → ERPNext account (row-by-row ORM) | **DONE** |
| **11b** | `fix_income_sql.py` | Same fix as 11a but via bulk SQL temp table JOIN (faster) | **DONE** |
| **11c** | `fix_gl_entries.py` | Update GL entries to match updated SI Item income accounts (dominant account per invoice) | **DONE** |
| **12a** | `create_portal_users.py` | Create ~11,800 Website Users with legacy MD5 password hashes | **DONE** |
| **12b** | `setup_portal_auth_bridge.py` | Server Script: MD5 → pbkdf2 conversion on first login | **DONE** |
| **12c** | `setup_invoice_print_format.py` | Custom Print Format for Sales Invoice (Gigafibre branding) | **DONE** |
| **12d** | `setup_subscription_api.py` | Fix Subscription DocField restrictions for REST API | **DONE** |
| **12e** | `add_missing_custom_fields.py` | Add ~73 custom fields to ERPNext doctypes | **DONE** |
| **12f** | `migrate_provisioning_data.py` | Migrate WiFi + VoIP provisioning data into Service Equipment | **DONE** |
| **12g** | `fix_olt_port_ip.py` | Replace OLT port data with actual OLT IP from legacy fibre table | **DONE** |
### Analysis/Exploration Scripts (read-only)
### Earlier Migration Scripts (superseded by clean_reimport.py)
These were used during development. `clean_reimport.py` now handles all accounting in one pass.
| Script | Purpose |
|--------|---------|
| `migrate_all.py` | Original phase orchestrator (7 phases). Superseded by clean_reimport.py for accounting |
| `migrate_direct.py` | Direct PG insert for Customers — now embedded in migrate_all.py |
| `migrate_phase3.py` | Subscription Plans + Subscriptions — still used standalone |
| `migrate_phase5.py` | Opening Balance via Journal Entry — superseded by PLE approach |
| `migrate_missing_data.py` | Populate custom fields from legacy — superseded by targeted fix scripts |
| `migrate_users.py` | Create ERPNext Users from legacy staff + Authentik SSO |
| `import_invoices.py` | Import invoices via Frappe ORM — superseded by clean_reimport.py (bulk SQL) |
| `import_payments.py` | Import payments via Frappe ORM — superseded by clean_reimport.py (bulk SQL) |
| `reimport_subscriptions.py` | Clean reimport of native Subscriptions from Service Subscription |
| `reconcile_subscriptions.py` | Reconcile Service Subscription vs Subscription for discrepancies |
### Rename Scripts (run once, in order)
| Script | Purpose |
|--------|---------|
| `rename_all_doctypes.py` | Rename all doctype IDs to zero-padded 10-digit numeric format |
| `rename_customers.py` | Rename Customer IDs to CUST-{legacy_id} |
| `rename_customers_c_prefix.py` | Prepend C- to customer names (visual distinction) |
### Analysis/Debug Scripts (read-only, not part of migration)
| Script | Purpose |
|--------|---------|
| `analyze_pricing_cleanup.py` | Pricing analysis: catalog vs hijack, rebate absorption |
| `check_items.py` | Check item_code/item_name values in Sales Invoice Items |
| `check_naming.py` | Verify invoice naming and legacy_invoice_id mapping |
| `check_gl_dates.py` | Check GL entry date distribution by income account |
| `check_missing_cat26.py` | Identify missing services in non-imported categories |
| `debug_match.py` | Debug credit/reversal matching logic for income accounts |
| `debug_match2.py` | Debug specific unupdated invoices and test match logic |
| `debug_idx.py` | Debug idx matching between legacy and ERPNext rows |
| `explore_expro_payments.py` | Compare legacy vs ERPNext payments for Expro Transit |
| `explore_expro_services.py` | Show active services for Expro Transit with pricing |
| `simulate_payment_import.py` | DRY RUN for Expro payments: timeline of invoice vs balance |
@ -642,8 +803,10 @@ The migration is split into phases. Each phase can be re-run independently (most
| Task | Priority | Notes |
|------|----------|-------|
| Migrate legacy password hashes (`account.password`) | P1 | Needed for customer portal auth bridge (see project_portal_auth.md) |
| ~~Migrate legacy password hashes~~ | ~~P1~~ | **DONE**`create_portal_users.py` + `setup_portal_auth_bridge.py` |
| ~~Customer portal users~~ | ~~P1~~ | **DONE** — ~11,800 Website Users created with MD5 bridge |
| ~~Income account mapping~~ | ~~P1~~ | **DONE**`fix_income_sql.py` + `fix_gl_entries.py` (2026-04-09) |
| Investigate 15 overpaid invoices ($3,695.77) | P2 | Unmatched credit notes |
| Customer portal users (Website User creation) | P1 | 15,305 accounts with email — send password reset |
| QR code on invoice PDFs | P2 | Stripe payment link for customer portal |
| Scheduler reactivation | P0 | **PAUSED** — need Louis-Paul approval before enabling |
| Password reset campaign | P3 | Email/SMS to portal users who haven't logged in |

View File

@ -73,7 +73,7 @@ print("PHASE 2: POPULATE FROM LEGACY")
print("="*60)
conn = pymysql.connect(
host="10.100.80.100",
host="legacy-db",
user="facturation",
password="*******",
database="gestionclient",

View File

@ -23,7 +23,7 @@ frappe.connect()
print("Connected:", frappe.local.site)
conn = pymysql.connect(
host="10.100.80.100",
host="legacy-db",
user="facturation",
password="*******",
database="gestionclient",

View File

@ -0,0 +1,28 @@
"""Check GL entry date distribution for income accounts."""
import frappe, os, sys
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
r = frappe.db.sql("""
SELECT EXTRACT(YEAR FROM posting_date)::int as y,
EXTRACT(MONTH FROM posting_date)::int as m,
COUNT(*)
FROM "tabGL Entry"
WHERE account LIKE %s AND credit > 0
GROUP BY y, m
ORDER BY y, m
""", ('4020%',))
print("GL entries for 4020 (Mensualite fibre) by month:")
for row in r:
print(f" {int(row[0])}-{int(row[1]):02d}: {row[2]} entries")
# Total GL entry date range
r2 = frappe.db.sql("""
SELECT MIN(posting_date), MAX(posting_date), COUNT(*)
FROM "tabGL Entry" WHERE credit > 0 AND account NOT LIKE %s AND account NOT LIKE %s
""", ('Comptes%', '%payer%'))
print(f"\nAll income GL: min={r2[0][0]} max={r2[0][1]} count={r2[0][2]}")
frappe.destroy()

View File

@ -0,0 +1,25 @@
"""Check what item_code/item_name values look like in tabSales Invoice Item."""
import frappe, os, sys
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
r = frappe.db.sql('SELECT item_code, COUNT(*) as cnt FROM "tabSales Invoice Item" GROUP BY item_code ORDER BY cnt DESC LIMIT 20')
print("item_code distribution:")
for row in r:
print(f" {row[1]:>10} {repr(row[0])}")
r2 = frappe.db.sql('SELECT item_name, COUNT(*) as cnt FROM "tabSales Invoice Item" GROUP BY item_name ORDER BY cnt DESC LIMIT 15')
print("\nitem_name distribution:")
for row in r2:
print(f" {row[1]:>10} {repr(row[0][:80] if row[0] else None)}")
# Check a sample invoice with items
r3 = frappe.db.sql("""SELECT sii.item_code, sii.item_name, sii.description, sii.income_account
FROM "tabSales Invoice Item" sii LIMIT 5""")
print("\nSample items:")
for row in r3:
print(f" code={repr(row[0][:40] if row[0] else None)} name={repr(row[1][:40] if row[1] else None)} acct={row[3]}")
frappe.destroy()

View File

@ -1,5 +1,5 @@
import pymysql
conn = pymysql.connect(host='10.100.80.100', user='facturation', password='VD67owoj',
conn = pymysql.connect(host='legacy-db', user='facturation', password='VD67owoj',
database='gestionclient', cursorclass=pymysql.cursors.DictCursor)
with conn.cursor() as cur:
cur.execute("""

View File

@ -0,0 +1,32 @@
"""Check invoice naming and legacy_invoice_id mapping."""
import frappe, os, sys
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
# Check invoice names
r = frappe.db.sql('SELECT name, legacy_invoice_id FROM "tabSales Invoice" WHERE legacy_invoice_id > 0 LIMIT 10')
print("Invoice names with legacy_invoice_id:")
for row in r:
print(f" name={row[0]} legacy_id={row[1]}")
# Check item parent vs invoice name
r2 = frappe.db.sql("""
SELECT sii.parent, sii.idx, sii.item_name, si.legacy_invoice_id
FROM "tabSales Invoice Item" sii
JOIN "tabSales Invoice" si ON si.name = sii.parent
WHERE si.legacy_invoice_id > 0
LIMIT 10
""")
print("\nItem parent → legacy_invoice_id:")
for row in r2:
print(f" parent={row[0]} idx={row[1]} legacy_id={row[3]} name={row[2][:50]}")
# Count
r3 = frappe.db.sql('SELECT COUNT(*) FROM "tabSales Invoice" WHERE legacy_invoice_id > 0')
print(f"\nInvoices with legacy_invoice_id: {r3[0][0]}")
r4 = frappe.db.sql('SELECT COUNT(*) FROM "tabSales Invoice" WHERE legacy_invoice_id IS NULL OR legacy_invoice_id = 0')
print(f"Invoices without legacy_invoice_id: {r4[0][0]}")
frappe.destroy()

View File

@ -64,7 +64,7 @@ print("="*60)
t0 = time.time()
legacy = pymysql.connect(
host="10.100.80.100", user="facturation", password="*******",
host="legacy-db", user="facturation", password="*******",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
@ -82,20 +82,53 @@ with legacy.cursor() as cur:
invoices = cur.fetchall()
print(" Loaded {} invoices".format(len(invoices)))
# 1b: Load invoice items
print("\n--- 1b: Invoice items ---")
# 1a2: Load SKU → GL account mapping from product → product_cat
print("\n--- 1a2: SKU → GL account mapping ---")
with legacy.cursor() as cur:
cur.execute("""
SELECT p.sku, pc.num_compte
FROM product p JOIN product_cat pc ON p.category = pc.id
WHERE p.sku IS NOT NULL AND pc.num_compte IS NOT NULL
""")
sku_to_gl_num = {}
for r in cur.fetchall():
if r['num_compte']:
sku_to_gl_num[r['sku']] = str(int(r['num_compte']))
print(" Loaded {} SKU→GL number mappings".format(len(sku_to_gl_num)))
# Build GL account number → ERPNext account name
gl_by_number = {}
acct_rows = frappe.db.sql("""
SELECT account_number, name FROM "tabAccount"
WHERE root_type IN ('Income', 'Expense') AND company = 'TARGO' AND is_group = 0
AND account_number IS NOT NULL AND account_number != ''
""", as_dict=True)
for a in acct_rows:
gl_by_number[a['account_number']] = a['name']
print(" {} numbered GL accounts in ERPNext".format(len(gl_by_number)))
# Combined: SKU → ERPNext income account name
DEFAULT_INCOME = "Autres produits d'exploitation - T"
sku_to_income = {}
for sku, num in sku_to_gl_num.items():
if num in gl_by_number:
sku_to_income[sku] = gl_by_number[num]
print(" {} SKUs mapped to named GL accounts".format(len(sku_to_income)))
# 1b: Load invoice items (with SKU for GL mapping)
print("\n--- 1b: Invoice items (with SKU) ---")
with legacy.cursor() as cur:
invoice_ids = [str(i['id']) for i in invoices]
# Process in chunks to avoid query too large
all_items = {}
chunk = 10000
for idx in range(0, len(invoice_ids), chunk):
batch = invoice_ids[idx:idx+chunk]
cur.execute("""
SELECT ii.invoice_id, ii.product_name, ii.quantity, ii.unitary_price,
(ii.quantity * ii.unitary_price) as total
(ii.quantity * ii.unitary_price) as total, ii.sku
FROM invoice_item ii
WHERE ii.invoice_id IN ({})
ORDER BY ii.id
""".format(",".join(batch)))
for it in cur.fetchall():
iid = it['invoice_id']
@ -360,6 +393,10 @@ for idx in range(0, len(invoices), CHUNK):
item_rate = float(it.get('unitary_price', 0) or 0)
desc = str(it.get('product_name', 'Services') or 'Services').replace("'", "''")[:140]
# Map to correct GL account via SKU → product_cat → num_compte
item_sku = it.get('sku') or ''
income_account = sku_to_income.get(item_sku, DEFAULT_INCOME).replace("'", "''")
si_item_values.append(
"('{name}', 'Administrator', NOW(), NOW(), 'Administrator', "
"'{parent}', 'Sales Invoice', 'items', {idx}, "
@ -376,7 +413,7 @@ for idx in range(0, len(invoices), CHUNK):
qty=item_qty,
rate=item_rate,
amount=item_total,
income_account="Autres produits d''exploitation - T",
income_account=income_account,
)
)
@ -385,10 +422,10 @@ for idx in range(0, len(invoices), CHUNK):
si_tax_values.append(
"('{name}', 'Administrator', NOW(), NOW(), 'Administrator', "
"'{parent}', 'Sales Invoice', 'taxes', 1, "
"'On Net Total', 'Comptes clients - T', "
"'TPS à payer - T', 5.0, "
"'On Net Total', '2300 - TPS perçue - T', "
"'TPS 5% (#834975559RT0001)', 5.0, "
"{tps}, {tps}, {running1}, "
"'834975559RT0001', 0)".format(
"'', 0)".format(
name="stc-tps-{}".format(inv['id']),
parent=sinv_name,
tps=tps,
@ -398,10 +435,10 @@ for idx in range(0, len(invoices), CHUNK):
si_tax_values.append(
"('{name}', 'Administrator', NOW(), NOW(), 'Administrator', "
"'{parent}', 'Sales Invoice', 'taxes', 2, "
"'On Net Total', 'Comptes clients - T', "
"'TVQ à payer - T', 9.975, "
"'On Net Total', '2350 - TVQ perçue - T', "
"'TVQ 9.975% (#1213765929TQ0001)', 9.975, "
"{tvq}, {tvq}, {running2}, "
"'1213765929TQ0001', 0)".format(
"'', 0)".format(
name="stc-tvq-{}".format(inv['id']),
parent=sinv_name,
tvq=tvq,
@ -728,7 +765,7 @@ frappe.db.commit()
gl_recv = frappe.db.sql("SELECT COUNT(*) FROM \"tabGL Entry\" WHERE name LIKE 'gir-%%'")[0][0]
print(" Receivable GL entries: {}".format(gl_recv))
# Income side
# Income side — one GL entry per income_account per invoice (items may use different GL accounts)
frappe.db.sql("""
INSERT INTO "tabGL Entry" (
name, owner, creation, modified, modified_by, docstatus,
@ -739,20 +776,23 @@ frappe.db.sql("""
is_opening, is_cancelled
)
SELECT
'gii-' || si.name, 'Administrator', NOW(), NOW(), 'Administrator', 1,
'gii-' || sii.parent || '-' || ROW_NUMBER() OVER (PARTITION BY sii.parent ORDER BY sii.income_account),
'Administrator', NOW(), NOW(), 'Administrator', 1,
si.posting_date, 'TARGO',
CASE
WHEN EXTRACT(MONTH FROM si.posting_date) >= 7
THEN EXTRACT(YEAR FROM si.posting_date)::text || '-' || (EXTRACT(YEAR FROM si.posting_date) + 1)::text
ELSE (EXTRACT(YEAR FROM si.posting_date) - 1)::text || '-' || EXTRACT(YEAR FROM si.posting_date)::text
END,
'Autres produits d''exploitation - T', 'CAD',
GREATEST(-si.net_total, 0), GREATEST(-si.net_total, 0),
GREATEST(si.net_total, 0), GREATEST(si.net_total, 0),
sii.income_account, 'CAD',
GREATEST(-SUM(sii.net_amount), 0), GREATEST(-SUM(sii.net_amount), 0),
GREATEST(SUM(sii.net_amount), 0), GREATEST(SUM(sii.net_amount), 0),
'Sales Invoice', si.name, 'Comptes clients - T',
'No', 0
FROM "tabSales Invoice" si
FROM "tabSales Invoice Item" sii
JOIN "tabSales Invoice" si ON si.name = sii.parent
WHERE si.docstatus = 1
GROUP BY sii.parent, sii.income_account, si.posting_date
""")
frappe.db.commit()
gl_inc = frappe.db.sql("SELECT COUNT(*) FROM \"tabGL Entry\" WHERE name LIKE 'gii-%%'")[0][0]
@ -776,14 +816,14 @@ frappe.db.sql("""
THEN EXTRACT(YEAR FROM si.posting_date)::text || '-' || (EXTRACT(YEAR FROM si.posting_date) + 1)::text
ELSE (EXTRACT(YEAR FROM si.posting_date) - 1)::text || '-' || EXTRACT(YEAR FROM si.posting_date)::text
END,
'TPS à payer - T', 'CAD',
'2300 - TPS perçue - T', 'CAD',
GREATEST(-stc.tax_amount, 0), GREATEST(-stc.tax_amount, 0),
GREATEST(stc.tax_amount, 0), GREATEST(stc.tax_amount, 0),
'Sales Invoice', si.name, 'Comptes clients - T',
'No', 0
FROM "tabSales Taxes and Charges" stc
JOIN "tabSales Invoice" si ON si.name = stc.parent
WHERE si.docstatus = 1 AND stc.description = 'TPS à payer - T'
WHERE si.docstatus = 1 AND stc.account_head = '2300 - TPS perçue - T'
""")
frappe.db.commit()
gl_tps = frappe.db.sql("SELECT COUNT(*) FROM \"tabGL Entry\" WHERE name LIKE 'glt-%%'")[0][0]
@ -807,14 +847,14 @@ frappe.db.sql("""
THEN EXTRACT(YEAR FROM si.posting_date)::text || '-' || (EXTRACT(YEAR FROM si.posting_date) + 1)::text
ELSE (EXTRACT(YEAR FROM si.posting_date) - 1)::text || '-' || EXTRACT(YEAR FROM si.posting_date)::text
END,
'TVQ à payer - T', 'CAD',
'2350 - TVQ perçue - T', 'CAD',
GREATEST(-stc.tax_amount, 0), GREATEST(-stc.tax_amount, 0),
GREATEST(stc.tax_amount, 0), GREATEST(stc.tax_amount, 0),
'Sales Invoice', si.name, 'Comptes clients - T',
'No', 0
FROM "tabSales Taxes and Charges" stc
JOIN "tabSales Invoice" si ON si.name = stc.parent
WHERE si.docstatus = 1 AND stc.description = 'TVQ à payer - T'
WHERE si.docstatus = 1 AND stc.account_head = '2350 - TVQ perçue - T'
""")
frappe.db.commit()
gl_tvq = frappe.db.sql("SELECT COUNT(*) FROM \"tabGL Entry\" WHERE name LIKE 'glq-%%'")[0][0]

View File

@ -0,0 +1,218 @@
"""
Create custom DocTypes for remaining customer data:
- Payment Method (Stripe, Paysafe/Bambora, Bank PPA)
- Payment Arrangement (accord de paiement)
- VoIP Line (PBX + 911 + ATA provisioning)
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/create_custom_doctypes.py
"""
import frappe
import os, sys
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
frappe.local.flags.ignore_permissions = True
print("Connected:", frappe.local.site)
def create_doctype(name, module, fields, **kwargs):
if frappe.db.exists("DocType", name):
print(f" DocType '{name}' already exists — skipping")
return
doc = frappe.get_doc({
"doctype": "DocType",
"name": name,
"module": module,
"custom": 1,
"is_submittable": kwargs.get("is_submittable", 0),
"autoname": kwargs.get("autoname", "autoincrement"),
"naming_rule": kwargs.get("naming_rule", "Autoincrement"),
"fields": fields,
"permissions": [
{"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1},
{"role": "Accounts User", "read": 1, "write": 1, "create": 1},
],
**{k: v for k, v in kwargs.items() if k not in ("is_submittable", "autoname", "naming_rule")},
})
doc.insert(ignore_if_duplicate=True)
frappe.db.commit()
print(f" Created DocType '{name}'")
# ═══════════════════════════════════════════════════════════
# 1. Payment Method
# ═══════════════════════════════════════════════════════════
print("\n=== Creating Payment Method ===")
create_doctype("Payment Method", "Custom", [
{"fieldname": "customer", "fieldtype": "Link", "label": "Client", "options": "Customer",
"reqd": 1, "in_list_view": 1, "in_standard_filter": 1},
{"fieldname": "customer_name", "fieldtype": "Data", "label": "Nom client",
"fetch_from": "customer.customer_name", "read_only": 1},
{"fieldname": "cb1", "fieldtype": "Column Break"},
{"fieldname": "provider", "fieldtype": "Select", "label": "Fournisseur",
"options": "\nStripe\nPaysafe\nBank Draft", "reqd": 1, "in_list_view": 1, "in_standard_filter": 1},
{"fieldname": "is_active", "fieldtype": "Check", "label": "Actif", "default": "1", "in_list_view": 1},
{"fieldname": "is_auto_ppa", "fieldtype": "Check", "label": "PPA automatique"},
{"fieldname": "sb_stripe", "fieldtype": "Section Break", "label": "Stripe",
"depends_on": "eval:doc.provider=='Stripe'"},
{"fieldname": "stripe_customer_id", "fieldtype": "Data", "label": "Stripe Customer ID"},
{"fieldname": "stripe_ppa_enabled", "fieldtype": "Check", "label": "Stripe PPA actif"},
{"fieldname": "cb_stripe", "fieldtype": "Column Break"},
{"fieldname": "stripe_ppa_nocc", "fieldtype": "Check", "label": "Stripe PPA sans CC"},
{"fieldname": "sb_paysafe", "fieldtype": "Section Break", "label": "Paysafe / Bambora",
"depends_on": "eval:doc.provider=='Paysafe'"},
{"fieldname": "paysafe_profile_id", "fieldtype": "Data", "label": "Profile ID"},
{"fieldname": "paysafe_card_id", "fieldtype": "Data", "label": "Card ID"},
{"fieldname": "cb_paysafe", "fieldtype": "Column Break"},
{"fieldname": "paysafe_token", "fieldtype": "Data", "label": "Token"},
{"fieldname": "paysafe_initial_txn", "fieldtype": "Data", "label": "Transaction initiale"},
{"fieldname": "sb_bank", "fieldtype": "Section Break", "label": "PPA Bancaire",
"depends_on": "eval:doc.provider=='Bank Draft'"},
{"fieldname": "ppa_name", "fieldtype": "Data", "label": "Nom titulaire"},
{"fieldname": "ppa_institution", "fieldtype": "Data", "label": "Code institution"},
{"fieldname": "ppa_branch", "fieldtype": "Data", "label": "Code succursale"},
{"fieldname": "cb_bank", "fieldtype": "Column Break"},
{"fieldname": "ppa_account", "fieldtype": "Data", "label": "Numéro de compte"},
{"fieldname": "ppa_amount", "fieldtype": "Currency", "label": "Montant PPA"},
{"fieldname": "ppa_buffer", "fieldtype": "Currency", "label": "Buffer PPA"},
{"fieldname": "sb_legacy", "fieldtype": "Section Break", "label": "Legacy", "collapsible": 1},
{"fieldname": "legacy_account_id", "fieldtype": "Int", "label": "Legacy Account ID"},
],
autoname="format:PM-{####}",
naming_rule="Expression (Jinja)",
)
# ═══════════════════════════════════════════════════════════
# 2. Payment Arrangement
# ═══════════════════════════════════════════════════════════
print("\n=== Creating Payment Arrangement ===")
create_doctype("Payment Arrangement", "Custom", [
{"fieldname": "customer", "fieldtype": "Link", "label": "Client", "options": "Customer",
"reqd": 1, "in_list_view": 1, "in_standard_filter": 1},
{"fieldname": "customer_name", "fieldtype": "Data", "label": "Nom client",
"fetch_from": "customer.customer_name", "read_only": 1},
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
"options": "Pending\nOpen\nCompleted", "default": "Open", "in_list_view": 1, "in_standard_filter": 1},
{"fieldname": "cb1", "fieldtype": "Column Break"},
{"fieldname": "amount", "fieldtype": "Currency", "label": "Montant convenu",
"reqd": 1, "in_list_view": 1},
{"fieldname": "method", "fieldtype": "Select", "label": "Mode de paiement",
"options": "\nPortail\nCheque\nTelephone\nPPA\nAutre"},
{"fieldname": "sb_dates", "fieldtype": "Section Break", "label": "Dates"},
{"fieldname": "date_agreed", "fieldtype": "Date", "label": "Date accord", "in_list_view": 1},
{"fieldname": "date_due", "fieldtype": "Date", "label": "Date echeance", "in_list_view": 1},
{"fieldname": "cb_dates", "fieldtype": "Column Break"},
{"fieldname": "date_cutoff", "fieldtype": "Date", "label": "Date coupure"},
{"fieldname": "sb_notes", "fieldtype": "Section Break", "label": "Notes"},
{"fieldname": "staff", "fieldtype": "Data", "label": "Employe"},
{"fieldname": "note", "fieldtype": "Small Text", "label": "Note"},
{"fieldname": "reason", "fieldtype": "Small Text", "label": "Raison du changement"},
{"fieldname": "sb_legacy", "fieldtype": "Section Break", "label": "Legacy", "collapsible": 1},
{"fieldname": "legacy_accord_id", "fieldtype": "Int", "label": "Legacy Accord ID"},
{"fieldname": "legacy_account_id", "fieldtype": "Int", "label": "Legacy Account ID"},
],
autoname="format:PA-{####}",
naming_rule="Expression (Jinja)",
)
# ═══════════════════════════════════════════════════════════
# 3. VoIP Line
# ═══════════════════════════════════════════════════════════
print("\n=== Creating VoIP Line ===")
create_doctype("VoIP Line", "Custom", [
{"fieldname": "did", "fieldtype": "Data", "label": "DID (numero)", "reqd": 1,
"unique": 1, "in_list_view": 1, "in_standard_filter": 1},
{"fieldname": "customer", "fieldtype": "Link", "label": "Client", "options": "Customer",
"reqd": 1, "in_list_view": 1, "in_standard_filter": 1},
{"fieldname": "customer_name", "fieldtype": "Data", "label": "Nom client",
"fetch_from": "customer.customer_name", "read_only": 1},
{"fieldname": "cb1", "fieldtype": "Column Break"},
{"fieldname": "service_location", "fieldtype": "Link", "label": "Lieu de service",
"options": "Service Location", "in_standard_filter": 1},
{"fieldname": "subscription", "fieldtype": "Link", "label": "Abonnement",
"options": "Service Subscription"},
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
"options": "Active\nInactive\nSuspended", "default": "Active", "in_list_view": 1},
{"fieldname": "sb_sip", "fieldtype": "Section Break", "label": "Configuration SIP"},
{"fieldname": "caller_id", "fieldtype": "Data", "label": "Caller ID"},
{"fieldname": "sip_password", "fieldtype": "Password", "label": "Mot de passe SIP"},
{"fieldname": "extension", "fieldtype": "Data", "label": "Extension"},
{"fieldname": "sip_context", "fieldtype": "Data", "label": "Contexte SIP"},
{"fieldname": "cb_sip", "fieldtype": "Column Break"},
{"fieldname": "max_calls", "fieldtype": "Int", "label": "Appels simultanes max", "default": "2"},
{"fieldname": "call_timeout", "fieldtype": "Int", "label": "Timeout (sec)", "default": "30"},
{"fieldname": "language", "fieldtype": "Select", "label": "Langue", "options": "fr\nen", "default": "fr"},
{"fieldname": "country_whitelist", "fieldtype": "Small Text", "label": "Pays autorises"},
{"fieldname": "sb_vm", "fieldtype": "Section Break", "label": "Messagerie vocale"},
{"fieldname": "voicemail_enabled", "fieldtype": "Check", "label": "Messagerie activee"},
{"fieldname": "vm_password", "fieldtype": "Data", "label": "Mot de passe messagerie"},
{"fieldname": "vm_email", "fieldtype": "Data", "label": "Email notification messagerie"},
{"fieldname": "cb_vm", "fieldtype": "Column Break"},
{"fieldname": "vm_keep_msg", "fieldtype": "Check", "label": "Conserver messages"},
{"fieldname": "vm_disk_quota", "fieldtype": "Int", "label": "Quota disque (Mo)", "default": "180"},
{"fieldname": "sb_911", "fieldtype": "Section Break", "label": "Adresse 911"},
{"fieldname": "e911_first_name", "fieldtype": "Data", "label": "Prenom 911"},
{"fieldname": "e911_last_name", "fieldtype": "Data", "label": "Nom 911"},
{"fieldname": "e911_street_number", "fieldtype": "Data", "label": "Numero civique"},
{"fieldname": "e911_apt", "fieldtype": "Data", "label": "Appartement"},
{"fieldname": "e911_street_name", "fieldtype": "Data", "label": "Rue"},
{"fieldname": "cb_911", "fieldtype": "Column Break"},
{"fieldname": "e911_city", "fieldtype": "Data", "label": "Ville"},
{"fieldname": "e911_state", "fieldtype": "Data", "label": "Province"},
{"fieldname": "e911_zip", "fieldtype": "Data", "label": "Code postal"},
{"fieldname": "e911_cauca_code", "fieldtype": "Data", "label": "Code CAUCA"},
{"fieldname": "e911_class", "fieldtype": "Select", "label": "Classe service",
"options": "RES\nBUS\nCOM", "default": "RES"},
{"fieldname": "e911_enhanced", "fieldtype": "Check", "label": "Enhanced 911", "default": "1"},
{"fieldname": "e911_synced", "fieldtype": "Check", "label": "Synchronise avec fournisseur 911"},
{"fieldname": "e911_info", "fieldtype": "Data", "label": "Info supplementaire 911"},
{"fieldname": "sb_ata", "fieldtype": "Section Break", "label": "Equipement ATA"},
{"fieldname": "ata_model", "fieldtype": "Data", "label": "Modele ATA"},
{"fieldname": "ata_mac", "fieldtype": "Data", "label": "MAC ATA"},
{"fieldname": "cb_ata", "fieldtype": "Column Break"},
{"fieldname": "ata_password", "fieldtype": "Data", "label": "Mot de passe ATA"},
{"fieldname": "international_enabled", "fieldtype": "Check", "label": "Appels internationaux"},
{"fieldname": "sb_legacy", "fieldtype": "Section Break", "label": "Legacy", "collapsible": 1},
{"fieldname": "legacy_pbx_id", "fieldtype": "Int", "label": "Legacy PBX ID"},
{"fieldname": "legacy_account_id", "fieldtype": "Int", "label": "Legacy Account ID"},
{"fieldname": "legacy_delivery_id", "fieldtype": "Int", "label": "Legacy Delivery ID"},
{"fieldname": "legacy_service_id", "fieldtype": "Int", "label": "Legacy Service ID"},
],
autoname="field:did",
naming_rule="By fieldname",
)
# ═══════════════════════════════════════════════════════════
# 4. Add is_suspended to Customer if not exists
# ═══════════════════════════════════════════════════════════
print("\n=== Adding Customer custom fields ===")
for field_def in [
{"dt": "Customer", "fieldname": "is_suspended", "fieldtype": "Check",
"label": "Suspendu", "insert_after": "is_bad_payer"},
{"dt": "Customer", "fieldname": "suspension_note", "fieldtype": "Data",
"label": "Note suspension", "insert_after": "is_suspended",
"depends_on": "eval:doc.is_suspended"},
]:
if not frappe.db.exists("Custom Field", {"dt": field_def["dt"], "fieldname": field_def["fieldname"]}):
cf = frappe.get_doc({"doctype": "Custom Field", **field_def})
cf.insert(ignore_permissions=True)
frappe.db.commit()
print(f" Added {field_def['dt']}.{field_def['fieldname']}")
else:
print(f" {field_def['dt']}.{field_def['fieldname']} already exists")
frappe.destroy()
print("\nAll doctypes created!")

View File

@ -55,7 +55,7 @@ print("Step 2: Fetch legacy accounts")
print("=" * 60)
legacy = pymysql.connect(
host="10.100.80.100", user="facturation", password="VD67owoj",
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)

View File

@ -0,0 +1,52 @@
"""Debug idx matching between legacy and ERPNext."""
import frappe, pymysql, os, sys
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
# Get an invoice that still has default income
r = frappe.db.sql("""
SELECT si.name, si.legacy_invoice_id
FROM "tabSales Invoice" si
JOIN "tabSales Invoice Item" sii ON sii.parent = si.name
WHERE sii.income_account = %s AND si.legacy_invoice_id > 0
LIMIT 1
""", ("Autres produits d'exploitation - T",))
if not r:
print("No invoices found with default account!")
frappe.destroy()
exit()
inv_name, legacy_id = r[0]
print(f"Invoice: {inv_name}, legacy_id: {legacy_id}")
# ERPNext items
items = frappe.db.sql("""
SELECT idx, item_name, income_account FROM "tabSales Invoice Item"
WHERE parent = %s ORDER BY idx
""", (inv_name,))
print(f"\nERPNext items (idx):")
for row in items:
print(f" idx={row[0]} {row[2]} {row[1][:60]}")
# Legacy items
legacy = pymysql.connect(
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
with legacy.cursor() as cur:
cur.execute("""
SELECT id, sku, product_name,
ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY id) as rn
FROM invoice_item WHERE invoice_id = %s ORDER BY id
""", (int(legacy_id),))
litems = cur.fetchall()
legacy.close()
print(f"\nLegacy items (ROW_NUMBER):")
for li in litems:
print(f" rn={li['rn']} sku={li['sku']} {li['product_name'][:60]}")
frappe.destroy()

View File

@ -0,0 +1,75 @@
"""Debug the exact matching logic."""
import frappe, pymysql, os, sys
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
DEFAULT_INCOME = "Autres produits d'exploitation - T"
# Build the same mapping as fix script
legacy = pymysql.connect(
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
with legacy.cursor() as cur:
cur.execute("""SELECT p.sku, pc.num_compte FROM product p JOIN product_cat pc ON p.category = pc.id
WHERE p.sku IS NOT NULL AND p.sku != '' AND pc.num_compte IS NOT NULL""")
sku_to_gl_num = {r['sku']: str(int(r['num_compte'])) for r in cur.fetchall() if r['num_compte']}
accts = frappe.db.sql("""SELECT account_number, name FROM "tabAccount"
WHERE root_type IN ('Income','Expense') AND company='TARGO' AND is_group=0
AND account_number IS NOT NULL AND account_number != ''""")
gl_by_number = {r[0]: r[1] for r in accts}
sku_to_income = {sku: gl_by_number[num] for sku, num in sku_to_gl_num.items() if num in gl_by_number}
# Test with legacy_id 609669
legacy_id = 609669
with legacy.cursor() as cur:
cur.execute("""SELECT invoice_id, sku,
ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY id) as rn
FROM invoice_item WHERE invoice_id = %s ORDER BY id""", (legacy_id,))
items = cur.fetchall()
legacy.close()
inv_item_map = {}
for li in items:
sku = li['sku'] or ''
acct = sku_to_income.get(sku)
if acct:
idx = li['rn']
inv_item_map[idx] = acct
print(f" rn={idx} sku={sku}{acct}")
else:
print(f" rn={li['rn']} sku={li['sku']} → NOT MAPPED")
print(f"\nMapping for 609669: {inv_item_map}")
# Now simulate the fix script logic
inv_rows = frappe.db.sql("""
SELECT name, legacy_invoice_id FROM "tabSales Invoice"
WHERE legacy_invoice_id = %s
""", (legacy_id,))
print(f"\nERPNext invoice: {inv_rows}")
inv_names = [r[0] for r in inv_rows]
inv_id_map = {r[0]: int(r[1]) for r in inv_rows}
print(f"inv_id_map: {inv_id_map}")
# The fix script uses inv_item_map[legacy_id][idx]
# But we built it as inv_item_map[idx] for this test
# In the actual script, it's inv_item_map = { legacy_invoice_id: { idx: acct } }
# Let's check types
r = frappe.db.sql('SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE name = %s', (inv_names[0],))
print(f"\nlegacy_invoice_id raw value: {repr(r[0][0])}, type: {type(r[0][0])}")
# Check if the bug is type mismatch
full_map = {legacy_id: inv_item_map}
lid = inv_id_map[inv_names[0]]
print(f"Looking up lid={repr(lid)} type={type(lid)}")
print(f"Keys in full_map: {list(full_map.keys())}, types: {[type(k) for k in full_map.keys()]}")
print(f"lid in full_map: {lid in full_map}")
frappe.destroy()

View File

@ -0,0 +1,83 @@
"""Debug: check a specific unupdated invoice and test the exact match logic."""
import frappe, pymysql, os, sys
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
DEFAULT_INCOME = "Autres produits d'exploitation - T"
# Find an invoice that STILL has default income (was never updated)
r = frappe.db.sql("""
SELECT si.name, si.legacy_invoice_id, sii.idx, sii.item_name, sii.income_account
FROM "tabSales Invoice" si
JOIN "tabSales Invoice Item" sii ON sii.parent = si.name
WHERE sii.income_account = %s AND si.legacy_invoice_id > 0
ORDER BY si.legacy_invoice_id DESC
LIMIT 5
""", (DEFAULT_INCOME,))
for row in r:
print(f" inv={row[0]} legacy_id={row[1]} idx={row[2]} acct={row[4]} name={row[3][:40]}")
if not r:
print("All items already updated!")
frappe.destroy()
exit()
# Take one and test the mapping
test_inv = r[0][0]
test_lid = int(r[0][1])
print(f"\nTesting invoice {test_inv}, legacy_id={test_lid}")
# Get all items for this invoice
items_erp = frappe.db.sql("""
SELECT name, idx, item_name, income_account FROM "tabSales Invoice Item"
WHERE parent = %s ORDER BY idx
""", (test_inv,))
print(f" ERPNext items: {len(items_erp)}")
for item in items_erp:
print(f" idx={item[1]} acct={item[3]} {item[2][:50]}")
# Check legacy items
legacy = pymysql.connect(
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
with legacy.cursor() as cur:
cur.execute("""SELECT id, sku, product_name FROM invoice_item WHERE invoice_id = %s ORDER BY id""", (test_lid,))
litems = cur.fetchall()
legacy.close()
print(f"\n Legacy items: {len(litems)}")
for i, li in enumerate(litems, 1):
print(f" pos={i} sku={li['sku']} {li['product_name'][:50]}")
# Now build the map and check
legacy2 = pymysql.connect(
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
with legacy2.cursor() as cur:
cur.execute("""SELECT p.sku, pc.num_compte FROM product p JOIN product_cat pc ON p.category = pc.id
WHERE p.sku IS NOT NULL AND p.sku != '' AND pc.num_compte IS NOT NULL""")
sku_to_gl_num = {r['sku']: str(int(r['num_compte'])) for r in cur.fetchall() if r['num_compte']}
legacy2.close()
accts = frappe.db.sql("""SELECT account_number, name FROM "tabAccount"
WHERE root_type IN ('Income','Expense') AND company='TARGO' AND is_group=0
AND account_number IS NOT NULL AND account_number != ''""")
gl_by_number = {r[0]: r[1] for r in accts}
sku_to_income = {sku: gl_by_number[num] for sku, num in sku_to_gl_num.items() if num in gl_by_number}
# Build what the script builds
inv_item_map = {}
for i, li in enumerate(litems, 1):
sku = li['sku'] or ''
acct = sku_to_income.get(sku)
if acct:
inv_item_map[i] = acct
print(f" Mapped: idx={i} sku={sku}{acct}")
print(f"\n Would update {len(inv_item_map)} of {len(items_erp)} items")
frappe.destroy()

View File

@ -10,7 +10,7 @@ frappe.connect()
print("Connected:", frappe.local.site)
conn = pymysql.connect(
host="10.100.80.100",
host="legacy-db",
user="facturation",
password="*******",
database="gestionclient",

View File

@ -1,7 +1,7 @@
"""Explore legacy services + product descriptions for Expro Transit."""
import pymysql
conn = pymysql.connect(
host="10.100.80.100", user="facturation",
host="legacy-db", user="facturation",
password="*******", database="gestionclient",
cursorclass=pymysql.cursors.DictCursor
)

View File

@ -10,7 +10,7 @@ import uuid
from datetime import datetime, timezone
from html import unescape
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
LEGACY = {"host": "legacy-db", "user": "facturation", "password": "VD67owoj",
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 600}
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
"dbname": "_eb65bdc0c4b1b2d6"}

View File

@ -23,7 +23,7 @@ frappe.connect()
print("Connected:", frappe.local.site)
conn = pymysql.connect(
host="10.100.80.100", user="facturation", password="VD67owoj",
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)

View File

@ -194,7 +194,7 @@ def main():
log("--- Phase 2: Resolving duplicates via legacy DB ---")
try:
import pymysql
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
LEGACY = {"host": "legacy-db", "user": "facturation", "password": "VD67owoj",
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 300}
mc = pymysql.connect(**LEGACY)
mcur = mc.cursor(pymysql.cursors.DictCursor)

View File

@ -8,7 +8,7 @@ import pymysql
import psycopg2
from datetime import datetime, timezone
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
LEGACY = {"host": "legacy-db", "user": "facturation", "password": "VD67owoj",
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 600}
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
"dbname": "_eb65bdc0c4b1b2d6"}

View File

@ -0,0 +1,71 @@
"""Fix GL entries to match updated invoice item income_accounts."""
import frappe, os, sys, time
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
print("Connected:", frappe.local.site)
DEFAULT_INCOME = "Autres produits d'exploitation - T"
print("=== Update GL Entries ===")
t0 = time.time()
# Get dominant income account per invoice (highest total amount)
print(" Getting dominant account per invoice...")
dom = frappe.db.sql("""
SELECT parent, income_account, SUM(ABS(base_net_amount)) as total
FROM "tabSales Invoice Item"
WHERE income_account != %s
GROUP BY parent, income_account
""", (DEFAULT_INCOME,))
inv_best = {}
for inv_name, acct, total in dom:
total = float(total or 0)
if inv_name not in inv_best or total > inv_best[inv_name][1]:
inv_best[inv_name] = (acct, total)
print(f" {len(inv_best)} invoices with mapped accounts")
# Batch update GL entries
by_acct = {}
for inv_name, (acct, _) in inv_best.items():
by_acct.setdefault(acct, []).append(inv_name)
gl_updated = 0
for acct_name, inv_names in by_acct.items():
for i in range(0, len(inv_names), 1000):
batch = inv_names[i:i+1000]
placeholders = ','.join(['%s'] * len(batch))
frappe.db.sql(
f"""UPDATE "tabGL Entry" SET account = %s
WHERE voucher_type = 'Sales Invoice'
AND voucher_no IN ({placeholders})
AND account = %s""",
[acct_name] + batch + [DEFAULT_INCOME]
)
frappe.db.commit()
gl_updated += len(inv_names)
print(f" {acct_name}: {len(inv_names)} invoices")
print(f" Total: {gl_updated} invoices [{time.time()-t0:.0f}s]")
# Verify
print("\n=== Verify ===")
r = frappe.db.sql("""
SELECT account, COUNT(*) as cnt, ROUND(SUM(credit)::numeric, 2) as total_credit
FROM "tabGL Entry"
WHERE voucher_type = 'Sales Invoice' AND credit > 0
GROUP BY account ORDER BY total_credit DESC LIMIT 25
""")
print(" GL credit entries by account (Sales Invoice):")
for row in r:
print(f" {row[1]:>10} ${row[2]:>14} {row[0]}")
# Drop temp table if exists
frappe.db.sql("DROP TABLE IF EXISTS _tmp_sku_income_map")
frappe.db.commit()
frappe.destroy()
print("\nDone!")

View File

@ -0,0 +1,221 @@
"""
Fix income_account on Sales Invoice Items and GL Entries.
Uses legacy_invoice_id to join with legacy invoice_item SKU product_cat GL account.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_income_accounts.py
"""
import frappe
import pymysql
import os, sys, time
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
print("Connected:", frappe.local.site)
DEFAULT_INCOME = "Autres produits d'exploitation - T"
# ── Step 1: Build SKU → ERPNext income account ──
print("\n=== Step 1: Build SKU → GL mapping ===")
legacy = pymysql.connect(
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
with legacy.cursor() as cur:
cur.execute("""
SELECT p.sku, pc.num_compte
FROM product p JOIN product_cat pc ON p.category = pc.id
WHERE p.sku IS NOT NULL AND p.sku != '' AND pc.num_compte IS NOT NULL
""")
sku_to_gl_num = {}
for r in cur.fetchall():
if r['num_compte']:
sku_to_gl_num[r['sku']] = str(int(r['num_compte']))
print(f" {len(sku_to_gl_num)} SKU → GL number mappings")
# ERPNext account_number → account name
accts = frappe.db.sql("""
SELECT account_number, name FROM "tabAccount"
WHERE root_type IN ('Income', 'Expense') AND company = 'TARGO' AND is_group = 0
AND account_number IS NOT NULL AND account_number != ''
""")
gl_by_number = {r[0]: r[1] for r in accts}
print(f" {len(gl_by_number)} numbered GL accounts")
sku_to_income = {}
for sku, num in sku_to_gl_num.items():
if num in gl_by_number:
sku_to_income[sku] = gl_by_number[num]
print(f" {len(sku_to_income)} SKUs → named accounts")
# ── Step 2: Load legacy invoice_items grouped by invoice, ordered by id ──
print("\n=== Step 2: Load legacy invoice items with row numbers ===")
t0 = time.time()
with legacy.cursor() as cur:
cur.execute("""
SELECT invoice_id, sku,
ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY id) as rn
FROM invoice_item
ORDER BY invoice_id, id
""")
legacy_items = cur.fetchall()
legacy.close()
print(f" {len(legacy_items)} legacy items [{time.time()-t0:.0f}s]")
# Build: { legacy_invoice_id → { idx → income_account } }
inv_item_map = {}
mapped = unmapped = 0
for li in legacy_items:
sku = li['sku'] or ''
acct = sku_to_income.get(sku)
if acct:
inv_id = li['invoice_id']
idx = li['rn']
if inv_id not in inv_item_map:
inv_item_map[inv_id] = {}
inv_item_map[inv_id][idx] = acct
mapped += 1
else:
unmapped += 1
print(f" {mapped} items mapped, {unmapped} unmapped")
print(f" {len(inv_item_map)} invoices with mappable items")
# ── Step 3: Build ERPNext item name → (legacy_invoice_id, idx) lookup ──
print("\n=== Step 3: Update Sales Invoice Items via legacy_invoice_id join ===")
t0 = time.time()
# Process in chunks using legacy_invoice_id from tabSales Invoice
chunk_size = 5000
total_updated = 0
acct_totals = {}
# Get all invoices with legacy_invoice_id
inv_rows = frappe.db.sql("""
SELECT name, legacy_invoice_id FROM "tabSales Invoice"
WHERE legacy_invoice_id > 0
ORDER BY legacy_invoice_id
""")
print(f" {len(inv_rows)} invoices to process")
for ci in range(0, len(inv_rows), chunk_size):
chunk = inv_rows[ci:ci+chunk_size]
inv_names = [r[0] for r in chunk]
inv_id_map = {r[0]: int(r[1]) for r in chunk} # erpnext_name → legacy_id
# Get all items for these invoices
placeholders = ','.join(['%s'] * len(inv_names))
items = frappe.db.sql(
f'SELECT name, parent, idx FROM "tabSales Invoice Item" WHERE parent IN ({placeholders}) AND income_account = %s ORDER BY parent, idx',
inv_names + [DEFAULT_INCOME]
)
updates = []
for item_name, parent, idx in items:
legacy_id = inv_id_map.get(parent)
if legacy_id and legacy_id in inv_item_map and idx in inv_item_map[legacy_id]:
new_acct = inv_item_map[legacy_id][idx]
updates.append((new_acct, item_name))
acct_totals[new_acct] = acct_totals.get(new_acct, 0) + 1
# Execute updates in batches
for i in range(0, len(updates), 500):
batch = updates[i:i+500]
for new_acct, iname in batch:
frappe.db.sql(
'UPDATE "tabSales Invoice Item" SET income_account = %s WHERE name = %s',
(new_acct, iname)
)
frappe.db.commit()
total_updated += len(updates)
if (ci // chunk_size) % 10 == 0 or ci + chunk_size >= len(inv_rows):
elapsed = time.time() - t0
print(f" [{ci+len(chunk)}/{len(inv_rows)}] updated={total_updated} [{elapsed:.0f}s]")
print(f"\n Total items updated: {total_updated}")
print(" By account:")
for acct, cnt in sorted(acct_totals.items(), key=lambda x: -x[1]):
print(f" {cnt:>10} {acct}")
remaining = frappe.db.sql(
'SELECT COUNT(*) FROM "tabSales Invoice Item" WHERE income_account = %s',
(DEFAULT_INCOME,)
)[0][0]
total = frappe.db.sql('SELECT COUNT(*) FROM "tabSales Invoice Item"')[0][0]
print(f"\n Remaining on default: {remaining} / {total}")
print(f" Time: {time.time()-t0:.0f}s")
# ── Step 4: Update GL Entries based on updated invoice items ──
print("\n=== Step 4: Update GL Entries ===")
t0 = time.time()
# Get aggregated income per invoice per account (non-default only)
inv_accounts = frappe.db.sql("""
SELECT parent, income_account, SUM(base_net_amount) as total
FROM "tabSales Invoice Item"
WHERE income_account != %s
GROUP BY parent, income_account
""", (DEFAULT_INCOME,))
print(f" {len(inv_accounts)} (invoice, account) pairs")
# Group by invoice
inv_acct_map = {}
for r in inv_accounts:
inv_name = r[0]
if inv_name not in inv_acct_map:
inv_acct_map[inv_name] = []
inv_acct_map[inv_name].append({'account': r[1], 'total': float(r[2])})
# For simplicity: assign the GL entry to the dominant (highest total) income account
# A proper fix would split the GL entry, but that requires complex double-entry bookkeeping
gl_updates = {}
for inv_name, accts_list in inv_acct_map.items():
best = max(accts_list, key=lambda a: a['total'])
acct = best['account']
if acct not in gl_updates:
gl_updates[acct] = []
gl_updates[acct].append(inv_name)
gl_total = 0
for acct_name, inv_names in gl_updates.items():
for i in range(0, len(inv_names), 500):
batch = inv_names[i:i+500]
placeholders = ','.join(['%s'] * len(batch))
frappe.db.sql(
f"""UPDATE "tabGL Entry"
SET account = %s
WHERE voucher_type = 'Sales Invoice'
AND voucher_no IN ({placeholders})
AND account = %s""",
[acct_name] + batch + [DEFAULT_INCOME]
)
frappe.db.commit()
gl_total += len(inv_names)
print(f" {acct_name}: {len(inv_names)} invoices")
print(f" Total: {gl_total} invoices GL updated [{time.time()-t0:.0f}s]")
# ── Step 5: Verify ──
print("\n=== Step 5: Verify ===")
r = frappe.db.sql("""
SELECT account, COUNT(*) as cnt, ROUND(SUM(credit)::numeric, 2) as total_credit
FROM "tabGL Entry"
WHERE voucher_type = 'Sales Invoice'
AND credit > 0
GROUP BY account
ORDER BY cnt DESC
LIMIT 25
""")
print(" GL credit entries by account (Sales Invoice):")
for row in r:
print(f" {row[1]:>10} ${row[2]:>14} {row[0]}")
frappe.destroy()
print("\nDone!")

View File

@ -0,0 +1,189 @@
"""
Fix income_account using pure SQL bulk updates via a temp mapping table.
Much faster than row-by-row.
Run inside erpnext-backend-1:
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/fix_income_sql.py
"""
import frappe
import pymysql
import os, sys, time
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 1)
os.chdir("/home/frappe/frappe-bench/sites")
frappe.init(site="erp.gigafibre.ca", sites_path=".")
frappe.connect()
print("Connected:", frappe.local.site)
DEFAULT_INCOME = "Autres produits d'exploitation - T"
# ── Step 1: Build SKU → income account ──
print("\n=== Step 1: Build SKU → GL mapping ===")
legacy = pymysql.connect(
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)
with legacy.cursor() as cur:
cur.execute("""SELECT p.sku, pc.num_compte FROM product p
JOIN product_cat pc ON p.category = pc.id
WHERE p.sku IS NOT NULL AND p.sku != '' AND pc.num_compte IS NOT NULL""")
sku_to_gl_num = {r['sku']: str(int(r['num_compte'])) for r in cur.fetchall() if r['num_compte']}
accts = frappe.db.sql("""SELECT account_number, name FROM "tabAccount"
WHERE root_type IN ('Income','Expense') AND company='TARGO' AND is_group=0
AND account_number IS NOT NULL AND account_number != ''""")
gl_by_number = {r[0]: r[1] for r in accts}
sku_to_income = {sku: gl_by_number[num] for sku, num in sku_to_gl_num.items() if num in gl_by_number}
print(f" {len(sku_to_income)} SKU → account mappings")
# ── Step 2: Load legacy items and build (invoice_id, row_num) → account ──
print("\n=== Step 2: Load legacy items ===")
t0 = time.time()
with legacy.cursor() as cur:
cur.execute("""
SELECT invoice_id, sku,
ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY id) as rn
FROM invoice_item
ORDER BY invoice_id, id
""")
legacy_items = cur.fetchall()
legacy.close()
print(f" {len(legacy_items)} legacy items [{time.time()-t0:.0f}s]")
# ── Step 3: Create temp mapping table in PostgreSQL ──
print("\n=== Step 3: Create temp mapping table ===")
t0 = time.time()
# Drop if exists
frappe.db.sql("DROP TABLE IF EXISTS _tmp_sku_income_map")
frappe.db.commit()
# Create temp table: (legacy_invoice_id, idx, income_account)
frappe.db.sql("""
CREATE TABLE _tmp_sku_income_map (
legacy_invoice_id bigint,
idx integer,
income_account varchar(140),
PRIMARY KEY (legacy_invoice_id, idx)
)
""")
frappe.db.commit()
# Insert mappings in batches
batch = []
batch_size = 5000
inserted = 0
for li in legacy_items:
sku = li['sku'] or ''
acct = sku_to_income.get(sku)
if acct:
batch.append((li['invoice_id'], li['rn'], acct))
if len(batch) >= batch_size:
args = ','.join(['(%s,%s,%s)'] * len(batch))
flat = [v for t in batch for v in t]
frappe.db.sql(f"INSERT INTO _tmp_sku_income_map VALUES {args}", flat)
frappe.db.commit()
inserted += len(batch)
batch = []
if batch:
args = ','.join(['(%s,%s,%s)'] * len(batch))
flat = [v for t in batch for v in t]
frappe.db.sql(f"INSERT INTO _tmp_sku_income_map VALUES {args}", flat)
frappe.db.commit()
inserted += len(batch)
print(f" Inserted {inserted} mappings [{time.time()-t0:.0f}s]")
# Add index
frappe.db.sql("CREATE INDEX idx_tmp_map_lid ON _tmp_sku_income_map (legacy_invoice_id)")
frappe.db.commit()
# ── Step 4: Bulk UPDATE Sales Invoice Items via JOIN ──
print("\n=== Step 4: Bulk update Sales Invoice Items ===")
t0 = time.time()
result = frappe.db.sql("""
UPDATE "tabSales Invoice Item" sii
SET income_account = m.income_account
FROM "tabSales Invoice" si, _tmp_sku_income_map m
WHERE si.name = sii.parent
AND m.legacy_invoice_id = si.legacy_invoice_id
AND m.idx = sii.idx
AND sii.income_account = %s
""", (DEFAULT_INCOME,))
frappe.db.commit()
print(f" Update done [{time.time()-t0:.0f}s]")
# Check results
dist = frappe.db.sql("""SELECT income_account, COUNT(*) FROM "tabSales Invoice Item"
GROUP BY income_account ORDER BY COUNT(*) DESC""")
print(" Income account distribution:")
for row in dist:
print(f" {row[1]:>10} {row[0]}")
# ── Step 5: Update GL Entries ──
print("\n=== Step 5: Update GL Entries ===")
t0 = time.time()
# For each invoice, get the dominant income account from its items
# and update the GL entry
print(" Getting dominant account per invoice...")
dom = frappe.db.sql("""
SELECT parent, income_account, SUM(ABS(base_net_amount)) as total
FROM "tabSales Invoice Item"
WHERE income_account != %s
GROUP BY parent, income_account
""", (DEFAULT_INCOME,))
# Find dominant per invoice
inv_best = {}
for inv_name, acct, total in dom:
total = float(total or 0)
if inv_name not in inv_best or total > inv_best[inv_name][1]:
inv_best[inv_name] = (acct, total)
print(f" {len(inv_best)} invoices with mapped accounts")
# Batch update GL entries
by_acct = {}
for inv_name, (acct, _) in inv_best.items():
by_acct.setdefault(acct, []).append(inv_name)
gl_updated = 0
for acct_name, inv_names in by_acct.items():
for i in range(0, len(inv_names), 1000):
batch = inv_names[i:i+1000]
placeholders = ','.join(['%s'] * len(batch))
frappe.db.sql(
f"""UPDATE "tabGL Entry" SET account = %s
WHERE voucher_type = 'Sales Invoice'
AND voucher_no IN ({placeholders})
AND account = %s""",
[acct_name] + batch + [DEFAULT_INCOME]
)
frappe.db.commit()
gl_updated += len(inv_names)
print(f" {acct_name}: {len(inv_names)} invoices")
print(f" Total: {gl_updated} invoices [{time.time()-t0:.0f}s]")
# ── Step 6: Cleanup and verify ──
print("\n=== Step 6: Verify ===")
frappe.db.sql("DROP TABLE IF EXISTS _tmp_sku_income_map")
frappe.db.commit()
r = frappe.db.sql("""
SELECT account, COUNT(*) as cnt, ROUND(SUM(credit)::numeric, 2) as total_credit
FROM "tabGL Entry"
WHERE voucher_type = 'Sales Invoice' AND credit > 0
GROUP BY account ORDER BY cnt DESC LIMIT 25
""")
print(" GL credit entries by account (Sales Invoice):")
for row in r:
print(f" {row[1]:>10} ${row[2]:>14} {row[0]}")
frappe.destroy()
print("\nDone!")

View File

@ -89,7 +89,7 @@ t0 = time.time()
import pymysql
legacy = pymysql.connect(
host="10.100.80.100", user="facturation", password="VD67owoj",
host="legacy-db", user="facturation", password="VD67owoj",
database="gestionclient", cursorclass=pymysql.cursors.DictCursor
)

View File

@ -34,7 +34,7 @@ print("PHASE 1: LOAD LEGACY INVOICE DATA")
print("="*60)
conn = pymysql.connect(
host="10.100.80.100",
host="legacy-db",
user="facturation",
password="*******",
database="gestionclient",

View File

@ -3,7 +3,7 @@
import pymysql
import psycopg2
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
LEGACY = {"host": "legacy-db", "user": "facturation", "password": "VD67owoj",
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 300}
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
"dbname": "_eb65bdc0c4b1b2d6"}

View File

@ -7,7 +7,7 @@ import pymysql
import psycopg2
import json
LEGACY = {"host": "10.100.80.100", "user": "facturation", "password": "VD67owoj",
LEGACY = {"host": "legacy-db", "user": "facturation", "password": "VD67owoj",
"database": "gestionclient", "connect_timeout": 30, "read_timeout": 300}
PG = {"host": "db", "port": 5432, "user": "postgres", "password": "123",
"dbname": "_eb65bdc0c4b1b2d6"}

Some files were not shown because too many files have changed in this diff Show More