Compare commits

..

24 Commits

Author SHA1 Message Date
louispaulb
04dc0ceb14 refactor: monorepo structure — apps/dispatch, apps/website, erpnext/
- Merged dispatch-app (17 commits) into apps/dispatch/
- Merged site-web-targo (4 commits) into apps/website/
- Renamed scripts/ → erpnext/
- Removed empty doctypes/
- Updated README with monorepo layout and Gigafibre branding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:10:15 -04:00
louispaulb
6620652900 merge: import site-web-targo into apps/website/ (4 commits preserved)
Integrates www.gigafibre.ca (React/Vite) into the monorepo.
Full git history accessible via `git log -- apps/website/`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:09:15 -04:00
louispaulb
7da22ff132 merge: import dispatch-app into apps/dispatch/ (17 commits preserved)
Integrates the Dispatch PWA (Vue/Quasar) into the gigafibre-fsm monorepo.
Full git history accessible via `git log -- apps/dispatch/`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:08:51 -04:00
louispaulb
c22240e6bf feat: Mailjet email for contact form + lead capture
- Contact form POSTs to /rpc/contact → Mailjet email to support@targo.ca
- Lead capture (availability dialog) POSTs to /rpc/lead → Mailjet email
- API server handles both endpoints with proper HTML formatting
- No Supabase edge functions needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:56:23 -04:00
louispaulb
d8200b73e4 feat: add Accessibilité + Politique de confidentialité pages
- Import content from targo.ca/accessibilite/ and /politique-de-confidentialite/
- Styled with site design (Header, Footer, prose layout)
- Routes: /accessibilite, /politique-de-confidentialite
- Footer links now point to real pages instead of placeholders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:33:25 -04:00
louispaulb
0af24643ff fix: contact form sends to API, remove dead links, secure .env
- Contact form now POSTs to /rpc/contact (www-api → n8n webhook)
- Footer links: Mon compte → store.targo.ca, placeholder # → /support
- .env added to .gitignore (Supabase keys should not be committed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:28:06 -04:00
louispaulb
88dc3714a1 Initial deploy: gigafibre.ca website with self-hosted address search
React/Vite/shadcn-ui site for Gigafibre ISP.
Address qualification via PostgreSQL (5.2M AQ addresses, pg_trgm fuzzy search).
No Supabase dependency for address search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:37:50 -04:00
louispaulb
6fc8a2d37f refactor: externalize ERP service token via VITE_ERP_TOKEN env var
Token is no longer hardcoded in source — injected at build time.
Build with: VITE_ERP_TOKEN="key:secret" npx quasar build
Prevents accidental token invalidation and keeps secrets out of git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:41 -04:00
louispaulb
1263786b90 fix: update service token + fix API proxy routing
- Regenerated Admin API token (old one was invalidated by generate_keys)
- Traefik: separate routers for app (with Authentik) and /api/ (no auth)
- Nginx proxy: use container IP (cross-compose DNS doesn't resolve names)
- /outpost.goauthentik.io/ route for Authentik callbacks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:11 -04:00
louispaulb
7ef22873f0 fix: handle Authentik session expiry in SPA
- authFetch uses redirect:'manual' to detect 302 from Authentik
- If session expired (302/401/opaqueredirect), reload page to trigger
  Traefik forwardAuth → Authentik re-login flow
- Logout redirects to Authentik invalidation flow
- App.vue calls checkSession on mount to populate user identity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:34:39 -04:00
louispaulb
f1faffeab9 feat: switch Dispatch auth to Authentik forwardAuth
- Remove login form from App.vue (Authentik handles auth at Traefik level)
- Simplify auth store: no more checkSession/generate_keys complexity
- All ERPNext API calls use a service token (reliable, no CORS issues)
- User identity provided by Authentik X-authentik-email header
- Logout redirects to Authentik end-session URL
- Removed: login(), generate_keys, cookie fallback, token localStorage

Infrastructure:
- Created Authentik Proxy Provider for dispatch.gigafibre.ca
- Added to embedded outpost
- Applied authentik@file middleware to dispatch Traefik router
- Also removed unused Gitea (git.gigafibre.ca) containers + volumes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:33:09 -04:00
louispaulb
6d8339fa16 fix: map markers zoom drift — fixed-size container + center anchor
- All elements (SVG ring + avatar + badge) inside a fixed-size
  container (45x45px) with absolute positioning
- Avatar centered with calculated offset, ring fills container
- Mapbox marker anchor changed from 'bottom' to 'center'
- No more variable margins causing offset drift on zoom

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:08:36 -04:00
louispaulb
6f901f911c feat: SVG circular progress ring on map tech markers
Replace faint linear bars with SVG ring around avatar:
- Outer arc (faded): planned load / 8h capacity, color-coded
  green→yellow→orange→red based on load percentage
- Inner arc (solid green): jobs completed / total jobs
- Ring uses stroke-dasharray for clean arcs with round caps
- Tooltip: "Name — 2/5 jobs (3.0h / 7.0h)"
- Crew badge (purple "2") positioned on avatar corner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:05:32 -04:00
louispaulb
f7fea2b8e5 feat: dual progress bar on map markers (load + completion)
- Background bar (faded): planned hours / 8h capacity
  Color codes: green <4h, yellow 4-6h, orange 6-7h, red 7h+
- Foreground bar (solid green): completed hours / 8h
  Shows real-time job completion progress
- Tooltip: "Name — X.Xh complété / X.Xh planifié"
- Both bars stacked with absolute positioning for clean overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:22:11 -04:00
louispaulb
15813e6caf feat: map markers — workload progress bar + crew group badge
- Progress bar under each tech avatar showing daily load (0-8h)
  Green (<4h) → Yellow (4-6h) → Orange (6-8h) → Red (8h+)
  Includes both primary queue and assistant jobs
- Crew badge: purple circle with count (e.g. "2") when tech has
  assistants on today's jobs — indicates grouped team
- Tooltip shows "Name — X.Xh / 8h"
- Marker now uses gpsCoords || coords for visibility check
  (fixes techs with GPS but no static coords)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:16:56 -04:00
louispaulb
af42c6082e feat: auth gate, GPS hybrid tracking, tech CRUD modal, ERPNext API proxy
Authentication:
- Add App.vue login gate (v-if auth.loading / v-else-if !auth.user / router-view)
- Fix auth.checkSession with try/finally to always reset loading
- Fix generate_keys method name for Frappe v16 (generate_keys, not generate_keys_for_api_user)
- Auto-generate API token on cookie-based auth (Authentik SSO support)
- Remove duplicate checkSession from DispatchV2Page (was causing infinite mount/unmount loop)

GPS Tracking — Hybrid REST + WebSocket:
- Initial REST fetch per-device in parallel (Traccar API only supports one deviceId per request)
- WebSocket real-time updates via wss://dispatch.gigafibre.ca/traccar/api/socket
- Auto-fallback to 30s polling if WebSocket fails, with exponential backoff reconnect
- Module-level guards (__gpsStarted, __gpsPolling) to prevent loops on component remount
- Only update tech.gpsCoords when value actually changes (prevents unnecessary reactive triggers)

Tech Management (GPS Modal):
- Add/delete technicians directly from GPS modal → persists to ERPNext
- Inline edit: double-click name to rename, phone field, status select
- Auto-generate technician_id (TECH-N+1)
- Unlink jobs before delete to avoid ERPNext LinkExistsError
- Added phone/email custom fields to Dispatch Technician doctype

Infrastructure:
- Nginx proxy: /api/ → ERPNext (same-origin, eliminates all CORS issues)
- Nginx proxy: /traccar/ WebSocket support (Upgrade headers, 86400s timeout)
- No-cache headers on index.html and sw.js for instant PWA updates
- BASE_URL switched to empty string in production (same-origin via proxy)

Bug fixes:
- ERPNext Number Card PostgreSQL fix (ORDER BY on aggregate queries)
- Traccar fetchPositions: parallel per-device calls (API ignores multiple deviceId params)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:02:04 -04:00
louispaulb
f1badea201 fix: add watcher for GPS position updates on map markers
Technician GPS positions from Traccar were being fetched and stored
correctly every 30s but drawMapMarkers() was never triggered on
gpsCoords change, so markers stayed at their initial position.

Added a deep watcher on store.technicians[].gpsCoords in useMap.js
that calls drawMapMarkers() whenever any technician's GPS position
is updated by pollGps().

Also includes traccar.js API module and dispatch store GPS polling
(pollGps / startGpsPolling / stopGpsPolling).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 20:17:13 -04:00
louispaulb
859f043bb2 Refactor: extract autoDispatch, serializeAssistants, store assign logic
- Extract useAutoDispatch.js (autoDistribute + optimizeRoute)
- Add serializeAssistants() to useHelpers — removes 6 duplications
- Move smartAssign/fullUnassign into Pinia store
- Add drag-and-drop on dispatch criteria modal
- DispatchV2Page.vue: 1463 → 1385 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:25:33 -04:00
louispaulb
a5822f7a5b Add deploy-fast.sh — local build + docker cp (~5s vs ~30s)
No Docker image rebuild needed. Builds PWA locally with npx quasar
then copies dist/pwa directly into the ERPNext frontend container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:12:07 -04:00
louispaulb
632e4ae0d1 Refactor: modular architecture — extract composables & components
- Extract useDragDrop.js (drag/drop, block move, resize)
- Extract useSelection.js (lasso, multi-select, hover linking)
- Extract WeekCalendar.vue, MonthCalendar.vue, RightPanel.vue
- DispatchV2Page.vue: 3018 → 1438 lines (orchestration only)
- Remove <style scoped> — styles cascade to child components
- Add .dockerignore (build context 214MB → 112KB)
- Add infra/ with docker-compose reference and .env.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:08:56 -04:00
louispaulb
b90db4673a Fix: restore techCtx and openTechCtx lost during map extraction
These were accidentally deleted when removing ~576 lines of inline map code.
Caused ReferenceError preventing the app from loading data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:22:19 -04:00
louispaulb
ec385c99d0 Add ARCHITECTURE.md — full project documentation
Covers: stack, directory structure, routes, ERPNext doctypes,
PostgreSQL extensions, features, component communication,
planned modules, and deploy instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:11:40 -04:00
louispaulb
e2b775c077 Clean up: remove duplicate TagInput, delete quasar-migration branch
- Removed src/components/shared/TagInput.vue (exact duplicate of src/components/TagInput.vue)
- Deleted feature/quasar-migration branch from local and remote
- Verified build + deploy: design intact

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:11:40 -04:00
louispaulb
1b0fc89304 Initial commit — OSS/BSS Field Dispatch app
Current state: custom CSS + vanilla Vue components
Architecture: modular with composables, provide/inject pattern
Ready for progressive migration to Quasar native components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:11:40 -04:00
245 changed files with 47798 additions and 17 deletions

View File

@ -1,27 +1,71 @@
# Gigafibre FSM # Gigafibre
Field Service Management for Gigafibre ISP — built on ERPNext + Vue/Quasar. Plateforme complète pour Gigafibre ISP (marque consommateur de TARGO).
## Quick Start ## Structure du monorepo
```
gigafibre-fsm/
apps/
dispatch/ Vue 3 / Quasar / Pinia — PWA de dispatch terrain
website/ React / Vite / Tailwind — www.gigafibre.ca
erpnext/
setup_fsm_doctypes.py Setup des doctypes FSM dans ERPNext
docs/
ARCHITECTURE.md Modèle de données, stack technique
INFRASTRUCTURE.md Serveur, DNS, auth, APIs, gotchas
ROADMAP.md Plan d'implémentation en 5 phases
COMPETITIVE-ANALYSIS.md Analyse concurrentielle
```
## Apps
### Dispatch PWA (`apps/dispatch/`)
Interface de répartition terrain : timeline drag-drop, carte Mapbox avec GPS temps réel (Traccar), gestion techniciens.
### 1. Create ERPNext doctypes
```bash ```bash
# Copy script to ERPNext container cd apps/dispatch
docker cp scripts/setup_fsm_doctypes.py erpnext-backend-1:/home/frappe/frappe-bench/apps/frappe/frappe/ npm install
npx quasar dev # dev local
DEPLOY_BASE=/ npx quasar build -m pwa # build prod
```
# Execute ### Site web (`apps/website/`)
Site vitrine www.gigafibre.ca : qualification d'adresse (5.2M adresses QC), formulaire contact, capture leads.
```bash
cd apps/website
npm install
npm run dev # dev local
npm run build # build prod
```
## ERPNext — Doctypes FSM
```bash
docker cp erpnext/setup_fsm_doctypes.py erpnext-backend-1:/home/frappe/frappe-bench/apps/frappe/frappe/
docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute frappe.setup_fsm_doctypes.create_all docker exec erpnext-backend-1 bench --site erp.gigafibre.ca execute frappe.setup_fsm_doctypes.create_all
``` ```
### 2. Dispatch PWA
See [OSS-BSS-Field-Dispatch](https://git.targo.ca/louis/OSS-BSS-Field-Dispatch) repo.
## Documentation ## Documentation
- [Architecture](docs/ARCHITECTURE.md) — data model, tech stack, auth flow
- [Roadmap](docs/ROADMAP.md) — phased implementation plan
## Related Repos | Document | Contenu |
| Repo | Purpose | |----------|---------|
|------|---------| | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Modèle de données, stack, auth flow |
| [OSS-BSS-Field-Dispatch](https://git.targo.ca/louis/OSS-BSS-Field-Dispatch) | Vue/Quasar dispatch PWA | | [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) | Serveur, DNS, Traefik, Authentik, Docker, gotchas |
| [frappe_docker](https://git.targo.ca/louis/frappe-docker) | ERPNext Docker setup | | [ROADMAP.md](docs/ROADMAP.md) | 5 phases d'implémentation |
| [COMPETITIVE-ANALYSIS.md](docs/COMPETITIVE-ANALYSIS.md) | Gaiia, Odoo, Zuper, Salesforce, ServiceTitan |
## Infrastructure
Voir [INFRASTRUCTURE.md](docs/INFRASTRUCTURE.md) pour le schéma complet. En résumé :
- **Serveur:** 96.125.196.67 (Proxmox VM, Ubuntu 24.04)
- **Proxy:** Traefik v2.11 avec Let's Encrypt
- **Auth:** Authentik SSO (auth.targo.ca) via forwardAuth
- **ERP:** ERPNext v16 (erp.gigafibre.ca)
- **GPS:** Traccar (tracker.targointernet.com)
- **Workflows:** n8n (n8n.gigafibre.ca)
- **DNS:** Cloudflare (gigafibre.ca)
- **Email:** Mailjet (noreply@targo.ca)
- **SMS:** Twilio (+1 438 231-3838)

View File

@ -0,0 +1,4 @@
node_modules
dist
.git
*.md

View File

@ -0,0 +1,10 @@
module.exports = {
root: true,
parserOptions: { ecmaVersion: 'latest' },
env: { browser: true },
extends: ['plugin:vue/vue3-essential', 'eslint:recommended'],
rules: {
'no-unused-vars': 'warn',
'vue/multi-word-component-names': 'off',
},
}

6
apps/dispatch/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.quasar/
.DS_Store
*.log
npm-debug.log*

14
apps/dispatch/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies first (cached layer)
COPY package*.json ./
RUN npm install
# Copy source and build
COPY . .
RUN npm run build
# The built app lives in /app/dist/pwa/
# It is extracted by deploy.sh using `docker cp`

29
apps/dispatch/deploy-fast.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# deploy-fast.sh — Build locally + copy to ERPNext container (no Docker build)
#
# ~5-8s vs ~30s with deploy.sh
#
# Usage:
# chmod +x deploy-fast.sh
# ./deploy-fast.sh
# ─────────────────────────────────────────────────────────────────────────────
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONTAINER="frappe_docker-frontend-1"
DEST="/home/frappe/frappe-bench/sites/assets/dispatch-app"
cd "$SCRIPT_DIR"
echo "==> Building PWA locally..."
npx quasar build -m pwa
echo "==> Deploying to $CONTAINER..."
docker exec "$CONTAINER" mkdir -p "$DEST"
docker cp "$SCRIPT_DIR/dist/pwa/." "$CONTAINER:$DEST/"
echo ""
echo "Done! (~$(date +%Ss))"
echo " Dispatch : http://localhost:8080/assets/dispatch-app/index.html"
echo " Mobile : http://localhost:8080/assets/dispatch-app/index.html#/mobile"

41
apps/dispatch/deploy.sh Executable file
View File

@ -0,0 +1,41 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# deploy.sh — Build the Quasar PWA and deploy to ERPNext Docker
#
# Usage:
# chmod +x deploy.sh
# ./deploy.sh
#
# Accès après déploiement :
# http://localhost:8080/assets/dispatch-app/
# http://localhost:8080/assets/dispatch-app/#/mobile
#
# To change the target container or path, edit CONTAINER and DEST below.
# ─────────────────────────────────────────────────────────────────────────────
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONTAINER="frappe_docker-frontend-1"
DEST="/home/frappe/frappe-bench/sites/assets/dispatch-app"
IMAGE="dispatch-app-builder"
echo "==> Building Docker image..."
docker build -t "$IMAGE" "$SCRIPT_DIR"
echo "==> Extracting build artifacts..."
TMPDIR="$(mktemp -d)"
# Create a temporary container (not running) to copy files out
CID=$(docker create "$IMAGE")
docker cp "$CID:/app/dist/pwa/." "$TMPDIR/"
docker rm "$CID"
echo "==> Deploying to ERPNext container ($CONTAINER:$DEST)..."
docker exec "$CONTAINER" mkdir -p "$DEST"
docker cp "$TMPDIR/." "$CONTAINER:$DEST/"
rm -rf "$TMPDIR"
echo ""
echo "Done!"
echo " Dispatch : http://localhost:8080/assets/dispatch-app/index.html"
echo " Mobile : http://localhost:8080/assets/dispatch-app/index.html#/mobile"

View File

@ -0,0 +1,23 @@
"""
Add start_time field to Dispatch Job doctype
"""
import frappe
def run():
meta = frappe.get_meta('Dispatch Job')
if meta.has_field('start_time'):
print("✓ Field 'start_time' already exists on Dispatch Job")
return
doc = frappe.get_doc('DocType', 'Dispatch Job')
doc.append('fields', {
'fieldname': 'start_time',
'fieldtype': 'Time',
'label': 'Heure de début',
'insert_after': 'scheduled_date',
})
doc.save(ignore_permissions=True)
frappe.db.commit()
print("✓ Field 'start_time' added to Dispatch Job")
run()

View File

@ -0,0 +1,99 @@
"""
Dispatch Settings création du DocType Single dans ERPNext/Frappe
==================================================================
Exécution (depuis le host) :
docker cp frappe-setup/create_dispatch_settings.py frappe_docker-backend-1:/home/frappe/
docker exec frappe_docker-backend-1 bash -c \
"cd /home/frappe/frappe-bench && bench --site $(bench --site-list | head -1) execute /home/frappe/create_dispatch_settings.py"
Ou directement dans la console bench :
bench --site <site> console
>>> exec(open('/home/frappe/create_dispatch_settings.py').read())
"""
import frappe
FIELDS = [
# ── ERPNext / Frappe ─────────────────────────────────────────────────────
{'fieldname': 'erp_section', 'fieldtype': 'Section Break', 'label': 'ERPNext / Frappe'},
{'fieldname': 'erp_url', 'fieldtype': 'Data', 'label': 'URL du serveur',
'description': 'Ex: http://localhost:8080 ou https://erp.monentreprise.com',
'default': 'http://localhost:8080'},
{'fieldname': 'erp_api_key', 'fieldtype': 'Data', 'label': 'API Key',
'description': 'Profil utilisateur ERPNext → API Access → API Key'},
{'fieldname': 'erp_api_secret', 'fieldtype': 'Password', 'label': 'API Secret'},
# ── Mapbox ───────────────────────────────────────────────────────────────
{'fieldname': 'mapbox_section', 'fieldtype': 'Section Break', 'label': 'Mapbox'},
{'fieldname': 'mapbox_token', 'fieldtype': 'Data', 'label': 'Token public (pk_)',
'description': 'Token public — visible dans le navigateur, limitez le scope dans le dashboard Mapbox',
'default': 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'},
# ── Twilio (SMS) ─────────────────────────────────────────────────────────
{'fieldname': 'twilio_section', 'fieldtype': 'Section Break', 'label': 'Twilio — SMS'},
{'fieldname': 'twilio_account_sid', 'fieldtype': 'Data', 'label': 'Account SID',
'description': 'Commence par AC — console.twilio.com'},
{'fieldname': 'twilio_auth_token', 'fieldtype': 'Password', 'label': 'Auth Token'},
{'fieldname': 'twilio_from_number', 'fieldtype': 'Data', 'label': 'Numéro expéditeur',
'description': 'Format E.164 : +15141234567'},
# ── Stripe ───────────────────────────────────────────────────────────────
{'fieldname': 'stripe_section', 'fieldtype': 'Section Break', 'label': 'Stripe — Paiements'},
{'fieldname': 'stripe_mode', 'fieldtype': 'Select', 'label': 'Mode',
'options': 'test\nlive', 'default': 'test'},
{'fieldname': 'stripe_publishable_key','fieldtype': 'Data', 'label': 'Clé publique (pk_)'},
{'fieldname': 'stripe_secret_key', 'fieldtype': 'Password', 'label': 'Clé secrète (sk_)'},
{'fieldname': 'stripe_webhook_secret', 'fieldtype': 'Password', 'label': 'Webhook Secret (whsec_)'},
# ── n8n ──────────────────────────────────────────────────────────────────
{'fieldname': 'n8n_section', 'fieldtype': 'Section Break', 'label': 'n8n — Automatisation'},
{'fieldname': 'n8n_url', 'fieldtype': 'Data', 'label': 'URL n8n',
'default': 'http://localhost:5678'},
{'fieldname': 'n8n_api_key', 'fieldtype': 'Password', 'label': 'API Key n8n'},
{'fieldname': 'n8n_webhook_base','fieldtype': 'Data', 'label': 'Base URL webhooks',
'description': 'Ex: http://localhost:5678/webhook — préfixe des webhooks ERPNext → n8n',
'default': 'http://localhost:5678/webhook'},
# ── Templates SMS ────────────────────────────────────────────────────────
{'fieldname': 'sms_section', 'fieldtype': 'Section Break', 'label': 'Templates SMS'},
{'fieldname': 'sms_enroute', 'fieldtype': 'Text', 'label': 'Technicien en route',
'default': 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}'},
{'fieldname': 'sms_completed', 'fieldtype': 'Text', 'label': 'Service complété',
'default': 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !'},
{'fieldname': 'sms_assigned', 'fieldtype': 'Text', 'label': 'Job assigné (technicien)',
'default': 'Nouveau job assigné : {job_id}{client_name}, {address}. Durée estimée : {duration}h.'},
]
PERMISSIONS = [
{'role': 'System Manager', 'read': 1, 'write': 1, 'create': 1, 'delete': 1},
{'role': 'Administrator', 'read': 1, 'write': 1, 'create': 1, 'delete': 1},
]
def create_dispatch_settings():
if frappe.db.exists('DocType', 'Dispatch Settings'):
print("✓ DocType 'Dispatch Settings' existe déjà")
print(" UI : ERPNext Desk → Dispatch Settings")
print(" API : /api/resource/Dispatch Settings/Dispatch Settings")
return
doc = frappe.new_doc('DocType')
doc.update({
'name': 'Dispatch Settings',
'module': 'Core',
'custom': 1,
'is_single': 1,
'track_changes': 0,
'fields': FIELDS,
'permissions': PERMISSIONS,
})
doc.insert(ignore_permissions=True)
frappe.db.commit()
print("✓ DocType 'Dispatch Settings' créé avec succès")
print(" UI : ERPNext Desk → Dispatch Settings")
print(" API : /api/resource/Dispatch Settings/Dispatch Settings")
create_dispatch_settings()

14
apps/dispatch/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Dispatch</title>
<meta charset="UTF-8" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, viewport-fit=cover" />
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png" />
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

View File

@ -0,0 +1,8 @@
# ERPNext
ERPNEXT_VERSION=v15.49.2
DB_ROOT_PASSWORD=admin
# PostgreSQL (address autocomplete)
PG_DB=dispatch
PG_USER=dispatch
PG_PASSWORD=dispatch

View File

@ -0,0 +1,138 @@
# ERPNext Docker Compose — reference config for rebuilding infrastructure
# Based on frappe_docker: https://github.com/frappe/frappe_docker
#
# Usage:
# cp .env.example .env (edit vars)
# docker compose -f docker-compose.erpnext.yaml up -d
#
# After ERPNext is running, deploy the dispatch PWA:
# cd ../dispatch-app && bash deploy.sh
x-customizable-image: &customizable_image
image: ${CUSTOM_IMAGE:-frappe/erpnext}:${CUSTOM_TAG:-v15.49.2}
pull_policy: ${PULL_POLICY:-always}
restart: unless-stopped
x-depends-on-configurator: &depends_on_configurator
depends_on:
configurator:
condition: service_completed_successfully
x-backend-defaults: &backend_defaults
<<: [*depends_on_configurator, *customizable_image]
volumes:
- sites:/home/frappe/frappe-bench/sites
services:
configurator:
<<: *backend_defaults
platform: linux/amd64
entrypoint: ["bash", "-c"]
command:
- >
ls -1 apps > sites/apps.txt;
bench set-config -g db_host $$DB_HOST;
bench set-config -gp db_port $$DB_PORT;
bench set-config -g redis_cache "redis://$$REDIS_CACHE";
bench set-config -g redis_queue "redis://$$REDIS_QUEUE";
bench set-config -g redis_socketio "redis://$$REDIS_QUEUE";
bench set-config -gp socketio_port $$SOCKETIO_PORT;
environment:
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-3306}
REDIS_CACHE: ${REDIS_CACHE:-redis-cache:6379}
REDIS_QUEUE: ${REDIS_QUEUE:-redis-queue:6379}
SOCKETIO_PORT: 9000
depends_on:
db:
condition: service_healthy
redis-cache:
condition: service_started
redis-queue:
condition: service_started
restart: on-failure
backend:
<<: *backend_defaults
platform: linux/amd64
frontend:
<<: *customizable_image
platform: linux/amd64
command: ["nginx-entrypoint.sh"]
environment:
BACKEND: backend:8000
SOCKETIO: websocket:9000
FRAPPE_SITE_NAME_HEADER: $$host
PROXY_READ_TIMEOUT: 120
CLIENT_MAX_BODY_SIZE: 50m
volumes:
- sites:/home/frappe/frappe-bench/sites
ports:
- "8080:8080"
depends_on:
- backend
- websocket
websocket:
<<: [*depends_on_configurator, *customizable_image]
platform: linux/amd64
command: ["node", "/home/frappe/frappe-bench/apps/frappe/socketio.js"]
volumes:
- sites:/home/frappe/frappe-bench/sites
queue-short:
<<: *backend_defaults
platform: linux/amd64
command: bench worker --queue short,default
queue-long:
<<: *backend_defaults
platform: linux/amd64
command: bench worker --queue long,default,short
scheduler:
<<: *backend_defaults
platform: linux/amd64
command: bench schedule
db:
image: mariadb:10.11
platform: linux/amd64
restart: unless-stopped
command: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci', '--skip-character-set-client-handshake']
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-admin}
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: mysqladmin ping -h localhost --password=$$MYSQL_ROOT_PASSWORD
interval: 5s
retries: 10
redis-cache:
image: redis:7-alpine
restart: unless-stopped
redis-queue:
image: redis:7-alpine
restart: unless-stopped
# PostgreSQL for address autocomplete (rqa_addresses table)
postgres:
image: postgres:14-alpine
platform: linux/amd64
restart: unless-stopped
environment:
POSTGRES_DB: ${PG_DB:-dispatch}
POSTGRES_USER: ${PG_USER:-dispatch}
POSTGRES_PASSWORD: ${PG_PASSWORD:-dispatch}
volumes:
- pg-data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
sites:
db-data:
pg-data:

10018
apps/dispatch/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
{
"name": "dispatch-app",
"version": "0.0.1",
"description": "Dispatch & Field Service app for ERPNext",
"productName": "Dispatch",
"private": true,
"scripts": {
"dev": "quasar dev",
"build": "quasar build -m pwa",
"lint": "eslint --ext .js,.vue ./src"
},
"dependencies": {
"@quasar/extras": "^1.16.12",
"html5-qrcode": "^2.3.8",
"pinia": "^2.1.7",
"quasar": "^2.16.10",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.10.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0",
"sass": "^1.72.0",
"workbox-build": "7.0.x",
"workbox-cacheable-response": "7.0.x",
"workbox-core": "7.0.x",
"workbox-expiration": "7.0.x",
"workbox-precaching": "7.0.x",
"workbox-routing": "7.0.x",
"workbox-strategies": "7.0.x"
},
"engines": {
"node": "^18 || ^20",
"npm": ">= 6.13.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,75 @@
/* eslint-env node */
const { configure } = require('quasar/wrappers')
module.exports = configure(function (ctx) {
return {
boot: ['pinia'],
css: ['app.scss'],
extras: ['roboto-font', 'material-icons'],
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node20',
},
vueRouterMode: 'hash',
// Base path = where the app is deployed under ERPNext
// Change this if you move the app to a different path
extendViteConf (viteConf) {
viteConf.base = process.env.DEPLOY_BASE || '/assets/dispatch-app/'
},
},
devServer: {
open: false,
// Listen on all interfaces so the container port is reachable from the host
host: '0.0.0.0',
port: 9000,
proxy: {
// Proxy ERPNext API calls to the frontend container
// host.docker.internal resolves to the Docker host on Mac / Windows
'/api': {
target: 'http://host.docker.internal:8080',
changeOrigin: true,
cookieDomainRewrite: 'localhost',
},
'/assets': {
target: 'http://host.docker.internal:8080',
changeOrigin: true,
},
},
},
framework: {
config: {},
// Only load what we actually use — add more as needed
plugins: ['Notify', 'Loading', 'LocalStorage'],
},
animations: [],
pwa: {
workboxMode: 'generateSW',
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialForManifestTag: false,
workboxOptions: {
skipWaiting: true,
clientsClaim: true,
},
extendManifestJson (json) {
json.name = 'Dispatch'
json.short_name = 'Dispatch'
json.description = 'Dispatch & Field Service'
json.display = 'standalone'
json.orientation = 'portrait'
json.background_color = '#ffffff'
json.theme_color = '#6366f1'
json.start_url = '.'
},
},
}
})

View File

@ -0,0 +1,88 @@
import frappe, os
os.chdir('/home/frappe/frappe-bench')
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
frappe.connect()
SCRIPT = """
frappe.ui.form.on('Dispatch Job', {
setup(frm) {
frm._addr_bound = false;
},
refresh(frm) {
if (frm._addr_bound) return;
frappe.run_serially([
() => frappe.timeout(1),
() => {
try { _bind_address_autocomplete(frm); }
catch(e) { console.warn('Address autocomplete deferred:', e.message); }
}
]);
}
});
function _bind_address_autocomplete(frm) {
var ctrl = frm.fields_dict && frm.fields_dict.address;
if (!ctrl) return;
var input = ctrl.input || (ctrl.$input && ctrl.$input[0]);
if (!input) return;
if (frm._addr_bound) return;
frm._addr_bound = true;
var dropdown = document.createElement('div');
dropdown.style.cssText = 'position:absolute;z-index:1000;background:#fff;border:1px solid #d1d5db;border-radius:6px;max-height:250px;overflow-y:auto;width:100%;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:none;';
input.parentElement.style.position = 'relative';
input.parentElement.appendChild(dropdown);
var timer = null;
input.addEventListener('input', function() {
clearTimeout(timer);
var q = this.value.trim();
if (q.length < 3) { dropdown.style.display = 'none'; return; }
timer = setTimeout(function() {
frappe.call({
method: 'search_address',
args: { q: q },
callback: function(r) {
dropdown.innerHTML = '';
var results = (r && r.results) || (r && r.message && r.message.results) || [];
if (!results.length) {
dropdown.innerHTML = '<div style="padding:8px 12px;color:#6b7280;font-size:12px">Aucun resultat</div>';
} else {
results.forEach(function(addr) {
var item = document.createElement('div');
item.style.cssText = 'padding:8px 12px;cursor:pointer;font-size:13px;border-bottom:1px solid #f3f4f6;';
var html = '<strong>' + addr.address_full + '</strong>';
if (addr.ville) html += ' <span style="float:right;color:#6b7280;font-size:11px">' + addr.ville + '</span>';
item.innerHTML = html;
item.addEventListener('mousedown', function(e) {
e.preventDefault();
frm.set_value('address', addr.address_full);
if (addr.latitude) frm.set_value('latitude', parseFloat(addr.latitude));
if (addr.longitude) frm.set_value('longitude', parseFloat(addr.longitude));
dropdown.style.display = 'none';
frm.dirty();
});
item.addEventListener('mouseenter', function() { this.style.background = '#f3f4f6'; });
item.addEventListener('mouseleave', function() { this.style.background = ''; });
dropdown.appendChild(item);
});
}
dropdown.style.display = 'block';
}
});
}, 300);
});
input.addEventListener('blur', function() {
setTimeout(function() { dropdown.style.display = 'none'; }, 200);
});
}
"""
cs = frappe.get_doc('Client Script', 'Dispatch Job Address Autocomplete')
cs.enabled = 1
cs.script = SCRIPT
cs.save(ignore_permissions=True)
frappe.db.commit()
print('Client Script fixed and re-enabled')
frappe.destroy()

View File

@ -0,0 +1,37 @@
import frappe, os
os.chdir('/home/frappe/frappe-bench')
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
frappe.connect()
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
ss.script = """
query = frappe.form_dict.get("q", "")
if not query or len(query) < 3:
frappe.response["results"] = []
else:
words = query.strip().split()
conditions = []
params = {}
for i, w in enumerate(words):
key = "w" + str(i)
conditions.append("f_unaccent(address_full) ILIKE f_unaccent(%({})s)".format(key))
params[key] = "%" + w + "%"
where = " AND ".join(conditions)
results = frappe.db.sql(
"SELECT address_full, ville, code_postal, latitude, longitude "
"FROM rqa_addresses "
"WHERE " + where + " "
"ORDER BY "
"CASE WHEN code_postal LIKE 'J0L%%' THEN 0 WHEN code_postal LIKE 'J0S%%' THEN 1 ELSE 2 END, "
"length(address_full) "
"LIMIT 10",
params, as_dict=True)
frappe.response["results"] = results
"""
ss.save(ignore_permissions=True)
frappe.db.commit()
print('Fixed: AND-based word search')
frappe.destroy()

View File

@ -0,0 +1,42 @@
import frappe, os
os.chdir('/home/frappe/frappe-bench')
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
frappe.connect()
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
ss.script = """
query = frappe.form_dict.get("q", "")
if not query or len(query) < 3:
frappe.response["results"] = []
else:
words = query.strip().split()
conditions = []
params = {}
for i, w in enumerate(words):
key = "w" + str(i)
params[key] = "%" + w + "%"
conditions.append(
"(f_unaccent(address_full) ILIKE f_unaccent(%({k})s) "
"OR f_unaccent(rue) ILIKE f_unaccent(%({k})s) "
"OR f_unaccent(ville) ILIKE f_unaccent(%({k})s) "
"OR numero ILIKE %({k})s)".format(k=key)
)
where = " AND ".join(conditions)
results = frappe.db.sql(
"SELECT address_full, ville, code_postal, latitude, longitude "
"FROM rqa_addresses "
"WHERE " + where + " "
"ORDER BY "
"CASE WHEN code_postal LIKE 'J0L%%' THEN 0 WHEN code_postal LIKE 'J0S%%' THEN 1 ELSE 2 END, "
"length(address_full) "
"LIMIT 10",
params, as_dict=True)
frappe.response["results"] = results
"""
ss.save(ignore_permissions=True)
frappe.db.commit()
print('Fixed: search across address_full, rue, ville, numero')
frappe.destroy()

View File

@ -0,0 +1,43 @@
import frappe, os
os.chdir('/home/frappe/frappe-bench')
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
frappe.connect()
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
ss.script = """
query = frappe.form_dict.get("q", "")
if not query or len(query) < 3:
frappe.response["results"] = []
else:
words = query.strip().split()
conditions = []
params = {}
idx = 0
for w in words:
k = "w" + str(idx)
params[k] = "%" + w + "%"
conditions.append(
"(f_unaccent(address_full) ILIKE f_unaccent(%%(%s)s) "
"OR f_unaccent(rue) ILIKE f_unaccent(%%(%s)s) "
"OR f_unaccent(ville) ILIKE f_unaccent(%%(%s)s) "
"OR numero ILIKE %%(%s)s)" % (k, k, k, k)
)
idx = idx + 1
where = " AND ".join(conditions)
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
"FROM rqa_addresses "
"WHERE " + where + " "
"ORDER BY "
"CASE WHEN code_postal LIKE 'J0L%%%%' THEN 0 WHEN code_postal LIKE 'J0S%%%%' THEN 1 ELSE 2 END, "
"length(address_full) "
"LIMIT 10")
results = frappe.db.sql(sql, params, as_dict=True)
frappe.response["results"] = results
"""
ss.save(ignore_permissions=True)
frappe.db.commit()
print('Fixed: no .format(), using % operator')
frappe.destroy()

View File

@ -0,0 +1,40 @@
import frappe, os
os.chdir('/home/frappe/frappe-bench')
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
frappe.connect()
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
ss.script = """
query = frappe.form_dict.get("q", "")
if not query or len(query) < 3:
frappe.response["results"] = []
else:
q = query.strip().lower()
q = q.replace("ste-", "sainte-").replace("ste ", "sainte-")
q = q.replace("st-", "saint-").replace("st ", "saint-")
q = q.replace("boul ", "boulevard ").replace("boul.", "boulevard")
q = q.replace("ave ", "avenue ").replace("ave.", "avenue")
words = q.split()
conditions = []
for w in words:
escaped = frappe.db.escape("%" + w + "%")
conditions.append("search_text LIKE " + escaped)
where = " AND ".join(conditions)
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
"FROM rqa_addresses "
"WHERE " + where + " "
"ORDER BY "
"CASE WHEN code_postal LIKE 'J0L%' THEN 0 "
"WHEN code_postal LIKE 'J0S%' THEN 1 ELSE 2 END, "
"length(address_full) "
"LIMIT 10")
results = frappe.db.sql(sql, as_dict=True)
frappe.response["results"] = results
"""
ss.save(ignore_permissions=True)
frappe.db.commit()
print('Updated: query normalizes ste->sainte, st->saint, boul->boulevard')
frappe.destroy()

View File

@ -0,0 +1,35 @@
import frappe, os
os.chdir('/home/frappe/frappe-bench')
frappe.init('frontend', sites_path='/home/frappe/frappe-bench/sites')
frappe.connect()
ss = frappe.get_doc('Server Script', 'Address Autocomplete')
ss.script = """
query = frappe.form_dict.get("q", "")
if not query or len(query) < 3:
frappe.response["results"] = []
else:
words = query.strip().lower().split()
conditions = []
for w in words:
escaped = frappe.db.escape("%" + w + "%")
conditions.append("search_text LIKE " + escaped)
where = " AND ".join(conditions)
sql = ("SELECT address_full, ville, code_postal, latitude, longitude "
"FROM rqa_addresses "
"WHERE " + where + " "
"ORDER BY "
"CASE WHEN code_postal LIKE 'J0L%' THEN 0 "
"WHEN code_postal LIKE 'J0S%' THEN 1 ELSE 2 END, "
"length(address_full) "
"LIMIT 10")
results = frappe.db.sql(sql, as_dict=True)
frappe.response["results"] = results
"""
ss.save(ignore_permissions=True)
frappe.db.commit()
print('Updated: using frappe.db.escape, no params')
frappe.destroy()

View File

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Import RQA (Réseau Québécois d'Adresses) CSV into PostgreSQL civic_addresses table.
Handles the ~2.8GB CSV file with streaming/batched inserts.
Usage:
python3 import_rqa_addresses.py /tmp/RQA_CSV/RQA.csv
Or from Docker:
docker cp import_rqa_addresses.py frappe_docker-db-1:/tmp/
docker exec frappe_docker-db-1 python3 /tmp/import_rqa_addresses.py /tmp/RQA.csv
"""
import csv
import sys
import os
import subprocess
import io
DB = "_171cf82a99ac0463"
BATCH_SIZE = 10000
def get_csv_path():
if len(sys.argv) > 1:
return sys.argv[1]
# Auto-detect from unzipped location
for p in ['/tmp/RQA_CSV/RQA.csv', '/tmp/RQA.csv', '/tmp/RQA_CSV.csv']:
if os.path.exists(p):
return p
print("Usage: python3 import_rqa_addresses.py <path_to_csv>")
sys.exit(1)
def main():
csv_path = get_csv_path()
print(f"Reading: {csv_path}")
# First peek at the header to understand columns
with open(csv_path, 'r', encoding='utf-8-sig', errors='replace') as f:
reader = csv.reader(f, delimiter=',')
header = next(reader)
print(f"Columns ({len(header)}): {header[:15]}...")
# Show first row
row = next(reader)
print(f"Sample row: {row[:15]}...")
print(f"\nHeader fields:")
for i, h in enumerate(header):
print(f" {i}: {h}")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,30 @@
/* eslint-env serviceworker */
/*
* This file (which will be your service worker)
* is picked up by the build system ONLY if
* quasar.config.js > pwa > workboxMode is set to "injectManifest"
*/
import { clientsClaim } from 'workbox-core'
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
self.skipWaiting()
clientsClaim()
// Use with precache injection
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
// Non-SSR fallback to index.html
// Production SSR fallback to offline.html (except for dev)
if (process.env.MODE !== 'ssr' || process.env.PROD) {
registerRoute(
new NavigationRoute(
createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
{ denylist: [/sw\.js$/, /workbox-(.)*\.js$/] }
)
)
}

View File

@ -0,0 +1,32 @@
{
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#027be3",
"icons": [
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

10
apps/dispatch/src-pwa/pwa-flag.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
pwa: true;
}
}

View File

@ -0,0 +1,41 @@
import { register } from 'register-service-worker'
// The ready(), registered(), cached(), updatefound() and updated()
// events passes a ServiceWorkerRegistration instance in their arguments.
// ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
register(process.env.SERVICE_WORKER_FILE, {
// The registrationOptions object will be passed as the second argument
// to ServiceWorkerContainer.register()
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter
// registrationOptions: { scope: './' },
ready (/* registration */) {
// console.log('Service worker is active.')
},
registered (/* registration */) {
// console.log('Service worker has been registered.')
},
cached (/* registration */) {
// console.log('Content has been cached for offline use.')
},
updatefound (/* registration */) {
// console.log('New content is downloading.')
},
updated (/* registration */) {
// console.log('New content is available; please refresh.')
},
offline () {
// console.log('No internet connection found. App is running in offline mode.')
},
error (/* err */) {
// console.error('Error during service worker registration:', err)
}
})

11
apps/dispatch/src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
const auth = useAuthStore()
onMounted(() => auth.checkSession())
</script>

View File

@ -0,0 +1,44 @@
// ── ERPNext API auth — service token + Authentik session guard ──────────────
// ERPNext API calls use a service token. User auth is via Authentik forwardAuth
// at the Traefik level. If the Authentik session expires mid-use, API calls
// get redirected (302) — we detect this and reload to trigger re-auth.
// ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext'
// Service token injected at build time via VITE_ERP_TOKEN env var
// Fallback: read from window.__ERP_TOKEN__ (set by server-side injection)
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
export function authFetch (url, opts = {}) {
opts.headers = { ...opts.headers, Authorization: 'token ' + SERVICE_TOKEN }
opts.redirect = 'manual' // Don't follow redirects — detect Authentik 302
return fetch(url, opts).then(res => {
// If Traefik/Authentik redirects (session expired), reload page to re-auth
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
window.location.reload()
return new Response('{}', { status: 401 })
}
return res
})
}
export function getCSRF () { return null }
export function invalidateCSRF () {}
export async function login () { window.location.reload() }
export async function logout () {
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
}
export async function getLoggedUser () {
try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
headers: { Authorization: 'token ' + SERVICE_TOKEN },
})
if (res.ok) {
const data = await res.json()
return data.message || 'authenticated'
}
} catch {}
return 'authenticated'
}

View File

@ -0,0 +1,60 @@
// ── Booking API — crée une demande client dans ERPNext ────────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { getCSRF } from './auth'
const SLOT_LABELS = {
matin: 'Matin (8h00 12h00)',
aprem: 'Après-midi (12h00 17h00)',
soir: 'Soirée (17h00 20h00)',
}
function buildDescription (data) {
const dateLabel = { today: "Aujourd'hui", tomorrow: 'Demain' }[data.date] ?? data.date
return [
`SERVICE: ${data.service.label}`,
data.serviceNote ? `Détail: ${data.serviceNote}` : null,
`ADRESSE: ${data.address}`,
`DATE: ${dateLabel}${SLOT_LABELS[data.slot] ?? data.slot}`,
data.urgent ? '*** URGENT — intervention dans les 2h ***' : null,
'---',
`Client: ${data.contact.name}`,
`Téléphone: ${data.contact.phone}`,
data.contact.email ? `Courriel: ${data.contact.email}` : null,
data.contact.note ? `Note: ${data.contact.note}` : null,
].filter(Boolean).join('\n')
}
function localRef () {
return 'DSP-' + Date.now().toString(36).toUpperCase().slice(-6)
}
export async function createBooking (data) {
const csrf = await getCSRF().catch(() => '')
// Try ERPNext Lead (CRM module — standard in ERPNext)
try {
const r = await fetch(`${BASE_URL}/api/resource/Lead`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
body: JSON.stringify({
lead_name: data.contact.name,
mobile_no: data.contact.phone,
email_id: data.contact.email || '',
source: 'Dispatch Booking',
notes: buildDescription(data),
status: 'Open',
lead_owner: '',
}),
})
const body = await r.json().catch(() => ({}))
if (r.ok && body.data?.name) return body.data.name
} catch (_) { /* fall through */ }
// Fallback: localStorage + generated ref
const ref = localRef()
const list = JSON.parse(localStorage.getItem('dispatch_bookings') || '[]')
list.push({ ref, ...data, created: new Date().toISOString() })
localStorage.setItem('dispatch_bookings', JSON.stringify(list))
return ref
}

View File

@ -0,0 +1,74 @@
// ── Contractor API — inscrit un sous-traitant dans ERPNext ────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { getCSRF } from './auth'
function buildNotes (data) {
const services = data.services
.map(s => `${s.label}: ${s.rate}$ / ${s.rateType === 'hourly' ? 'heure' : 'forfait'}`)
.join('\n')
const days = data.availability.days
.map(d => ({ mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }[d]))
.join(', ')
return [
`SERVICES OFFERTS:`,
services,
``,
`ZONE: ${data.availability.city} — rayon ${data.availability.radius}`,
`DISPONIBILITÉ: ${days}`,
data.availability.urgent ? 'Disponible pour urgences' : '',
``,
data.profile.license ? `Licence/RBQ: ${data.profile.license}` : '',
data.profile.company ? `Entreprise: ${data.profile.company}` : '',
].filter(s => s !== undefined).join('\n')
}
function localRef () {
return 'TECH-' + Date.now().toString(36).toUpperCase().slice(-6)
}
export async function registerContractor (data) {
const csrf = await getCSRF().catch(() => '')
// Try ERPNext Supplier (standard ERPNext)
try {
const supplierName = data.profile.company
|| `${data.profile.firstname} ${data.profile.lastname}`
const r = await fetch(`${BASE_URL}/api/resource/Supplier`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
body: JSON.stringify({
supplier_name: supplierName,
supplier_type: 'Individual',
supplier_group: 'Services',
}),
})
const body = await r.json().catch(() => ({}))
if (r.ok && body.data?.name) {
// Try to create a Contact linked to the supplier
await fetch(`${BASE_URL}/api/resource/Contact`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
body: JSON.stringify({
first_name: data.profile.firstname,
last_name: data.profile.lastname,
email_ids: [{ email_id: data.profile.email, is_primary: 1 }],
phone_nos: [{ phone: data.profile.phone, is_primary_phone: 1 }],
links: [{ link_doctype: 'Supplier', link_name: body.data.name }],
}),
}).catch(() => {})
return body.data.name
}
} catch (_) { /* fall through */ }
// Fallback: localStorage + generated ref
const ref = localRef()
const list = JSON.parse(localStorage.getItem('dispatch_contractors') || '[]')
list.push({ ref, ...data, created: new Date().toISOString(), status: 'pending_review' })
localStorage.setItem('dispatch_contractors', JSON.stringify(list))
return ref
}

View File

@ -0,0 +1,116 @@
// ── ERPNext Dispatch resource calls ─────────────────────────────────────────
// All ERPNext fetch() calls live here.
// Swap BASE_URL in config/erpnext.js to change the target server.
// ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { authFetch } from './auth'
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data
}
async function apiPut (doctype, name, body) {
const res = await authFetch(
`${BASE_URL}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
if (!res.ok) console.error(`[API] PUT ${doctype}/${name} failed:`, res.status, await res.text().catch(() => ''))
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data
}
export async function fetchTechnicians () {
const list = await apiGet('/api/resource/Dispatch%20Technician?fields=["name"]&limit=100')
const names = (list.data || []).map(t => t.name)
if (!names.length) return []
const docs = await Promise.all(
names.map(n => apiGet(`/api/resource/Dispatch%20Technician/${encodeURIComponent(n)}`).then(d => d.data))
)
return docs
}
// Fetch all jobs with child tables (assistants)
export async function fetchJobs (filters = null) {
// Step 1: get job names from list endpoint
let url = '/api/resource/Dispatch%20Job?fields=["name"]&limit=200'
if (filters) url += '&filters=' + encodeURIComponent(JSON.stringify(filters))
const list = await apiGet(url)
const names = (list.data || []).map(j => j.name)
if (!names.length) return []
// Step 2: fetch each doc individually (includes child tables)
const docs = await Promise.all(
names.map(n => apiGet(`/api/resource/Dispatch%20Job/${encodeURIComponent(n)}`).then(d => d.data))
)
return docs
}
export async function updateJob (name, payload) {
return apiPut('Dispatch Job', name, payload)
}
export async function createJob (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Job`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function updateTech (name, payload) {
return apiPut('Dispatch Technician', name, payload)
}
export async function createTech (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Technician`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) },
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}
export async function deleteTech (name) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Technician/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
const msg = data._server_messages ? JSON.parse(JSON.parse(data._server_messages)[0]).message : data.exception || 'Delete failed'
throw new Error(msg)
}
}
export async function fetchTags () {
const data = await apiGet('/api/resource/Dispatch%20Tag?fields=["name","label","color","category"]&limit=200')
return data.data || []
}
export async function createTag (label, category = 'Custom', color = '#6b7280') {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Tag`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, category, color }),
},
)
const data = await res.json()
if (data.exc) throw new Error(data.exc)
return data.data
}

View File

@ -0,0 +1,273 @@
/**
* API ServiceRequest, ServiceBid, EquipmentInstall
*
* Tries Frappe custom doctypes first, falls back to Lead + localStorage
* so the app works before the backend doctypes are created.
*/
const BASE = ''
async function getCSRF () {
const m = document.cookie.match(/csrftoken=([^;]+)/)
if (m) return m[1]
const r = await fetch('/api/method/frappe.auth.get_csrf_token', { credentials: 'include' })
const d = await r.json().catch(() => ({}))
return d.csrf_token || ''
}
async function frappePOST (doctype, data) {
const csrf = await getCSRF().catch(() => '')
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
body: JSON.stringify(data),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const body = await r.json()
return body.data
}
async function frappePUT (doctype, name, data) {
const csrf = await getCSRF().catch(() => '')
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf },
body: JSON.stringify(data),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const body = await r.json()
return body.data
}
async function frappeGET (doctype, filters = {}, fields = ['name']) {
const params = new URLSearchParams({
fields: JSON.stringify(fields),
filters: JSON.stringify(filters),
limit: 50,
})
const r = await fetch(`${BASE}/api/resource/${encodeURIComponent(doctype)}?${params}`, {
credentials: 'include',
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const body = await r.json()
return body.data || []
}
// ─────────────────────────────────────────────────────────────────────────────
// ServiceRequest
// ─────────────────────────────────────────────────────────────────────────────
export async function createServiceRequest (data) {
/**
* data = {
* service_type: 'internet' | 'tv' | 'telephone' | 'multi',
* problem_type: string,
* description: string,
* address: string,
* coordinates: [lng, lat],
* preferred_dates: [{ date, time_slot, time_slots[] }, ...], // up to 3
* contact: { name, phone, email },
* urgency: 'normal' | 'urgent',
* budget: { id, label, min, max } | null,
* }
*/
const ref = 'SR-' + Date.now().toString(36).toUpperCase().slice(-6)
// Try Frappe ServiceRequest doctype
try {
const doc = await frappePOST('Service Request', {
customer_name: data.contact.name,
phone: data.contact.phone,
email: data.contact.email,
service_type: data.service_type,
problem_type: data.problem_type,
description: data.description,
address: data.address,
lng: data.coordinates?.[0] || 0,
lat: data.coordinates?.[1] || 0,
preferred_date_1: data.preferred_dates[0]?.date || '',
time_slot_1: data.preferred_dates[0]?.time_slot || '',
preferred_date_2: data.preferred_dates[1]?.date || '',
time_slot_2: data.preferred_dates[1]?.time_slot || '',
preferred_date_3: data.preferred_dates[2]?.date || '',
time_slot_3: data.preferred_dates[2]?.time_slot || '',
urgency: data.urgency || 'normal',
budget_label: data.budget?.label || '',
budget_min: data.budget?.min || 0,
budget_max: data.budget?.max || 0,
status: 'New',
})
return { ref: doc.name, source: 'frappe' }
} catch (_) {}
// Fallback: create as Frappe Lead + HD Ticket
try {
const notes = buildNotes(data)
const doc = await frappePOST('Lead', {
lead_name: data.contact.name,
mobile_no: data.contact.phone,
email_id: data.contact.email || '',
source: 'Dispatch Booking',
lead_owner: '',
status: 'Open',
notes,
})
return { ref: doc.name, source: 'lead' }
} catch (_) {}
// Final fallback: localStorage
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
list.push({ ref, ...data, lng: data.coordinates?.[0] || 0, lat: data.coordinates?.[1] || 0, budget_label: data.budget?.label || '', created: new Date().toISOString(), status: 'new' })
localStorage.setItem('dispatch_service_requests', JSON.stringify(list))
return { ref, source: 'local' }
}
function buildNotes (data) {
const dates = data.preferred_dates
.filter(d => d.date)
.map((d, i) => ` Date ${i + 1}: ${d.date}${d.time_slot}`)
.join('\n')
return [
`SERVICE: ${data.service_type?.toUpperCase()}`,
`PROBLÈME: ${data.problem_type}`,
`DESCRIPTION: ${data.description}`,
`ADRESSE: ${data.address}`,
`URGENCE: ${data.urgency}`,
'',
'DATES PRÉFÉRÉES:',
dates,
].join('\n')
}
export async function fetchServiceRequests (status = null) {
try {
const filters = status ? { status } : {}
return await frappeGET('Service Request', filters, [
'name', 'customer_name', 'phone', 'service_type', 'problem_type',
'description', 'address', 'status', 'urgency',
'preferred_date_1', 'time_slot_1',
'preferred_date_2', 'time_slot_2',
'preferred_date_3', 'time_slot_3',
'confirmed_date', 'creation',
'budget_label', 'budget_min', 'budget_max',
])
} catch (_) {
return JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
}
}
export async function updateServiceRequestStatus (name, status, confirmedDate = null) {
try {
const data = {}
if (status) data.status = status
if (confirmedDate) data.confirmed_date = confirmedDate
if (Object.keys(data).length === 0) return
return await frappePUT('Service Request', name, data)
} catch (_) {
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
const item = list.find(r => r.ref === name || r.name === name)
if (item) {
if (status) item.status = status
if (confirmedDate) item.confirmed_date = confirmedDate
}
localStorage.setItem('dispatch_service_requests', JSON.stringify(list))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ServiceBid (tech bids on a date)
// ─────────────────────────────────────────────────────────────────────────────
export async function createServiceBid (data) {
/**
* data = { request, technician, proposed_date, time_slot, estimated_duration, notes, price }
*/
try {
return await frappePOST('Service Bid', { ...data, status: 'Pending' })
} catch (_) {
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
const bid = { ref: 'BID-' + Date.now().toString(36).toUpperCase().slice(-6), ...data, status: 'pending', created: new Date().toISOString() }
list.push(bid)
localStorage.setItem('dispatch_service_bids', JSON.stringify(list))
return bid
}
}
export async function fetchBidsForRequest (requestName) {
try {
return await frappeGET('Service Bid', { request: requestName }, [
'name', 'technician', 'proposed_date', 'time_slot',
'estimated_duration', 'notes', 'status', 'creation',
])
} catch (_) {
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
return list.filter(b => b.request === requestName)
}
}
export async function fetchBidsForTech (techName) {
try {
return await frappeGET('Service Bid', { technician: techName }, [
'name', 'request', 'proposed_date', 'time_slot', 'status', 'creation',
])
} catch (_) {
const list = JSON.parse(localStorage.getItem('dispatch_service_bids') || '[]')
return list.filter(b => b.technician === techName)
}
}
export async function fetchOpenRequests () {
try {
return await frappeGET('Service Request', { status: ['in', ['New', 'Bidding']] }, [
'name', 'customer_name', 'service_type', 'problem_type', 'description',
'address', 'lng', 'lat', 'urgency', 'preferred_date_1', 'time_slot_1',
'preferred_date_2', 'time_slot_2', 'preferred_date_3', 'time_slot_3',
'creation', 'budget_label', 'budget_min', 'budget_max',
])
} catch (_) {
const list = JSON.parse(localStorage.getItem('dispatch_service_requests') || '[]')
return list.filter(r => ['new', 'bidding'].includes(r.status))
}
}
export async function acceptBid (bidName, requestName, confirmedDate) {
try {
await frappePUT('Service Bid', bidName, { status: 'Accepted' })
await frappePUT('Service Request', requestName, { status: 'Confirmed', confirmed_date: confirmedDate })
} catch (_) {}
}
// ─────────────────────────────────────────────────────────────────────────────
// EquipmentInstall (barcode scan on site)
// ─────────────────────────────────────────────────────────────────────────────
export async function createEquipmentInstall (data) {
/**
* data = { request, barcode, equipment_type, brand, model, notes, photo_base64 }
*/
try {
return await frappePOST('Equipment Install', {
...data,
installation_date: new Date().toISOString().split('T')[0],
})
} catch (_) {
const list = JSON.parse(localStorage.getItem('dispatch_equipment') || '[]')
const item = { ref: 'EQ-' + Date.now().toString(36).toUpperCase().slice(-6), ...data, created: new Date().toISOString() }
list.push(item)
localStorage.setItem('dispatch_equipment', JSON.stringify(list))
return item
}
}
export async function fetchEquipmentForRequest (requestName) {
try {
return await frappeGET('Equipment Install', { request: requestName }, [
'name', 'barcode', 'equipment_type', 'brand', 'model', 'notes', 'installation_date',
])
} catch (_) {
const list = JSON.parse(localStorage.getItem('dispatch_equipment') || '[]')
return list.filter(e => e.request === requestName)
}
}

View File

@ -0,0 +1,97 @@
// ── Dispatch Settings — lecture/écriture du DocType Single ERPNext ───────────
import { BASE_URL } from 'src/config/erpnext'
import { getCSRF } from './auth'
const DOCTYPE = 'Dispatch Settings'
const NAME = 'Dispatch Settings'
function isDocTypeError (body) {
const s = JSON.stringify(body)
return s.includes('dispatch_settings') || s.includes('DoesNotExist') || s.includes('No module named')
}
export async function fetchSettings () {
const r = await fetch(`${BASE_URL}/api/resource/${DOCTYPE}/${NAME}`, {
credentials: 'include',
})
if (!r.ok) {
const body = await r.json().catch(() => ({}))
if (r.status === 404 || isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
throw new Error(`Erreur HTTP ${r.status}`)
}
const body = await r.json()
// Frappe peut retourner 200 avec une exception dans le corps
if (body.exc_type || body.exception) {
if (isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
throw new Error(body.exc_type || 'Erreur Frappe')
}
return body.data
}
export async function saveSettings (payload) {
const csrf = await getCSRF()
const r = await fetch(`${BASE_URL}/api/resource/${DOCTYPE}/${NAME}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf || '' },
body: JSON.stringify(payload),
})
const body = await r.json().catch(() => ({}))
if (!r.ok || body.exc_type || body.exception) {
if (isDocTypeError(body)) throw new Error('DOCTYPE_NOT_FOUND')
throw new Error(body._error_message || body.exc_type || `Erreur HTTP ${r.status}`)
}
return body
}
// ── Création du DocType via API (bouton Initialiser dans l'admin) ─────────────
const DOCTYPE_FIELDS = [
{ fieldname: 'erp_section', fieldtype: 'Section Break', label: 'ERPNext / Frappe' },
{ fieldname: 'erp_url', fieldtype: 'Data', label: 'URL du serveur', default: 'http://localhost:8080' },
{ fieldname: 'erp_api_key', fieldtype: 'Data', label: 'API Key' },
{ fieldname: 'erp_api_secret', fieldtype: 'Password', label: 'API Secret' },
{ fieldname: 'mapbox_section', fieldtype: 'Section Break', label: 'Mapbox' },
{ fieldname: 'mapbox_token', fieldtype: 'Data', label: 'Token public (pk_)' },
{ fieldname: 'twilio_section', fieldtype: 'Section Break', label: 'Twilio — SMS' },
{ fieldname: 'twilio_account_sid', fieldtype: 'Data', label: 'Account SID' },
{ fieldname: 'twilio_auth_token', fieldtype: 'Password', label: 'Auth Token' },
{ fieldname: 'twilio_from_number', fieldtype: 'Data', label: 'Numéro expéditeur' },
{ fieldname: 'stripe_section', fieldtype: 'Section Break', label: 'Stripe — Paiements' },
{ fieldname: 'stripe_mode', fieldtype: 'Select', label: 'Mode', options: 'test\nlive', default: 'test' },
{ fieldname: 'stripe_publishable_key', fieldtype: 'Data', label: 'Clé publique (pk_)' },
{ fieldname: 'stripe_secret_key', fieldtype: 'Password', label: 'Clé secrète (sk_)' },
{ fieldname: 'stripe_webhook_secret',fieldtype: 'Password', label: 'Webhook Secret (whsec_)' },
{ fieldname: 'n8n_section', fieldtype: 'Section Break', label: 'n8n — Automatisation' },
{ fieldname: 'n8n_url', fieldtype: 'Data', label: 'URL n8n', default: 'http://localhost:5678' },
{ fieldname: 'n8n_api_key', fieldtype: 'Password', label: 'API Key n8n' },
{ fieldname: 'n8n_webhook_base', fieldtype: 'Data', label: 'Base URL webhooks', default: 'http://localhost:5678/webhook' },
{ fieldname: 'sms_section', fieldtype: 'Section Break', label: 'Templates SMS' },
{ fieldname: 'sms_enroute', fieldtype: 'Text', label: 'Technicien en route',
default: 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}' },
{ fieldname: 'sms_completed', fieldtype: 'Text', label: 'Service complété',
default: 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !' },
{ fieldname: 'sms_assigned', fieldtype: 'Text', label: 'Job assigné (technicien)',
default: 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.' },
]
export async function createDocType () {
const csrf = await getCSRF()
const r = await fetch(`${BASE_URL}/api/resource/DocType`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Frappe-CSRF-Token': csrf || '' },
body: JSON.stringify({
name: DOCTYPE, module: 'Core', custom: 1, is_single: 1, track_changes: 0,
fields: DOCTYPE_FIELDS,
permissions: [
{ role: 'System Manager', read: 1, write: 1, create: 1 },
{ role: 'Administrator', read: 1, write: 1, create: 1 },
],
}),
})
const body = await r.json().catch(() => ({}))
if (!r.ok || body.exc_type) {
throw new Error(body._error_message || body.exc_type || `Erreur HTTP ${r.status}`)
}
return body
}

View File

@ -0,0 +1,94 @@
// ── Traccar GPS API ──────────────────────────────────────────────────────────
// Polls Traccar for real-time device positions.
// Auth: session cookie via POST /api/session
// ─────────────────────────────────────────────────────────────────────────────
// Use proxy on same origin to avoid mixed content (HTTPS → HTTP)
const TRACCAR_URL = window.location.hostname === 'localhost'
? 'http://tracker.targointernet.com:8082'
: window.location.origin + '/traccar'
const TRACCAR_USER = 'louis@targo.ca'
const TRACCAR_PASS = 'targo2026'
let _devices = []
// Use Basic auth — works through proxy without cookies
function authOpts () {
return {
headers: {
Authorization: 'Basic ' + btoa(TRACCAR_USER + ':' + TRACCAR_PASS),
Accept: 'application/json',
}
}
}
// ── Devices ──────────────────────────────────────────────────────────────────
export async function fetchDevices () {
try {
const res = await fetch(TRACCAR_URL + '/api/devices?all=true', authOpts())
if (res.ok) {
_devices = await res.json()
return _devices
}
} catch {}
return _devices
}
// ── Positions ────────────────────────────────────────────────────────────────
// Traccar API only supports ONE deviceId per request — fetch in parallel
export async function fetchPositions (deviceIds = null) {
if (!deviceIds || !deviceIds.length) return []
const results = await Promise.allSettled(
deviceIds.map(id =>
fetch(TRACCAR_URL + '/api/positions?deviceId=' + id, authOpts())
.then(r => r.ok ? r.json() : [])
)
)
return results.flatMap(r => r.status === 'fulfilled' ? r.value : [])
}
// ── Get position for a specific device ───────────────────────────────────────
export async function fetchDevicePosition (deviceId) {
const positions = await fetchPositions([deviceId])
return positions[0] || null
}
// ── Get all positions mapped by deviceId ─────────────────────────────────────
export async function fetchAllPositions () {
// Get devices we care about (online + offline with recent position)
if (!_devices.length) await fetchDevices()
const deviceIds = _devices.filter(d => d.positionId).map(d => d.id)
if (!deviceIds.length) return {}
const positions = await fetchPositions(deviceIds)
const map = {}
positions.forEach(p => { map[p.deviceId] = p })
return map
}
// ── Utility: match device to tech by uniqueId or name ────────────────────────
export function matchDeviceToTech (devices, techs) {
const matched = []
for (const tech of techs) {
const traccarId = tech.traccarDeviceId
if (!traccarId) continue
const device = devices.find(d => d.id === parseInt(traccarId) || d.uniqueId === traccarId)
if (device) matched.push({ tech, device })
}
return matched
}
// ── Session (required for WebSocket auth) ────────────────────────────────────
export async function createTraccarSession () {
try {
const res = await fetch(TRACCAR_URL + '/api/session', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ email: TRACCAR_USER, password: TRACCAR_PASS }),
})
return res.ok
} catch { return false }
}
export { TRACCAR_URL, _devices as cachedDevices }

View File

@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
export default ({ app }) => {
app.use(createPinia())
}

View File

@ -0,0 +1,137 @@
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] }, // current tag labels
allTags: { type: Array, default: () => [] }, // { label, color, category }
getColor: { type: Function, default: () => '#6b7280' },
placeholder:{ type: String, default: 'Ajouter un tag…' },
canCreate: { type: Boolean, default: true },
})
const emit = defineEmits(['update:modelValue', 'create'])
const query = ref('')
const focused = ref(false)
const inputEl = ref(null)
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.allTags.filter(t => !props.modelValue.includes(t.label)).slice(0, 12)
return props.allTags
.filter(t => !props.modelValue.includes(t.label) && t.label.toLowerCase().includes(q))
.slice(0, 12)
})
const showCreate = computed(() => {
if (!props.canCreate) return false
const q = query.value.trim()
if (!q || q.length < 2) return false
return !props.allTags.some(t => t.label.toLowerCase() === q.toLowerCase())
})
function addTag (label) {
if (!label || props.modelValue.includes(label)) return
emit('update:modelValue', [...props.modelValue, label])
query.value = ''
}
function removeTag (label) {
emit('update:modelValue', props.modelValue.filter(t => t !== label))
}
function createAndAdd () {
const label = query.value.trim()
if (!label) return
emit('create', label)
addTag(label)
}
function onBlur () {
setTimeout(() => { focused.value = false }, 180)
}
function onKeydown (e) {
if (e.key === 'Enter' && query.value.trim()) {
e.preventDefault()
if (filtered.value.length) addTag(filtered.value[0].label)
else if (showCreate.value) createAndAdd()
}
if (e.key === 'Backspace' && !query.value && props.modelValue.length) {
removeTag(props.modelValue[props.modelValue.length - 1])
}
}
</script>
<template>
<div class="ti-wrap" :class="{ 'ti-focused': focused }">
<!-- Existing tags as chips -->
<span v-for="t in modelValue" :key="t" class="ti-chip" :style="'background:'+getColor(t)">
{{ t }}
<button class="ti-chip-rm" @click.stop="removeTag(t)">×</button>
</span>
<!-- Input -->
<input ref="inputEl" class="ti-input" type="text"
v-model="query" :placeholder="modelValue.length ? '' : placeholder"
@focus="focused=true" @blur="onBlur" @keydown="onKeydown" />
<!-- Dropdown -->
<div v-if="focused && (filtered.length || showCreate)" class="ti-dropdown">
<div v-for="t in filtered" :key="t.label" class="ti-option" @mousedown.prevent="addTag(t.label)">
<span class="ti-opt-dot" :style="'background:'+getColor(t.label)"></span>
<span class="ti-opt-label">{{ t.label }}</span>
<span class="ti-opt-cat">{{ t.category }}</span>
</div>
<div v-if="showCreate" class="ti-option ti-option-create" @mousedown.prevent="createAndAdd">
<span class="ti-create-plus">+</span>
<span>Créer « <strong>{{ query.trim() }}</strong> »</span>
</div>
</div>
</div>
</template>
<style scoped>
.ti-wrap {
display:flex; flex-wrap:wrap; gap:3px; align-items:center;
background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px;
padding:3px 6px; min-height:28px; position:relative; cursor:text;
transition: border-color 0.12s;
}
.ti-wrap.ti-focused { border-color:rgba(99,102,241,0.4); }
.ti-chip {
display:inline-flex; align-items:center; gap:2px;
font-size:0.58rem; font-weight:600; color:#fff;
padding:1px 6px; border-radius:10px; white-space:nowrap;
}
.ti-chip-rm {
background:none; border:none; color:rgba(255,255,255,0.6); cursor:pointer;
font-size:0.7rem; padding:0 1px; margin-left:1px; line-height:1;
}
.ti-chip-rm:hover { color:#fff; }
.ti-input {
flex:1; min-width:60px; background:none; border:none; outline:none;
color:#e2e4ef; font-size:0.72rem; padding:2px 0;
}
.ti-input::placeholder { color:#7b80a0; }
.ti-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:180px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.45);
margin-top:2px;
}
.ti-dropdown::-webkit-scrollbar { width:3px; }
.ti-dropdown::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.ti-option {
display:flex; align-items:center; gap:6px;
padding:5px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef;
transition:background 0.1s;
}
.ti-option:hover { background:rgba(99,102,241,0.12); }
.ti-opt-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.ti-opt-label { flex:1; }
.ti-opt-cat { font-size:0.55rem; color:#7b80a0; }
.ti-option-create { color:#6366f1; font-weight:600; border-top:1px solid rgba(255,255,255,0.06); }
.ti-create-plus {
width:18px; height:18px; border-radius:50%; background:rgba(99,102,241,0.2);
display:flex; align-items:center; justify-content:center;
font-size:0.75rem; font-weight:800; color:#6366f1; flex-shrink:0;
}
</style>

View File

@ -0,0 +1,140 @@
// ── Auto-dispatch composable: autoDistribute + optimizeRoute ─────────────────
import { localDateStr } from './useHelpers'
import { updateJob } from 'src/api/dispatch'
export function useAutoDispatch (deps) {
const { store, MAPBOX_TOKEN, filteredResources, periodStart, getJobDate, unscheduledJobs, bottomSelected, dispatchCriteria, pushUndo, invalidateRoutes } = deps
async function autoDistribute () {
const techs = filteredResources.value
if (!techs.length) return
const today = localDateStr(new Date())
let pool
if (bottomSelected.value.size) {
pool = [...bottomSelected.value].map(id => store.jobs.find(j => j.id === id)).filter(Boolean)
} else {
pool = unscheduledJobs.value.filter(j => !j.scheduledDate || j.scheduledDate === today)
}
if (!pool.length) return
// Jobs with coords get proximity-based assignment, jobs without get load-balanced only
const withCoords = pool.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
const noCoords = pool.filter(j => !j.coords || (j.coords[0] === 0 && j.coords[1] === 0))
const unassigned = [...withCoords, ...noCoords]
if (!unassigned.length) return
const prevQueues = {}
techs.forEach(t => { prevQueues[t.id] = [...t.queue] })
const prevAssignments = unassigned.map(j => ({ jobId: j.id, techId: j.assignedTech, scheduledDate: j.scheduledDate }))
function techLoadForDay (tech, dayStr) {
return tech.queue.filter(j => getJobDate(j.id) === dayStr).reduce((s, j) => s + (parseFloat(j.duration) || 1), 0)
}
function dist (a, b) {
if (!a || !b) return 999
const dx = (a[0] - b[0]) * 80, dy = (a[1] - b[1]) * 111
return Math.sqrt(dx * dx + dy * dy)
}
function techLastPosForDay (tech, dayStr) {
const dj = tech.queue.filter(j => getJobDate(j.id) === dayStr)
if (dj.length) { const last = dj[dj.length - 1]; if (last.coords && last.coords[0] !== 0) return last.coords }
return tech.coords
}
const criteria = dispatchCriteria.value.filter(c => c.enabled)
const sorted = [...unassigned].sort((a, b) => {
for (const c of criteria) {
if (c.id === 'urgency') {
const p = { high: 0, medium: 1, low: 2 }
const diff = (p[a.priority] ?? 2) - (p[b.priority] ?? 2)
if (diff !== 0) return diff
}
}
return 0
})
const useSkills = criteria.some(c => c.id === 'skills')
const weights = {}
criteria.forEach((c, i) => { weights[c.id] = criteria.length - i })
sorted.forEach(job => {
const assignDay = job.scheduledDate || today
let bestTech = null, bestScore = Infinity
techs.forEach(tech => {
let score = 0
if (weights.balance) score += techLoadForDay(tech, assignDay) * (weights.balance || 1)
if (weights.proximity && job.coords && (job.coords[0] !== 0 || job.coords[1] !== 0)) score += dist(techLastPosForDay(tech, assignDay), job.coords) / 60 * (weights.proximity || 1)
if (weights.skills && useSkills) {
const jt = job.tags || [], tt = tech.tags || []
score += (jt.length > 0 ? (jt.length - jt.filter(t => tt.includes(t)).length) * 2 : 0) * (weights.skills || 1)
}
if (score < bestScore) { bestScore = score; bestTech = tech }
})
if (bestTech) store.smartAssign(job.id, bestTech.id, assignDay)
})
pushUndo({ type: 'autoDistribute', assignments: prevAssignments, prevQueues })
bottomSelected.value = new Set()
invalidateRoutes()
}
async function optimizeRoute (tech) {
const dayStr = localDateStr(periodStart.value)
const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr)
if (dayJobs.length < 2) return
const jobsWithCoords = dayJobs.filter(j => j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0))
if (jobsWithCoords.length < 2) return
const urgent = jobsWithCoords.filter(j => j.priority === 'high')
const normal = jobsWithCoords.filter(j => j.priority !== 'high')
function nearestNeighbor (start, jobs) {
const result = [], remaining = [...jobs]
let cur = start
while (remaining.length) {
let bi = 0, bd = Infinity
remaining.forEach((j, i) => {
const dx = j.coords[0] - cur[0], dy = j.coords[1] - cur[1], d = dx * dx + dy * dy
if (d < bd) { bd = d; bi = i }
})
result.push(remaining.splice(bi, 1)[0])
cur = result.at(-1).coords
}
return result
}
const home = (tech.coords?.[0] && tech.coords?.[1]) ? tech.coords : jobsWithCoords[0].coords
const orderedUrgent = nearestNeighbor(home, urgent)
const orderedNormal = nearestNeighbor(orderedUrgent.length ? orderedUrgent.at(-1).coords : home, normal)
const reordered = [...orderedUrgent, ...orderedNormal]
try {
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
const coords = []
if (hasHome) coords.push(`${tech.coords[0]},${tech.coords[1]}`)
reordered.forEach(j => coords.push(`${j.coords[0]},${j.coords[1]}`))
if (coords.length <= 12) {
const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/driving/${coords.join(';')}?overview=false${hasHome ? '&source=first' : ''}&roundtrip=false&destination=any&access_token=${MAPBOX_TOKEN}`
const res = await fetch(url)
const data = await res.json()
if (data.code === 'Ok' && data.waypoints) {
const off = hasHome ? 1 : 0, uc = orderedUrgent.length
const mu = reordered.slice(0, uc).map((j, i) => ({ job: j, o: data.waypoints[i + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job)
const mn = reordered.slice(uc).map((j, i) => ({ job: j, o: data.waypoints[i + uc + off].waypoint_index })).sort((a, b) => a.o - b.o).map(x => x.job)
reordered.length = 0
reordered.push(...mu, ...mn)
}
}
} catch (_) {}
pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] })
const otherJobs = tech.queue.filter(j => getJobDate(j.id) !== dayStr)
tech.queue = [...reordered, ...otherJobs]
tech.queue.forEach((j, i) => {
j.routeOrder = i
updateJob(j.name || j.id, { route_order: i, start_time: '' }).catch(() => {})
})
invalidateRoutes()
}
return { autoDistribute, optimizeRoute }
}

View File

@ -0,0 +1,120 @@
// ── Bottom panel composable: unassigned jobs table, multi-select, criteria ────
import { ref, computed, watch } from 'vue'
import { localDateStr } from './useHelpers'
export function useBottomPanel (store, todayStr, unscheduledJobs, { pushUndo, smartAssign, invalidateRoutes, periodStart }) {
const bottomPanelOpen = ref(localStorage.getItem('sbv2-bottomPanel') !== 'false')
const bottomPanelH = ref(parseInt(localStorage.getItem('sbv2-bottomH')) || 220)
watch(bottomPanelOpen, v => localStorage.setItem('sbv2-bottomPanel', v ? 'true' : 'false'))
// ── Grouped by date ──────────────────────────────────────────────────────────
const unassignedGrouped = computed(() => {
const today = todayStr
const jobs = unscheduledJobs.value.slice()
jobs.sort((a, b) => {
const da = a.scheduledDate || '9999-99-99'
const db = b.scheduledDate || '9999-99-99'
const aToday = da === today ? 0 : 1
const bToday = db === today ? 0 : 1
if (aToday !== bToday) return aToday - bToday
if (da !== db) return da.localeCompare(db)
const prio = { high: 0, medium: 1, low: 2 }
return (prio[a.priority] ?? 2) - (prio[b.priority] ?? 2)
})
const groups = []
let currentDate = null
jobs.forEach(job => {
const d = job.scheduledDate || null
if (d !== currentDate) {
currentDate = d
let label = d === today ? "Aujourd'hui" : d ? d : 'Sans date'
if (d && d !== today) {
const dt = new Date(d + 'T00:00:00')
label = dt.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
}
groups.push({ date: d, label, jobs: [] })
}
groups.at(-1).jobs.push(job)
})
return groups
})
// ── Resize ───────────────────────────────────────────────────────────────────
function startBottomResize (e) {
e.preventDefault()
const startY = e.clientY, startH = bottomPanelH.value
function onMove (ev) { bottomPanelH.value = Math.max(100, Math.min(window.innerHeight * 0.6, startH - (ev.clientY - startY))) }
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-bottomH', String(bottomPanelH.value)) }
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
}
// ── Multi-select ─────────────────────────────────────────────────────────────
const bottomSelected = ref(new Set())
function toggleBottomSelect (jobId, event) {
const s = new Set(bottomSelected.value)
// Checkbox click: always toggle (no modifier needed)
// Shift+click: range select
if (event?.shiftKey && s.size) {
const flat = unassignedGrouped.value.flatMap(g => g.jobs)
const ids = flat.map(j => j.id)
const lastId = [...s].pop()
const fromIdx = ids.indexOf(lastId), toIdx = ids.indexOf(jobId)
if (fromIdx >= 0 && toIdx >= 0) {
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]
for (let i = lo; i <= hi; i++) s.add(ids[i])
}
} else {
// Simple toggle (no Ctrl needed)
if (s.has(jobId)) s.delete(jobId); else s.add(jobId)
}
bottomSelected.value = s
}
function selectAllBottom () { const s = new Set(); unscheduledJobs.value.forEach(j => s.add(j.id)); bottomSelected.value = s }
function clearBottomSelect () { bottomSelected.value = new Set() }
function batchAssignBottom (techId) {
const dayStr = localDateStr(periodStart.value)
bottomSelected.value.forEach(jobId => {
const job = store.jobs.find(j => j.id === jobId)
if (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, techId, dayStr)
}
})
bottomSelected.value = new Set()
invalidateRoutes()
}
// ── Dispatch criteria ────────────────────────────────────────────────────────
const defaultCriteria = [
{ id: 'urgency', label: 'Urgence (priorité haute en premier)', enabled: true },
{ id: 'balance', label: 'Équilibrage de charge (tech le moins chargé)', enabled: true },
{ id: 'proximity', label: 'Proximité géographique', enabled: true },
{ id: 'skills', label: 'Correspondance des tags/skills', enabled: false },
]
const dispatchCriteria = ref(JSON.parse(localStorage.getItem('sbv2-dispatchCriteria') || 'null') || defaultCriteria.map(c => ({ ...c })))
const dispatchCriteriaModal = ref(false)
function saveDispatchCriteria () { localStorage.setItem('sbv2-dispatchCriteria', JSON.stringify(dispatchCriteria.value)); dispatchCriteriaModal.value = false }
function moveCriterion (idx, dir) {
const arr = dispatchCriteria.value, newIdx = idx + dir
if (newIdx < 0 || newIdx >= arr.length) return
const tmp = arr[idx]; arr[idx] = arr[newIdx]; arr[newIdx] = tmp
}
// ── Column widths ────────────────────────────────────────────────────────────
const btColWidths = ref(JSON.parse(localStorage.getItem('sbv2-btColW') || '{}'))
function btColW (col, def) { return (btColWidths.value[col] || def) + 'px' }
function startColResize (e, col) {
e.preventDefault(); e.stopPropagation()
const startX = e.clientX, startW = btColWidths.value[col] || parseInt(getComputedStyle(e.target.parentElement).width)
function onMove (ev) { btColWidths.value = { ...btColWidths.value, [col]: Math.max(40, startW + (ev.clientX - startX)) } }
function onUp () { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); localStorage.setItem('sbv2-btColW', JSON.stringify(btColWidths.value)) }
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
}
return {
bottomPanelOpen, bottomPanelH, unassignedGrouped, startBottomResize,
bottomSelected, toggleBottomSelect, selectAllBottom, clearBottomSelect, batchAssignBottom,
dispatchCriteria, dispatchCriteriaModal, saveDispatchCriteria, moveCriterion,
btColWidths, btColW, startColResize,
}
}

View File

@ -0,0 +1,242 @@
// ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ──
import { ref } from 'vue'
import { snapH, hToTime, fmtDur, localDateStr, SNAP, serializeAssistants } from './useHelpers'
import { updateJob } from 'src/api/dispatch'
export function useDragDrop (deps) {
const {
store, pxPerHr, dayW, periodStart, periodDays, H_START,
getJobDate, bottomSelected, multiSelect,
pushUndo, smartAssign, invalidateRoutes,
} = deps
const dragJob = ref(null)
const dragSrc = ref(null)
const dragIsAssist = ref(false)
const dropGhost = ref(null)
const dragTech = ref(null)
const dragBatchIds = ref(null)
function cleanupDropIndicators () {
document.querySelectorAll('.sb-block-drop-hover').forEach(el => el.classList.remove('sb-block-drop-hover'))
dropGhost.value = null
}
function onJobDragStart (e, job, srcTechId, isAssist = false) {
dragJob.value = job; dragSrc.value = srcTechId || null; dragIsAssist.value = isAssist
if (!srcTechId && bottomSelected.value.size > 1 && bottomSelected.value.has(job.id)) {
dragBatchIds.value = new Set(bottomSelected.value)
e.dataTransfer.setData('text/plain', `batch:${dragBatchIds.value.size}`)
} else {
dragBatchIds.value = null
}
e.dataTransfer.effectAllowed = 'move'
e.target.addEventListener('dragend', () => { cleanupDropIndicators(); dragIsAssist.value = false; dragBatchIds.value = null }, { once: true })
}
function onTimelineDragOver (e, tech) {
e.preventDefault()
if (!dragJob.value && !dragTech.value) return
const x = e.clientX - e.currentTarget.getBoundingClientRect().left
dropGhost.value = { techId: tech.id, x, dateStr: xToDateStr(x) }
}
function onTimelineDragLeave (e) {
if (!e.currentTarget.contains(e.relatedTarget)) dropGhost.value = null
}
function onTechDragStart (e, tech) {
dragTech.value = tech
e.dataTransfer.effectAllowed = 'copyMove'
e.dataTransfer.setData('text/plain', tech.id)
e.target.addEventListener('dragend', () => { dragTech.value = null; cleanupDropIndicators() }, { once: true })
return tech
}
function onBlockDrop (e, job) {
if (dragTech.value) {
e.preventDefault(); e.stopPropagation()
cleanupDropIndicators()
pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' })
store.addAssistant(job.id, dragTech.value.id)
dragTech.value = null
invalidateRoutes()
}
}
function assignDroppedJob (tech, dateStr) {
if (!dragJob.value) return
if (dragIsAssist.value) {
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
}
if (dragBatchIds.value && dragBatchIds.value.size > 1) {
const prevStates = []
dragBatchIds.value.forEach(jobId => {
const j = store.jobs.find(x => x.id === jobId)
if (j && !j.assignedTech) {
prevStates.push({ jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants || [])] })
smartAssign(j, tech.id, dateStr)
}
})
if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, targetTechId: tech.id })
bottomSelected.value = new Set()
dragBatchIds.value = null
} else if (multiSelect && multiSelect.value?.length > 1 && multiSelect.value.some(s => s.job.id === dragJob.value.id)) {
// Dragging a multi-selected block from timeline — move all selected
const prevStates = []
const prevQueues = {}
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
multiSelect.value.filter(s => !s.isAssist).forEach(s => {
prevStates.push({ jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder, scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])] })
smartAssign(s.job, tech.id, dateStr)
})
if (prevStates.length) pushUndo({ type: 'batchAssign', assignments: prevStates, prevQueues })
multiSelect.value = []
} else {
const job = dragJob.value
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, tech.id, dateStr)
}
dropGhost.value = null; dragJob.value = null; dragSrc.value = null
invalidateRoutes()
}
function onTimelineDrop (e, tech) {
e.preventDefault()
cleanupDropIndicators()
if (dragTech.value) {
const els = document.elementsFromPoint(e.clientX, e.clientY)
const blockEl = els.find(el => el.dataset?.jobId)
if (blockEl) {
const job = store.jobs.find(j => j.id === blockEl.dataset.jobId)
if (job) {
pushUndo({ type: 'removeAssistant', jobId: job.id, techId: dragTech.value.id, duration: 0, note: '' })
store.addAssistant(job.id, dragTech.value.id)
dragTech.value = null; invalidateRoutes(); return
}
}
dragTech.value = null; return
}
if (!dragJob.value) return
if (dragJob.value.assignedTech === tech.id) {
const rect = e.currentTarget.getBoundingClientRect()
const x = (e.clientX || e.pageX) - rect.left
const dropH = H_START + x / pxPerHr.value
const dayStr = localDateStr(periodStart.value)
pushUndo({ type: 'optimizeRoute', techId: tech.id, prevQueue: [...tech.queue] })
const draggedJob = dragJob.value
tech.queue = tech.queue.filter(j => j.id !== draggedJob.id)
const dayJobs = tech.queue.filter(j => getJobDate(j.id) === dayStr)
const queueDayStart = tech.queue.findIndex(j => getJobDate(j.id) === dayStr)
let slot = dayJobs.length, cursor = 8
for (let i = 0; i < dayJobs.length; i++) {
const dur = parseFloat(dayJobs[i].duration) || 1
if (dropH < cursor + dur / 2) { slot = i; break }
cursor += dur + 0.5
}
const insertAt = queueDayStart >= 0 ? queueDayStart + slot : tech.queue.length
tech.queue.splice(insertAt, 0, draggedJob)
tech.queue.forEach((q, i) => { q.routeOrder = i; updateJob(q.name || q.id, { route_order: i }).catch(() => {}) })
dragJob.value = null; dragSrc.value = null; invalidateRoutes(); return
}
if (dragIsAssist.value) {
dragJob.value = null; dragSrc.value = null; dragIsAssist.value = false; return
}
assignDroppedJob(tech, xToDateStr(e.clientX - e.currentTarget.getBoundingClientRect().left))
}
function onCalDrop (e, tech, dateStr) { assignDroppedJob(tech, dateStr) }
function xToDateStr (x) {
const di = Math.max(0, Math.min(periodDays.value - 1, Math.floor(x / dayW.value)))
const d = new Date(periodStart.value); d.setDate(d.getDate() + di)
return localDateStr(d)
}
function startBlockMove (e, job, block) {
if (e.button !== 0) return
const startX = e.clientX, startY = e.clientY
const startLeft = parseFloat(block.style.left) || 0
let moving = false
function onMove (ev) {
const dx = ev.clientX - startX, dy = ev.clientY - startY
if (!moving && Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 5) { cleanup(); return }
if (!moving && Math.abs(dx) > 5) { moving = true; block.style.zIndex = '10' }
if (!moving) return
ev.preventDefault()
const newLeft = Math.max(0, startLeft + dx)
const newH = snapH(H_START + newLeft / pxPerHr.value)
block.style.left = ((newH - H_START) * pxPerHr.value) + 'px'
const meta = block.querySelector('.sb-block-meta')
if (meta) meta.textContent = `${hToTime(newH)} · ${fmtDur(job.duration)}`
}
function cleanup () {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
function onUp (ev) {
cleanup()
if (!moving) return
block.style.zIndex = ''
const dx = ev.clientX - startX
const newH = snapH(H_START + Math.max(0, startLeft + dx) / pxPerHr.value)
job.startHour = newH; job.startTime = hToTime(newH)
store.setJobSchedule(job.id, job.scheduledDate, hToTime(newH))
invalidateRoutes()
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function startResize (e, job, mode, assistTechId) {
e.preventDefault()
const startX = e.clientX
const startDur = mode === 'assist'
? (job.assistants.find(a => a.techId === assistTechId)?.duration || job.duration)
: job.duration
const block = e.target.parentElement
const startW = block.offsetWidth
function onMove (ev) {
const dx = ev.clientX - startX
const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value))
block.style.width = (newDur * pxPerHr.value) + 'px'
const meta = block.querySelector('.sb-block-meta')
if (meta) meta.textContent = mode === 'assist' ? `assistant · ${fmtDur(newDur)}` : fmtDur(newDur)
}
function onUp (ev) {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
const dx = ev.clientX - startX
const newDur = Math.max(SNAP, snapH(Math.max(18, startW + dx) / pxPerHr.value))
if (mode === 'assist' && assistTechId) {
const assist = job.assistants.find(a => a.techId === assistTechId)
if (assist) {
assist.duration = newDur
updateJob(job.name || job.id, {
assistants: serializeAssistants(job.assistants),
}).catch(() => {})
}
} else {
job.duration = newDur
updateJob(job.name || job.id, { duration_h: newDur }).catch(() => {})
}
invalidateRoutes()
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
return {
dragJob, dragSrc, dragIsAssist, dropGhost, dragTech, dragBatchIds,
cleanupDropIndicators,
onJobDragStart, onTimelineDragOver, onTimelineDragLeave,
onTechDragStart, onBlockDrop,
assignDroppedJob, onTimelineDrop, onCalDrop, xToDateStr,
startBlockMove, startResize,
}
}

View File

@ -0,0 +1,162 @@
// ── Pure utility functions (no Vue dependencies) ─────────────────────────────
export function localDateStr (d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
export function startOfWeek (d) {
const r = new Date(d); r.setHours(0,0,0,0)
const diff = r.getDay() === 0 ? -6 : 1 - r.getDay()
r.setDate(r.getDate() + diff); return r
}
export function startOfMonth (d) { return new Date(d.getFullYear(), d.getMonth(), 1) }
export function timeToH (t) {
const [h, m] = t.split(':').map(Number)
return h + m / 60
}
export function hToTime (h) {
const totalMin = Math.round(h * 60)
const hh = Math.floor(totalMin / 60)
const mm = totalMin % 60
return `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`
}
export function fmtDur (h) {
const totalMin = Math.round((parseFloat(h) || 0) * 60)
const hh = Math.floor(totalMin / 60)
const mm = totalMin % 60
if (hh === 0) return `${mm}m`
if (mm === 0) return `${hh}h`
return `${hh}h${String(mm).padStart(2,'0')}`
}
export const SNAP_MIN = 5
export const SNAP = SNAP_MIN / 60
export function snapH (h) { return Math.round(h * 60 / SNAP_MIN) * SNAP_MIN / 60 }
export function dayLoadColor (ratio) {
const r = Math.min(ratio, 1.2)
if (r <= 0.5) return '#10b981'
if (r <= 0.75) return '#f59e0b'
if (r <= 1) return '#f97316'
return '#ef4444'
}
export function shortAddr (addr) {
if (!addr) return ''
const parts = addr.replace(/[A-Z]\d[A-Z]\s?\d[A-Z]\d/g, '').trim().split(/[\s,]+/)
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i].length > 2 && /^[A-ZÀ-Ú]/.test(parts[i])) return parts[i]
}
return parts.slice(-2).join(' ')
}
// Service colors & labels
export const SVC_COLORS = { 'Internet':'#3b82f6','Télévisión':'#a855f7','Téléphonie':'#10b981','Multi-service':'#f59e0b' }
export const SVC_ICONS = { 'Internet':'🌐','Télévisión':'📺','Téléphonie':'📞','Multi-service':'🔧' }
const SVC_CODES = { 'Internet':'WEB','Télévisión':'TV','Téléphonie':'TEL','Multi-service':'MX' }
export function jobSvcCode (job) {
if (SVC_CODES[job.service_type]) return SVC_CODES[job.service_type]
const s = (job.subject || '').toLowerCase()
if (s.includes('internet')) return 'WEB'
if (s.includes('tv') || s.includes('télév')) return 'TV'
if (s.includes('téléph')) return 'TEL'
if (s.includes('multi')) return 'MX'
return 'WO'
}
export function jobColor (job, techColors, store) {
if (SVC_COLORS[job.service_type]) return SVC_COLORS[job.service_type]
const s = (job.subject||'').toLowerCase()
if (s.includes('internet')) return '#3b82f6'
if (s.includes('tv')||s.includes('télév')) return '#a855f7'
if (s.includes('téléph')) return '#10b981'
if (s.includes('multi')) return '#f59e0b'
if (job.assignedTech && store) {
const t = store.technicians.find(x=>x.id===job.assignedTech)
if (t) return techColors[t.colorIdx]
}
return '#6b7280'
}
export function jobSpansDate (job, ds) {
const start = job.scheduledDate
const end = job.endDate
if (!start) return false
if (!end) return start === ds
return ds >= start && ds <= end
}
export function sortJobsByTime (jobs) {
return jobs.slice().sort((a, b) => {
const aH = a.startTime ? timeToH(a.startTime) : (a.startHour ?? 8)
const bH = b.startTime ? timeToH(b.startTime) : (b.startHour ?? 8)
return aH - bH
})
}
// Status helpers
export const STATUS_MAP = {
'available': { cls:'st-available', label:'Disponible' },
'en-route': { cls:'st-enroute', label:'En route' },
'busy': { cls:'st-busy', label:'En cours' },
'in progress': { cls:'st-busy', label:'En cours' },
'off': { cls:'st-off', label:'Hors shift' },
}
export function stOf (t) { return STATUS_MAP[(t.status||'').toLowerCase()] || STATUS_MAP['available'] }
export function prioLabel (p) { return { high:'Haute', medium:'Moyenne', low:'Basse' }[p] || p || '—' }
export function prioClass (p) { return { high:'prio-high', medium:'prio-med', low:'prio-low' }[p] || '' }
// Lucide-style inline SVG icons (stroke-based)
const _s = (d, w=10) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`
export const ICON = {
pin: _s('<path d="M12 17v5"/><path d="M9 11V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v7"/><path d="M4 15h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>'),
mapPin: _s('<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>'),
wifi: _s('<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/>'),
tv: _s('<rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><path d="m17 2-5 5-5-5"/>'),
phone: _s('<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>'),
wrench: _s('<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>'),
cable: _s('<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><path d="M8 7V4"/><path d="M16 7V4"/><path d="M12 16v4"/>'),
check: _s('<path d="M20 6L9 17l-5-5"/>'),
x: _s('<path d="M18 6L6 18M6 6l12 12"/>'),
clock: _s('<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>'),
loader: _s('<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>'),
truck: _s('<path d="M5 17h2l3-6h4l3 6h2M7 17a2 2 0 1 1-4 0M21 17a2 2 0 1 1-4 0"/>'),
}
// Job type icon based on service/subject
export function jobTypeIcon (job) {
const s = (job.subject || '').toLowerCase()
const svc = job.service_type || ''
if (svc === 'Internet' || s.includes('internet') || s.includes('fibre') || s.includes('routeur') || s.includes('wifi')) return ICON.wifi
if (svc === 'Télévisión' || s.includes('tv') || s.includes('télév')) return ICON.tv
if (svc === 'Téléphonie' || s.includes('téléph') || s.includes('phone')) return ICON.phone
if (s.includes('cable') || s.includes('câble') || s.includes('cablage')) return ICON.cable
if (s.includes('camera') || s.includes('install')) return ICON.wrench
return ICON.wrench
}
// Priority color
export function prioColor (p) {
return { high: '#ef4444', medium: '#f59e0b', low: '#7b80a0' }[p] || '#7b80a0'
}
// Status icon (minimal, for timeline blocks)
// Serialize assistants array for ERPNext API calls (used in store + page)
export function serializeAssistants (assistants) {
return (assistants || []).map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 }))
}
export function jobStatusIcon (job) {
const st = (job.status || '').toLowerCase()
if (st === 'completed') return { svg: ICON.check, cls: 'si-done' }
if (st === 'cancelled') return { svg: ICON.x, cls: 'si-cancelled' }
if (st === 'en-route') return { svg: ICON.truck, cls: 'si-enroute' }
if (st === 'in progress') return { svg: ICON.loader, cls: 'si-progress' }
return { svg: '', cls: '' } // no icon for open/assigned — the type icon is enough
}

View File

@ -0,0 +1,413 @@
// ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ────────
import { ref, watch, nextTick } from 'vue'
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
export function useMap (deps) {
const {
store, MAPBOX_TOKEN, TECH_COLORS,
currentView, periodStart, filteredResources, mapVisible,
routeLegs, routeGeometry,
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
dragJob, dragIsAssist, rightPanel, openCtxMenu,
} = deps
let map = null
let mapResizeObs = null
const mapContainer = ref(null)
const selectedTechId = ref(null)
const mapMarkers = ref([])
const mapPanelW = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340)
const geoFixJob = ref(null)
const mapDragJob = ref(null)
let _mapGhost = null
// ── Geo-fix ──────────────────────────────────────────────────────────────────
function startGeoFix (job) {
geoFixJob.value = job
if (!mapVisible.value) mapVisible.value = true
if (map) map.getCanvas().style.cursor = 'crosshair'
}
function cancelGeoFix () {
geoFixJob.value = null
if (map) map.getCanvas().style.cursor = ''
}
watch(geoFixJob, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })
// ── Panel resize ─────────────────────────────────────────────────────────────
function startMapResize (e) {
e.preventDefault()
const startX = e.clientX, startW = mapPanelW.value
function onMove (ev) {
mapPanelW.value = Math.max(220, Math.min(window.innerWidth * 0.65, startW - (ev.clientX - startX)))
}
function onUp () {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
if (map) map.resize()
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function initMap () {
if (!mapContainer.value || map) return
if (!window.mapboxgl) {
if (!document.getElementById('mapbox-js')) {
await new Promise(resolve => {
const s = document.createElement('script'); s.id = 'mapbox-js'
s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
s.onload = resolve; document.head.appendChild(s)
})
} else { await new Promise(r => setTimeout(r, 200)) }
}
const mapboxgl = window.mapboxgl
mapboxgl.accessToken = MAPBOX_TOKEN
map = new mapboxgl.Map({
container: mapContainer.value,
style: 'mapbox://styles/mapbox/dark-v11',
center: [-73.567, 45.502], zoom: 10,
})
if (mapResizeObs) mapResizeObs.disconnect()
mapResizeObs = new ResizeObserver(() => { if (map) map.resize() })
mapResizeObs.observe(mapContainer.value)
map.on('load', () => {
map.resize()
// Route layers
map.addSource('sb-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'sb-route-halo', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 12, 'line-opacity': 0.18 } })
map.addLayer({ id: 'sb-route-line', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 3.5, 'line-opacity': 0.85 } })
// Job layers
map.addSource('sb-jobs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'sb-jobs-halo', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 22, 'circle-color': ['get', 'color'], 'circle-opacity': ['*', ['get', 'opacity'], 0.18], 'circle-blur': 0.7 } })
map.addLayer({ id: 'sb-jobs-circle', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 15, 'circle-color': ['get', 'color'], 'circle-opacity': ['get', 'opacity'], 'circle-stroke-width': 2, 'circle-stroke-color': ['case', ['get', 'unassigned'], 'rgba(255,255,255,0.4)', 'rgba(255,255,255,0.85)'], 'circle-stroke-opacity': ['get', 'opacity'] } })
map.addLayer({ id: 'sb-jobs-label', type: 'symbol', source: 'sb-jobs', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 9, 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#ffffff', 'text-opacity': ['get', 'opacity'] } })
// Event handlers
map.on('mouseenter', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = 'grab' })
map.on('mouseleave', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = '' })
map.on('mousedown', 'sb-jobs-circle', e => {
if (geoFixJob.value) return
e.preventDefault()
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
if (job) startMapDrag(e.originalEvent, job)
})
map.on('click', 'sb-jobs-circle', e => {
if (geoFixJob.value) return
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
if (job) {
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
rightPanel.value = { mode: 'details', data: { job, tech } }
}
})
map.on('contextmenu', 'sb-jobs-circle', e => {
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
if (job) {
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
openCtxMenu(e.originalEvent, job, tech?.id || null)
}
})
map.on('mouseenter', 'sb-route-line', () => { if (mapDragJob.value) map.getCanvas().style.cursor = 'copy' })
map.on('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' })
// Geo-fix click
map.on('click', e => {
if (!geoFixJob.value) return
const job = geoFixJob.value
const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
saved[job.id] = [e.lngLat.lng, e.lngLat.lat]
localStorage.setItem('dispatch-job-coords', JSON.stringify(saved))
store.updateJobCoords(job.id, e.lngLat.lng, e.lngLat.lat)
routeLegs.value = {}; routeGeometry.value = {}
geoFixJob.value = null
map.getCanvas().style.cursor = ''
nextTick(() => {
drawMapMarkers()
const dayStr = localDateStr(periodStart.value)
filteredResources.value.forEach(tech => computeDayRoute(tech, dayStr))
drawSelectedRoute()
})
})
drawMapMarkers()
drawSelectedRoute()
})
}
// ── Draw markers ─────────────────────────────────────────────────────────────
function drawMapMarkers () {
if (!map || !window.mapboxgl) return
const dayStr = localDateStr(periodStart.value)
const mbgl = window.mapboxgl
const jobFeatures = store.jobs
.filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0))
.filter(j => {
if (!j.assignedTech) return (j.scheduledDate || null) === dayStr
return jobSpansDate(j, dayStr)
})
.map(job => {
const isUnassigned = !job.assignedTech
const isCompleted = (job.status || '').toLowerCase() === 'completed'
const isSelected = selectedTechId.value && job.assignedTech === selectedTechId.value
const opacity = isCompleted ? 0.4 : (isSelected || isUnassigned || !selectedTechId.value ? 0.92 : 0.4)
let label = jobSvcCode(job)
if (!isUnassigned) {
const tech = store.technicians.find(t => t.id === job.assignedTech)
if (tech) { const idx = tech.queue.filter(j2 => getJobDate(j2.id) === dayStr).indexOf(job); if (idx >= 0) label = String(idx + 1) }
}
return { type: 'Feature', geometry: { type: 'Point', coordinates: job.coords }, properties: { id: job.id, color: jobColor(job), label, title: job.subject, opacity, unassigned: isUnassigned, completed: isCompleted } }
})
if (map.getSource('sb-jobs')) map.getSource('sb-jobs').setData({ type: 'FeatureCollection', features: jobFeatures })
// Tech avatar markers
mapMarkers.value.forEach(m => m.remove())
mapMarkers.value = []
// Pre-compute: which techs are assistants on which lead tech's jobs today
const groupCounts = {} // leadTechId → total crew size (1 + assistants)
store.technicians.forEach(tech => {
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr))
const assistIds = new Set()
todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId)))
if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size
})
filteredResources.value.forEach(tech => {
const pos = tech.gpsCoords || tech.coords
if (!pos || (pos[0] === 0 && pos[1] === 0)) return
const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
const color = TECH_COLORS[tech.colorIdx]
// Calculate daily workload + completion
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr))
const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr))
const allToday = [...todayJobs, ...todayAssist]
const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0)
const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed')
.reduce((s, j) => s + (j.duration || 1), 0)
const loadPct = Math.min(totalHours / 8, 1)
const donePct = totalHours > 0 ? Math.min(doneHours / 8, 1) : 0
const loadColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444'
// Ring + avatar in a fixed-size container so Mapbox anchor stays consistent
const PIN = 36, STROKE = 3.5, SIZE = PIN + STROKE * 2 + 2 // ~45px
const R = (SIZE - STROKE) / 2, CIRC = 2 * Math.PI * R
const completedJobs = allToday.filter(j => (j.status || '').toLowerCase() === 'completed').length
const totalJobs = allToday.length
const completionPct = totalJobs > 0 ? completedJobs / totalJobs : 0
// Fixed-size outer wrapper — Mapbox anchors to this
const outer = document.createElement('div')
outer.style.cssText = `cursor:pointer;width:${SIZE}px;height:${SIZE}px;position:relative;`
outer.dataset.techId = tech.id
// SVG ring (load arc + completion arc) — fills entire container
if (totalHours > 0) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('width', SIZE); svg.setAttribute('height', SIZE)
svg.style.cssText = 'position:absolute;top:0;left:0;transform:rotate(-90deg);pointer-events:none;'
const loadArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
loadArc.setAttribute('cx', SIZE/2); loadArc.setAttribute('cy', SIZE/2); loadArc.setAttribute('r', R)
loadArc.setAttribute('fill', 'none'); loadArc.setAttribute('stroke', loadColor)
loadArc.setAttribute('stroke-width', STROKE); loadArc.setAttribute('opacity', '0.3')
loadArc.setAttribute('stroke-dasharray', `${CIRC * loadPct} ${CIRC}`)
loadArc.setAttribute('stroke-linecap', 'round')
svg.appendChild(loadArc)
if (completionPct > 0) {
const doneArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
doneArc.setAttribute('cx', SIZE/2); doneArc.setAttribute('cy', SIZE/2); doneArc.setAttribute('r', R)
doneArc.setAttribute('fill', 'none'); doneArc.setAttribute('stroke', '#10b981')
doneArc.setAttribute('stroke-width', STROKE); doneArc.setAttribute('opacity', '1')
doneArc.setAttribute('stroke-dasharray', `${CIRC * completionPct * loadPct} ${CIRC}`)
doneArc.setAttribute('stroke-linecap', 'round')
svg.appendChild(doneArc)
}
outer.appendChild(svg)
}
// Avatar circle — absolutely centered in container
const el = document.createElement('div')
el.className = 'sb-map-tech-pin'
const offset = (SIZE - PIN) / 2
el.style.cssText = `background:${color};border-color:${color};position:absolute;top:${offset}px;left:${offset}px;width:${PIN}px;height:${PIN}px;`
el.textContent = initials
el.title = `${tech.fullName}${completedJobs}/${totalJobs} jobs (${doneHours.toFixed(1)}h / ${totalHours.toFixed(1)}h)`
outer.appendChild(el)
// Group badge (crew size)
const crew = groupCounts[tech.id]
if (crew && crew > 1) {
const badge = document.createElement('div')
badge.className = 'sb-map-crew-badge'
badge.textContent = String(crew)
badge.title = `Équipe de ${crew}`
el.appendChild(badge)
}
// Drag & drop handlers
outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
outer.addEventListener('dragleave', () => { el.style.transform = '' })
outer.addEventListener('drop', e => {
e.preventDefault(); el.style.transform = ''
const job = dragJob.value
if (job) {
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, tech.id, dayStr)
dragJob.value = null
invalidateRoutes()
}
})
outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' })
outer.addEventListener('mouseleave', () => { el.style.transform = '' })
if (tech.gpsCoords) {
el.classList.add('sb-map-gps-active')
el.title += ' (GPS)'
}
const m = new mbgl.Marker({ element: outer, anchor: 'center' }).setLngLat(pos).addTo(map)
mapMarkers.value.push(m)
})
}
// ── Map drag (job pin → tech) ────────────────────────────────────────────────
function startMapDrag (e, job) {
e.preventDefault()
mapDragJob.value = job
if (map) map.dragPan.disable()
_mapGhost = document.createElement('div')
_mapGhost.className = 'sb-map-drag-ghost'
_mapGhost.textContent = job.subject
_mapGhost.style.cssText = `position:fixed;pointer-events:none;z-index:9999;left:${e.clientX + 14}px;top:${e.clientY + 14}px`
document.body.appendChild(_mapGhost)
document.addEventListener('mousemove', _onMapDragMove)
document.addEventListener('mouseup', _onMapDragEnd)
}
function _onMapDragMove (e) { if (_mapGhost) { _mapGhost.style.left = (e.clientX + 14) + 'px'; _mapGhost.style.top = (e.clientY + 14) + 'px' } }
function _onMapDragEnd (e) {
document.removeEventListener('mousemove', _onMapDragMove)
document.removeEventListener('mouseup', _onMapDragEnd)
if (_mapGhost) { _mapGhost.remove(); _mapGhost = null }
if (map) { map.getCanvas().style.cursor = ''; map.dragPan.enable() }
const job = mapDragJob.value; mapDragJob.value = null
if (!job) return
const els = document.elementsFromPoint(e.clientX, e.clientY)
const dateStr = localDateStr(periodStart.value)
function assignFromMap (tech) {
if (dragIsAssist.value) { dragIsAssist.value = false; return }
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
smartAssign(job, tech.id, dateStr)
invalidateRoutes()
}
const domTarget = els.find(el => el.dataset?.techId)
if (domTarget) { const tech = store.technicians.find(t => t.id === domTarget.dataset.techId); if (tech) assignFromMap(tech); return }
if (map && selectedTechId.value) {
const canvas = map.getCanvas(), rect = canvas.getBoundingClientRect()
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
const tech = store.technicians.find(t => t.id === selectedTechId.value)
if (tech) assignFromMap(tech)
}
}
}
// ── Route computation ────────────────────────────────────────────────────────
async function computeDayRoute (tech, dateStr) {
const key = `${tech.id}||${dateStr}`
if (routeLegs.value[key] !== undefined) return
const points = []
if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`)
const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr))]
allJobs.forEach(j => { if (j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) points.push(`${j.coords[0]},${j.coords[1]}`) })
function setCache (legs, geom) {
routeLegs.value = { ...routeLegs.value, [key]: legs }
routeGeometry.value = { ...routeGeometry.value, [key]: geom }
}
if (points.length < 2) { setCache([], null); return }
try {
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${points.join(';')}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
const r = await fetch(url)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json()
if (data.routes?.[0]) setCache(data.routes[0].legs.map(l => Math.round(l.duration / 60)), data.routes[0].geometry.coordinates)
else setCache([], null)
} catch (e) { console.warn('[route] fetch error', e); setCache([], null) }
}
// ── Draw route ───────────────────────────────────────────────────────────────
function drawSelectedRoute () {
if (!map || !mapVisible.value) return
const src = map.getSource('sb-route'); if (!src) return
const empty = { type: 'FeatureCollection', features: [] }
if (currentView.value !== 'day') { src.setData(empty); return }
const dayStr = localDateStr(periodStart.value)
const features = []
const techs = selectedTechId.value ? filteredResources.value.filter(t => t.id === selectedTechId.value) : filteredResources.value
techs.forEach(tech => {
const coords = routeGeometry.value[`${tech.id}||${dayStr}`]
if (coords?.length) features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: TECH_COLORS[tech.colorIdx] } })
})
src.setData({ type: 'FeatureCollection', features })
map.setPaintProperty('sb-route-halo', 'line-color', ['get', 'color'])
map.setPaintProperty('sb-route-line', 'line-color', ['get', 'color'])
}
// ── Select tech on board ─────────────────────────────────────────────────────
function selectTechOnBoard (tech) {
const wasSelected = selectedTechId.value === tech.id
selectedTechId.value = wasSelected ? null : tech.id
if (!wasSelected && currentView.value === 'day') {
if (!mapVisible.value) {
mapPanelW.value = Math.round(window.innerWidth * 0.5)
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
mapVisible.value = true
}
}
if (map) { drawMapMarkers(); drawSelectedRoute() }
}
// ── Watchers ─────────────────────────────────────────────────────────────────
watch([selectedTechId, () => periodStart.value?.getTime(), currentView, routeGeometry], () => { if (map) { drawMapMarkers(); drawSelectedRoute() } })
watch(mapVisible, async v => {
if (v) {
if (map) { try { map.remove() } catch (_) {} map = null }
await nextTick(); await initMap()
if (map) {
const r = () => { if (!map) return; map.resize(); drawMapMarkers(); drawSelectedRoute() }
await nextTick(); r(); setTimeout(r, 100); setTimeout(r, 300); setTimeout(r, 600)
}
} else {
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
if (map) { try { map.remove() } catch (_) {} map = null }
}
})
watch([() => periodStart.value?.getTime(), filteredResources], () => {
if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() }
})
watch(
() => store.technicians.map(t => t.gpsCoords),
() => { if (map) drawMapMarkers() },
{ deep: true }
)
// ── Lifecycle helpers ────────────────────────────────────────────────────────
function destroyMap () {
if (map) { map.remove(); map = null }
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
}
function loadMapboxCss () {
if (!document.getElementById('mapbox-css')) {
const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'
l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
}
}
function getMap () { return map }
return {
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob,
startGeoFix, cancelGeoFix, startMapResize, initMap,
drawMapMarkers, drawSelectedRoute, computeDayRoute,
selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
}
}

View File

@ -0,0 +1,209 @@
// ── Scheduling logic: timeline computation, route cache, job placement ───────
import { ref, computed } from 'vue'
import { localDateStr, timeToH, hToTime, sortJobsByTime, jobSpansDate } from './useHelpers'
export function useScheduler (store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColorFn) {
const H_START = 7
const H_END = 20
// ── Route cache ────────────────────────────────────────────────────────────
const routeLegs = ref({})
const routeGeometry = ref({})
// ── Parent start position cache ────────────────────────────────────────────
let _parentStartCache = {}
function getParentStartH (job) {
if (!store.technicians.length) return job.startHour ?? 8
const key = `${job.assignedTech}||${job.id}`
if (_parentStartCache[key] !== undefined) return _parentStartCache[key]
const leadTech = store.technicians.find(t => t.id === job.assignedTech)
if (!leadTech) return job.startHour ?? 8
const dayStr = localDateStr(periodStart.value)
const leadJobs = sortJobsByTime(leadTech.queue.filter(j => getJobDate(j.id) === dayStr))
const cacheKey = `${leadTech.id}||${dayStr}`
const legMins = routeLegs.value[cacheKey]
const hasHome = !!(leadTech.coords?.[0] && leadTech.coords?.[1])
let cursor = 8, result = job.startHour ?? 8
leadJobs.forEach((j, idx) => {
const showTravel = idx > 0 || (idx === 0 && hasHome)
if (showTravel) {
const legIdx = hasHome ? idx : idx - 1
const routeMin = legMins?.[legIdx]
cursor += (routeMin != null ? routeMin : (parseFloat(j.legDur) > 0 ? parseFloat(j.legDur) : 20)) / 60
}
const pinnedH = j.startTime ? timeToH(j.startTime) : null
const startH = pinnedH ?? cursor
if (j.id === job.id) result = startH
cursor = startH + (parseFloat(j.duration) || 1)
})
_parentStartCache[key] = result
return result
}
// ── All jobs for a tech on a date (primary + assists) ──────────────────────
function techAllJobsForDate (tech, dateStr) {
_parentStartCache = {}
const primary = tech.queue.filter(j => jobSpansDate(j, dateStr))
const assists = (tech.assistJobs || [])
.filter(j => jobSpansDate(j, dateStr))
.map(j => {
const a = j.assistants.find(x => x.techId === tech.id)
const parentH = getParentStartH(j)
return {
...j,
duration: a?.duration || j.duration,
startTime: hToTime(parentH),
startHour: parentH,
_isAssist: true,
_assistPinned: !!a?.pinned,
_assistNote: a?.note || '',
_parentJob: j,
}
})
return sortJobsByTime([...primary, ...assists])
}
// ── Day view: schedule blocks with pinned anchors + auto-flow ──────────────
function techDayJobsWithTravel (tech) {
const dayStr = localDateStr(periodStart.value)
const cacheKey = `${tech.id}||${dayStr}`
const legMins = routeLegs.value[cacheKey]
const hasHome = !!(tech.coords?.[0] && tech.coords?.[1])
const allJobs = techAllJobsForDate(tech, dayStr)
const flowEntries = []
const floatingEntries = []
allJobs.forEach(job => {
const isAssist = !!job._isAssist
const dur = parseFloat(job.duration) || 1
const isPinned = isAssist ? !!job._assistPinned : !!getJobTime(job.id)
const pinH = isAssist ? job.startHour : (getJobTime(job.id) ? timeToH(getJobTime(job.id)) : null)
const entry = { job, dur, isAssist, isPinned, pinH }
if (isAssist && !job._assistPinned) floatingEntries.push(entry)
else flowEntries.push(entry)
})
const pinnedAnchors = flowEntries.filter(e => e.isPinned).map(e => ({ start: e.pinH, end: e.pinH + e.dur }))
const placed = []
const occupied = pinnedAnchors.map(a => ({ ...a }))
const sortedFlow = [...flowEntries].sort((a, b) => {
if (a.isPinned && b.isPinned) return a.pinH - b.pinH
if (a.isPinned) return -1
if (b.isPinned) return 1
return 0
})
sortedFlow.filter(e => e.isPinned).forEach(e => placed.push({ entry: e, startH: e.pinH }))
let cursor = 8, flowIdx = 0
sortedFlow.filter(e => !e.isPinned).forEach(e => {
const legIdx = hasHome ? flowIdx : flowIdx - 1
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
const travelH = (routeMin != null ? routeMin : (parseFloat(e.job.legDur) > 0 ? parseFloat(e.job.legDur) : 20)) / 60
let startH = cursor + (flowIdx > 0 || hasHome ? travelH : 0)
let safe = false
while (!safe) {
const endH = startH + e.dur
const overlap = occupied.find(o => startH < o.end && endH > o.start)
if (overlap) startH = overlap.end + travelH
else safe = true
}
placed.push({ entry: e, startH })
occupied.push({ start: startH, end: startH + e.dur })
cursor = startH + e.dur
flowIdx++
})
placed.sort((a, b) => a.startH - b.startH)
const result = []
let prevEndH = null
placed.forEach((p, pIdx) => {
const { entry, startH } = p
const { job, dur, isAssist, isPinned } = entry
const realJob = isAssist ? job._parentJob : job
const travelStart = prevEndH ?? (hasHome ? 8 : null)
if (travelStart != null && startH > travelStart + 0.01) {
const gapH = startH - travelStart
const legIdx = hasHome ? pIdx : pIdx - 1
const routeMin = legMins?.[legIdx >= 0 ? legIdx : 0]
const fromRoute = routeMin != null
result.push({
type: 'travel', job: realJob, travelMin: fromRoute ? routeMin : Math.round(gapH * 60), fromRoute, isAssist: false,
style: { left: (travelStart - H_START) * pxPerHr.value + 'px', width: Math.max(8, gapH * pxPerHr.value) + 'px', top: '10px', bottom: '10px', position: 'absolute' },
color: jobColorFn(realJob),
})
}
const jLeft = (startH - H_START) * pxPerHr.value
const jWidth = Math.max(18, dur * pxPerHr.value)
result.push({
type: isAssist ? 'assist' : 'job', job: realJob,
pinned: isPinned, pinnedTime: isPinned ? hToTime(startH) : null, isAssist,
assistPinned: isAssist ? isPinned : false, assistDur: isAssist ? dur : null,
assistNote: isAssist ? job._assistNote : null, assistTechId: isAssist ? tech.id : null,
style: { left: jLeft + 'px', width: jWidth + 'px', top: '6px', bottom: '6px', position: 'absolute' },
})
prevEndH = startH + dur
})
floatingEntries.forEach(entry => {
const { job, dur } = entry
const startH = job.startHour ?? 8
result.push({
type: 'assist', job: job._parentJob, pinned: false, pinnedTime: null, isAssist: true,
assistPinned: false, assistDur: dur, assistNote: job._assistNote, assistTechId: tech.id,
style: { left: (startH - H_START) * pxPerHr.value + 'px', width: Math.max(18, dur * pxPerHr.value) + 'px', top: '52%', bottom: '4px', position: 'absolute' },
})
})
return result
}
// ── Week view helpers ──────────────────────────────────────────────────────
function techBookingsByDay (tech) {
return dayColumns.value.map(d => {
const ds = localDateStr(d)
const primary = tech.queue.filter(j => jobSpansDate(j, ds))
const assists = (tech.assistJobs || [])
.filter(j => jobSpansDate(j, ds) && j.assistants.find(a => a.techId === tech.id)?.pinned)
.map(j => ({ ...j, _isAssistChip: true, _assistDur: j.assistants.find(a => a.techId === tech.id)?.duration || j.duration }))
return { day: d, dateStr: ds, jobs: [...primary, ...assists] }
})
}
function periodLoadH (tech) {
const dateSet = new Set(dayColumns.value.map(d => localDateStr(d)))
let total = tech.queue.reduce((sum, j) => {
const ds = getJobDate(j.id)
return ds && dateSet.has(ds) ? sum + (parseFloat(j.duration) || 0) : sum
}, 0)
;(tech.assistJobs || []).forEach(j => {
const ds = getJobDate(j.id)
if (ds && dateSet.has(ds)) {
const a = j.assistants.find(x => x.techId === tech.id)
if (a?.pinned) total += parseFloat(a?.duration || j.duration) || 0
}
})
return total
}
function techsActiveOnDay (dateStr, resources) {
return resources.filter(tech =>
tech.queue.some(j => jobSpansDate(j, dateStr)) ||
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned)
)
}
function dayJobCount (dateStr, resources) {
const jobIds = new Set()
resources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id)))
return jobIds.size
}
return {
H_START, H_END, routeLegs, routeGeometry,
techAllJobsForDate, techDayJobsWithTravel,
techBookingsByDay, periodLoadH, techsActiveOnDay, dayJobCount,
}
}

View File

@ -0,0 +1,172 @@
// ── Selection composable: lasso, multi-select, hover linking, batch ops ───────
import { ref, computed } from 'vue'
import { localDateStr } from './useHelpers'
export function useSelection (deps) {
const { store, periodStart, smartAssign, invalidateRoutes, fullUnassign } = deps
const hoveredJobId = ref(null)
const selectedJob = ref(null) // { job, techId, isAssist?, assistTechId? }
const multiSelect = ref([]) // [{ job, techId, isAssist?, assistTechId? }]
// ── Select / toggle ─────────────────────────────────────────────────────────
function selectJob (job, techId, isAssist = false, assistTechId = null, event = null, rightPanel = null) {
const entry = { job, techId, isAssist, assistTechId }
const isMulti = event && (event.ctrlKey || event.metaKey)
if (isMulti) {
const idx = multiSelect.value.findIndex(s => s.job.id === job.id && s.isAssist === isAssist)
if (idx >= 0) multiSelect.value.splice(idx, 1)
else multiSelect.value.push(entry)
selectedJob.value = entry
} else {
multiSelect.value = []
const same = selectedJob.value?.job?.id === job.id && selectedJob.value?.isAssist === isAssist && selectedJob.value?.assistTechId === assistTechId
selectedJob.value = same ? null : entry
if (!same && rightPanel !== undefined) {
const tech = store.technicians.find(t => t.id === (techId || job.assignedTech))
if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) {
rightPanel.value = { mode: 'details', data: { job, tech: tech || null } }
}
} else if (rightPanel !== null && typeof rightPanel === 'object' && 'value' in rightPanel) {
rightPanel.value = null
}
}
}
function isJobMultiSelected (jobId, isAssist = false) {
return multiSelect.value.some(s => s.job.id === jobId && s.isAssist === isAssist)
}
// ── Batch ops (grouped undo) ──────────────────────────────────────────────────
function batchUnassign (pushUndo) {
if (!multiSelect.value.length) return
// Snapshot all jobs before unassign — single undo entry
const assignments = multiSelect.value.filter(s => !s.isAssist).map(s => ({
jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder,
scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])]
}))
const prevQueues = {}
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
multiSelect.value.forEach(s => {
if (s.isAssist && s.assistTechId) store.removeAssistant(s.job.id, s.assistTechId)
else store.fullUnassign(s.job.id)
})
if (pushUndo && assignments.length) {
pushUndo({ type: 'batchAssign', assignments, prevQueues })
}
multiSelect.value = []; selectedJob.value = null
invalidateRoutes()
}
function batchMoveTo (techId, dayStr, pushUndo) {
if (!multiSelect.value.length) return
const day = dayStr || localDateStr(periodStart.value)
const jobs = multiSelect.value.filter(s => !s.isAssist)
// Snapshot for grouped undo
const assignments = jobs.map(s => ({
jobId: s.job.id, techId: s.job.assignedTech, routeOrder: s.job.routeOrder,
scheduledDate: s.job.scheduledDate, assistants: [...(s.job.assistants || [])]
}))
const prevQueues = {}
store.technicians.forEach(t => { prevQueues[t.id] = [...t.queue] })
jobs.forEach(s => smartAssign(s.job, techId, day))
if (pushUndo && assignments.length) {
pushUndo({ type: 'batchAssign', assignments, prevQueues })
}
multiSelect.value = []; selectedJob.value = null
invalidateRoutes()
}
// ── Lasso ─────────────────────────────────────────────────────────────────────
const lasso = ref(null)
const boardScroll = ref(null)
const lassoStyle = computed(() => {
if (!lasso.value) return {}
const l = lasso.value
return {
left: Math.min(l.x1, l.x2) + 'px', top: Math.min(l.y1, l.y2) + 'px',
width: Math.abs(l.x2 - l.x1) + 'px', height: Math.abs(l.y2 - l.y1) + 'px',
}
})
function startLasso (e) {
if (e.target.closest('.sb-block, .sb-chip, .sb-res-cell, .sb-travel-trail, button, input, select, a')) return
if (e.button !== 0) return
e.preventDefault()
if (!e.ctrlKey && !e.metaKey) {
if (selectedJob.value || multiSelect.value.length) {
selectedJob.value = null; multiSelect.value = []
}
}
const rect = boardScroll.value.getBoundingClientRect()
const x = e.clientX - rect.left + boardScroll.value.scrollLeft
const y = e.clientY - rect.top + boardScroll.value.scrollTop
lasso.value = { x1: x, y1: y, x2: x, y2: y }
}
function moveLasso (e) {
if (!lasso.value) return
e.preventDefault()
const rect = boardScroll.value.getBoundingClientRect()
lasso.value.x2 = e.clientX - rect.left + boardScroll.value.scrollLeft
lasso.value.y2 = e.clientY - rect.top + boardScroll.value.scrollTop
}
function endLasso () {
if (!lasso.value) return
const l = lasso.value
const w = Math.abs(l.x2 - l.x1), h = Math.abs(l.y2 - l.y1)
if (w > 10 && h > 10) {
const boardRect = boardScroll.value.getBoundingClientRect()
const lassoLeft = Math.min(l.x1, l.x2) - boardScroll.value.scrollLeft + boardRect.left
const lassoTop = Math.min(l.y1, l.y2) - boardScroll.value.scrollTop + boardRect.top
const lassoRight = lassoLeft + w, lassoBottom = lassoTop + h
const blocks = boardScroll.value.querySelectorAll('.sb-block[data-job-id], .sb-chip')
const selected = []
blocks.forEach(el => {
const r = el.getBoundingClientRect()
if (r.right > lassoLeft && r.left < lassoRight && r.bottom > lassoTop && r.top < lassoBottom) {
const jobId = el.dataset?.jobId
if (jobId) {
const job = store.jobs.find(j => j.id === jobId)
if (job) selected.push({ job, techId: job.assignedTech, isAssist: false, assistTechId: null })
}
}
})
if (selected.length) {
multiSelect.value = selected
if (selected.length === 1) selectedJob.value = selected[0]
}
}
lasso.value = null
}
// ── Hover linking helpers ─────────────────────────────────────────────────────
function techHasLinkedJob (tech) {
const hId = hoveredJobId.value, sId = selectedJob.value?.job?.id
if (hId && (tech.assistJobs || []).some(j => j.id === hId)) return true
if (hId && tech.queue.some(j => j.id === hId)) return true
if (sId && !selectedJob.value?.isAssist && (tech.assistJobs || []).some(j => j.id === sId)) return true
if (sId && selectedJob.value?.isAssist && tech.queue.some(j => j.id === sId)) return true
return false
}
function techIsHovered (tech) {
const hId = hoveredJobId.value
if (!hId) return false
const job = tech.queue.find(j => j.id === hId)
return job && job.assistants?.length > 0
}
return {
hoveredJobId, selectedJob, multiSelect,
selectJob, isJobMultiSelected, batchUnassign, batchMoveTo,
lasso, boardScroll, lassoStyle, startLasso, moveLasso, endLasso,
techHasLinkedJob, techIsHovered,
}
}

View File

@ -0,0 +1,78 @@
// ── Undo stack composable ────────────────────────────────────────────────────
import { ref, nextTick } from 'vue'
import { updateJob } from 'src/api/dispatch'
import { serializeAssistants } from './useHelpers'
export function useUndo (store, invalidateRoutes) {
const undoStack = ref([])
function pushUndo (action) {
undoStack.value.push(action)
if (undoStack.value.length > 30) undoStack.value.shift()
}
// Restore a single job to its previous state (unassign from current tech, re-assign if it had one)
function _restoreJob (prev) {
const job = store.jobs.find(j => j.id === prev.jobId)
if (!job) return
// Remove from all tech queues first
store.technicians.forEach(t => { t.queue = t.queue.filter(q => q.id !== prev.jobId) })
if (prev.techId) {
// Was assigned before — re-assign
store.assignJobToTech(prev.jobId, prev.techId, prev.routeOrder, prev.scheduledDate)
} else {
// Was unassigned before — just mark as open
job.assignedTech = null
job.status = 'open'
job.scheduledDate = prev.scheduledDate || null
updateJob(job.name || job.id, { assigned_tech: null, status: 'open', scheduled_date: prev.scheduledDate || '' }).catch(() => {})
}
if (prev.assistants?.length) {
job.assistants = prev.assistants
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
}
}
function performUndo () {
const action = undoStack.value.pop()
if (!action) return
if (action.type === 'removeAssistant') {
store.addAssistant(action.jobId, action.techId)
nextTick(() => {
const job = store.jobs.find(j => j.id === action.jobId)
const a = job?.assistants.find(x => x.techId === action.techId)
if (a) { a.duration = action.duration; a.note = action.note }
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
})
} else if (action.type === 'optimizeRoute') {
const tech = store.technicians.find(t => t.id === action.techId)
if (tech) {
tech.queue = action.prevQueue
action.prevQueue.forEach((j, i) => { j.routeOrder = i })
}
} else if (action.type === 'autoDistribute') {
action.assignments.forEach(a => _restoreJob(a))
if (action.prevQueues) {
store.technicians.forEach(t => {
if (action.prevQueues[t.id]) t.queue = action.prevQueues[t.id]
})
}
} else if (action.type === 'batchAssign') {
// Undo a multi-select drag — restore each job to previous state
action.assignments.forEach(a => _restoreJob(a))
} else if (action.type === 'unassignJob') {
_restoreJob(action)
}
// Rebuild assistJobs on all techs
store.technicians.forEach(t => { t.assistJobs = store.jobs.filter(j => j.assistants.some(a => a.techId === t.id)) })
invalidateRoutes()
}
return { undoStack, pushUndo, performUndo }
}

View File

@ -0,0 +1,26 @@
// ── ERPNext connection config ────────────────────────────────────────────────
// To host the app separately from ERPNext (e.g. Nginx, Vercel):
// - Set BASE_URL to 'https://your-erpnext.example.com'
// - Add CORS + session/JWT config on the ERPNext side
// - Update api/auth.js if switching from session cookie to JWT
// For same-origin (ERPNext serves the app): keep BASE_URL as empty string.
// In production, /api/ is proxied to ERPNext via nginx (same-origin, no CORS)
// In dev (localhost), calls go directly to ERPNext
export const BASE_URL = window.location.hostname === 'localhost' ? 'https://erp.gigafibre.ca' : ''
// Mapbox public token — safe to expose (scope-limited in Mapbox dashboard)
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
export const TECH_COLORS = [
'#6366f1', // Indigo
'#10b981', // Emerald
'#f59e0b', // Amber
'#8b5cf6', // Violet
'#06b6d4', // Cyan
'#f43f5e', // Rose
'#f97316', // Orange
'#14b8a6', // Teal
'#d946ef', // Fuchsia
'#3b82f6', // Blue
]

View File

@ -0,0 +1,41 @@
// Global CSS variables
// Shared between DispatchPage and MobilePage.
// To add a new theme: duplicate the :root block with a body class selector.
:root {
// Dark theme (default for dispatch desktop)
--bg: #0b0f1a;
--sidebar-bg: rgba(15, 23, 42, 0.9);
--card-bg: rgba(30, 41, 59, 0.5);
--card-hover: rgba(51, 65, 85, 0.6);
--border: rgba(255, 255, 255, 0.08);
--border-accent: rgba(99, 102, 241, 0.3);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.3);
--green: #10b981;
--green-glow: rgba(16, 185, 129, 0.2);
--orange: #f59e0b;
--red: #f43f5e;
--card-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
body.light-mode {
--bg: #ffffff;
--sidebar-bg: #ffffff;
--card-bg: #ffffff;
--card-hover: #f1f5f9;
--border: #e2e8f0;
--border-accent: #cbd5e1;
--text-primary: #0f172a;
--text-secondary: #475569;
--accent: #4f46e5;
--accent-glow: rgba(79, 70, 229, 0.1);
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
*, *::before, *::after { box-sizing: border-box; }
// Quasar resets some of these keep them consistent
html, body { height: 100%; }

View File

@ -0,0 +1,194 @@
<script setup>
import { ref, inject } from 'vue'
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
const props = defineProps({
open: Boolean,
height: Number,
groups: Array,
unscheduledCount: Number,
selected: Object, // Set
dropActive: Boolean,
})
const emit = defineEmits([
'update:open', 'update:height', 'resize-start',
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
'auto-distribute', 'open-criteria',
'row-click', 'row-dblclick', 'row-dragstart',
'drop-unassign', 'lasso-select', 'deselect-all',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const btColW = inject('btColW')
const startColResize = inject('startColResize')
// Lasso selection
const btLasso = ref(null)
const btScrollRef = ref(null)
let btLassoMoved = false
function btLassoStart (e) {
if (e.target.closest('button, input, .sb-bt-checkbox, a, .sb-col-resize, .sb-bottom-hdr, .sb-bottom-resize')) return
if (e.button !== 0) return
const scroll = btScrollRef.value
if (!scroll) return
// On a job row don't start lasso, let drag handle it
const row = e.target.closest('.sb-bottom-row')
if (row) return
e.preventDefault()
btLassoMoved = false
const rect = scroll.getBoundingClientRect()
const x = e.clientX - rect.left + scroll.scrollLeft
const y = e.clientY - rect.top + scroll.scrollTop
btLasso.value = { x1: x, y1: y, x2: x, y2: y }
document.addEventListener('mousemove', btLassoMove)
document.addEventListener('mouseup', btLassoEnd)
}
function btLassoMove (e) {
if (!btLasso.value) return
e.preventDefault()
btLassoMoved = true
const scroll = btScrollRef.value
const rect = scroll.getBoundingClientRect()
btLasso.value.x2 = e.clientX - rect.left + scroll.scrollLeft
btLasso.value.y2 = e.clientY - rect.top + scroll.scrollTop
// Live selection as lasso moves
const l = btLasso.value
const h = Math.abs(l.y2 - l.y1)
if (h > 8) {
const scrollRect = scroll.getBoundingClientRect()
const lassoTop = Math.min(l.y1, l.y2) - scroll.scrollTop + scrollRect.top
const lassoBottom = lassoTop + h
const rows = scroll.querySelectorAll('.sb-bottom-row')
const ids = []
rows.forEach(row => {
const r = row.getBoundingClientRect()
if (r.bottom > lassoTop && r.top < lassoBottom) {
const jobId = row.dataset?.jobId
if (jobId) ids.push(jobId)
}
})
if (ids.length) emit('lasso-select', ids)
}
}
function btLassoEnd () {
document.removeEventListener('mousemove', btLassoMove)
document.removeEventListener('mouseup', btLassoEnd)
if (!btLasso.value) return
// If no movement = click on empty space = clear selection
if (!btLassoMoved) {
emit('deselect-all')
}
btLasso.value = null
}
</script>
<template>
<div v-if="open" class="sb-bottom-panel" :style="'height:'+height+'px'">
<div class="sb-bottom-resize" @mousedown.prevent="emit('resize-start', $event)"></div>
<div class="sb-bottom-hdr">
<span class="sb-bottom-title">
Jobs non assignées
<span class="sbf-count">{{ unscheduledCount }}</span>
</span>
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement"> Répartir auto</button>
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch"> Critères</button>
<!-- Batch assign bar -->
<template v-if="selected.size">
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
<span class="sb-bottom-sel-lbl"></span>
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech"
:style="'border-color:'+TECH_COLORS[t.colorIdx]" :title="t.fullName"
@click="emit('batch-assign', t.id)">
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</button>
<button class="sb-bottom-sel-clear" @click="emit('clear-select')"></button>
</template>
<div style="flex:1"></div>
<button v-if="unscheduledCount" class="sb-bottom-sel-all" @click="emit('select-all')" title="Tout sélectionner"> Tout</button>
<button class="sb-bottom-close" @click="emit('update:open', false)"></button>
</div>
<div class="sb-bottom-body"
:class="{ 'sbf-drop-active': dropActive }"
@dragover.prevent="$emit('drop-unassign', $event, 'over')"
@dragleave="$emit('drop-unassign', $event, 'leave')"
@drop="$emit('drop-unassign', $event, 'drop')">
<div v-if="dropActive" class="sbf-drop-hint" style="margin:4px"> Désaffecter ici</div>
<table class="sb-bottom-table">
<thead>
<tr>
<th class="sb-bt-chk" style="width:28px"></th>
<th class="sb-bt-prio" style="width:12px"></th>
<th class="sb-bt-name" :style="'width:'+btColW('name',200)"><span>Nom</span><div class="sb-col-resize" @mousedown="startColResize($event,'name')"></div></th>
<th class="sb-bt-addr" :style="'width:'+btColW('addr',180)"><span>Adresse</span><div class="sb-col-resize" @mousedown="startColResize($event,'addr')"></div></th>
<th class="sb-bt-dur" :style="'width:'+btColW('dur',130)"><span>Durée</span><div class="sb-col-resize" @mousedown="startColResize($event,'dur')"></div></th>
<th class="sb-bt-prio-lbl" style="width:70px">Priorité</th>
<th class="sb-bt-tags">Skills / Tags</th>
</tr>
</thead>
</table>
<div class="sb-bottom-scroll" ref="btScrollRef" @mousedown="btLassoStart" style="position:relative">
<template v-for="group in groups" :key="group.date||'nodate'">
<div class="sb-bottom-date-sep">
<span class="sb-bottom-date-label">{{ group.label }}</span>
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
</div>
<table class="sb-bottom-table">
<tbody>
<tr v-for="job in group.jobs" :key="job.id"
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
:data-job-id="job.id"
draggable="true"
@dragstart="emit('row-dragstart', $event, job, selected.has(job.id) && selected.size > 1)"
@click="emit('row-click', job, $event)"
@dblclick.stop="emit('row-dblclick', job)">
<td class="sb-bt-chk" style="width:28px" @click.stop="emit('toggle-select', job.id, $event)">
<span class="sb-bt-checkbox" :class="{ checked: selected.has(job.id) }"></span>
</td>
<td class="sb-bt-prio" style="width:12px">
<span class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)" :title="prioLabel(job.priority)"></span>
</td>
<td class="sb-bt-name" :style="'width:'+btColW('name',200)">
<span class="sb-bt-name-text">{{ job.subject }}</span>
</td>
<td class="sb-bt-addr" :style="'width:'+btColW('addr',180)">{{ shortAddr(job.address) || '—' }}</td>
<td class="sb-bt-dur" :style="'width:'+btColW('dur',130)">
<div class="sb-bt-dur-wrap">
<div class="sb-bt-dur-bar">
<div class="sb-bt-dur-fill" :style="{ width: Math.min(100,(parseFloat(job.duration)||0)/8*100)+'%', background: dayLoadColor((parseFloat(job.duration)||0)/8) }"></div>
</div>
<span class="sb-bt-dur-lbl">{{ fmtDur(job.duration) }}</span>
</div>
</td>
<td class="sb-bt-prio-lbl" style="width:70px">
<span :class="prioClass(job.priority)" class="sb-bt-prio-tag">{{ prioLabel(job.priority) }}</span>
</td>
<td class="sb-bt-tags">
<span v-for="t in (job.tags||[])" :key="t" class="sb-bt-skill-chip">{{ t }}</span>
<span v-if="!(job.tags||[]).length" class="sb-bt-no-tag"></span>
</td>
</tr>
</tbody>
</table>
</template>
<div v-if="!unscheduledCount" class="sbf-empty" style="padding:1rem;text-align:center">Aucune job non assignée</div>
<div v-if="btLasso" class="sb-bt-lasso" :style="{
left: Math.min(btLasso.x1, btLasso.x2) + 'px',
top: Math.min(btLasso.y1, btLasso.y2) + 'px',
width: Math.abs(btLasso.x2 - btLasso.x1) + 'px',
height: Math.abs(btLasso.y2 - btLasso.y1) + 'px'
}"></div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,80 @@
<script setup>
import { inject } from 'vue'
import { ICON } from 'src/composables/useHelpers'
import TagInput from 'src/components/TagInput.vue'
const props = defineProps({ modelValue: Object }) // { job, subject, address, note, duration, priority, tags, latitude, longitude }
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const store = inject('store')
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
const getTagColor = inject('getTagColor')
const onCreateTag = inject('onCreateTag')
const searchAddr = inject('searchAddr')
const addrResults = inject('addrResults')
const selectAddr = inject('selectAddr')
function close () { emit('update:modelValue', null); emit('cancel') }
</script>
<template>
<div v-if="modelValue" class="sb-overlay" @click.self="close">
<div class="sb-modal sb-modal-wo">
<div class="sb-modal-hdr">
<span> Modifier la job</span>
<button class="sb-rp-close" @click="close"></button>
</div>
<div class="sb-modal-body sb-wo-body">
<div class="sb-wo-form">
<div class="sb-form-row">
<label class="sb-form-lbl">Titre</label>
<input class="sb-form-input" v-model="modelValue.subject" autofocus />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Adresse</label>
<div class="sb-addr-wrap">
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple"
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
<div v-if="addrResults.length" class="sb-addr-dropdown">
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
@mousedown.prevent="selectAddr(a, modelValue)">
<strong>{{ a.address_full }}</strong>
<span v-if="a.code_postal" class="sb-addr-cp">{{ a.code_postal }}</span>
<span v-if="a.ville" class="sb-addr-city">{{ a.ville }}</span>
</div>
</div>
</div>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Note</label>
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Durée (h)</label>
<input type="number" class="sb-form-input" v-model.number="modelValue.duration" min="0.25" max="24" step="0.25" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Priorité</label>
<select class="sb-form-sel" v-model="modelValue.priority">
<option value="low">Basse</option>
<option value="medium">Moyenne</option>
<option value="high">Haute</option>
</select>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Tags / Skills</label>
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
</div>
</div>
<div v-if="modelValue.latitude" class="sb-wo-minimap">
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
alt="Carte" class="sb-minimap-img" />
</div>
</div>
<div class="sb-modal-ftr">
<button class="sbf-primary-btn" @click="emit('confirm')"> Enregistrer</button>
<button class="sb-rp-btn" @click="close">Annuler</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
import { inject } from 'vue'
import { SVC_COLORS } from 'src/composables/useHelpers'
const props = defineProps({
visible: Boolean,
panelW: Number,
selectedTechId: String,
geoFixJob: Object,
mapContainer: Object, // template ref
})
const emit = defineEmits([
'close', 'resize-start', 'cancel-geofix',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
</script>
<template>
<template v-if="visible">
<div class="sb-map-backdrop" @click="emit('close')"></div>
<div class="sb-map-panel" @click.stop="()=>{}" :style="`width:${panelW}px;min-width:${panelW}px`">
<div class="sb-map-resize-handle" @mousedown.prevent="emit('resize-start', $event)"></div>
<div class="sb-map-bar" :class="{ 'sb-map-bar-geofix': geoFixJob }">
<span class="sb-map-title">Carte</span>
<template v-if="geoFixJob">
<span class="sb-geofix-hint">📍 Cliquer sur la carte pour placer <strong>{{ geoFixJob.subject }}</strong></span>
<button class="sb-geofix-cancel" @click="emit('cancel-geofix')"> Annuler</button>
</template>
<template v-else>
<span v-if="selectedTechId" class="sb-map-tech"
:style="'color:'+TECH_COLORS[store.technicians.find(t=>t.id===selectedTechId)?.colorIdx||0]">
{{ store.technicians.find(t=>t.id===selectedTechId)?.fullName }}
<span class="sb-map-route-hint">· Glisser une job sur le trajet</span>
</span>
<span v-else class="sb-map-hint">Cliquer un technicien pour voir son trajet</span>
<button class="sb-map-close" @click="emit('close')"></button>
</template>
</div>
<div class="sb-map-legend">
<div v-for="(col, lbl) in SVC_COLORS" :key="lbl" class="sb-legend-item">
<span class="sb-legend-dot" :style="'background:'+col"></span>{{ lbl }}
</div>
</div>
<div ref="mapContainer" class="sb-map"></div>
</div>
</template>
</template>

View File

@ -0,0 +1,72 @@
<script setup>
import { inject, computed } from 'vue'
import { localDateStr, startOfWeek, jobSpansDate } from 'src/composables/useHelpers'
const props = defineProps({
anchorDate: Date,
filteredResources: Array,
todayStr: String,
})
const emit = defineEmits(['go-to-day', 'select-tech'])
const TECH_COLORS = inject('TECH_COLORS')
function isDayToday (d) { return localDateStr(d) === props.todayStr }
const monthWeeks = computed(() => {
const first = new Date(props.anchorDate.getFullYear(), props.anchorDate.getMonth(), 1)
const last = new Date(props.anchorDate.getFullYear(), props.anchorDate.getMonth() + 1, 0)
const start = startOfWeek(first)
const end = new Date(last)
const dow = end.getDay()
if (dow !== 0) end.setDate(end.getDate() + (7 - dow))
const weeks = []; let cur = new Date(start)
while (cur <= end) {
const week = []
for (let i = 0; i < 7; i++) { week.push(new Date(cur)); cur.setDate(cur.getDate() + 1) }
weeks.push(week)
}
return weeks
})
function techsActiveOnDay (dateStr) {
return props.filteredResources.filter(tech =>
tech.queue.some(j => jobSpansDate(j, dateStr)) ||
(tech.assistJobs || []).some(j => jobSpansDate(j, dateStr) && j.assistants.find(a => a.techId === tech.id)?.pinned)
)
}
function dayJobCount (dateStr) {
const jobIds = new Set()
props.filteredResources.forEach(t => t.queue.filter(j => jobSpansDate(j, dateStr)).forEach(j => jobIds.add(j.id)))
return jobIds.size
}
</script>
<template>
<div class="sb-month-wrap">
<div class="sb-month-dow-hdr">
<div v-for="wd in ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim']" :key="wd" class="sb-month-dow">{{ wd }}</div>
</div>
<div v-for="(week, wi) in monthWeeks" :key="wi" class="sb-month-week">
<div v-for="day in week" :key="localDateStr(day)"
class="sb-month-day"
:class="{ 'sb-month-day-out': day.getMonth() !== anchorDate.getMonth(), 'sb-month-day-today': isDayToday(day) }"
@click="emit('go-to-day', day)">
<div class="sb-month-day-num">{{ day.getDate() }}</div>
<div class="sb-month-avatars">
<div v-for="tech in techsActiveOnDay(localDateStr(day))" :key="tech.id"
class="sb-month-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]"
:title="tech.fullName + ' — ' + tech.queue.filter(j=>jobSpansDate(j,localDateStr(day))).length + ' job(s)'"
@click.stop="emit('select-tech', tech)">
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</div>
</div>
<div v-if="dayJobCount(localDateStr(day))" class="sb-month-job-count">
{{ dayJobCount(localDateStr(day)) }} job{{ dayJobCount(localDateStr(day))>1?'s':'' }}
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,104 @@
<script setup>
import { inject } from 'vue'
import { fmtDur, prioLabel, prioClass, ICON } from 'src/composables/useHelpers'
import TagInput from 'src/components/TagInput.vue'
const props = defineProps({
panel: Object, // { mode, data: { job, tech } } or null
})
const emit = defineEmits([
'close', 'edit', 'move', 'geofix', 'unassign',
'set-end-date', 'remove-assistant', 'assign-pending',
'update-tags',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const getTagColor = inject('getTagColor')
const onCreateTag = inject('onCreateTag')
</script>
<template>
<transition name="sb-slide-right">
<aside v-if="panel" class="sb-right">
<div class="sb-rp-hdr">
<span class="sb-rp-title">{{ {details:'Détails',pending:'Demande entrante'}[panel.mode] || 'Détails' }}</span>
<button class="sb-rp-close" @click="emit('close')"></button>
</div>
<!-- JOB DETAILS -->
<template v-if="panel.mode==='details'">
<div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+jobColor(panel.data?.job||{})"></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Titre</span><strong>{{ panel.data?.job?.subject }}</strong></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.job?.address || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Durée</span>{{ fmtDur(panel.data?.job?.duration) }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Priorité</span>
<span :class="prioClass(panel.data?.job?.priority)">{{ prioLabel(panel.data?.job?.priority) }}</span>
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Technicien</span>{{ panel.data?.tech?.fullName || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Date planifiée</span>
{{ panel.data?.job?.scheduledDate || '—' }}
<span v-if="panel.data?.job?.endDate"> {{ panel.data.job.endDate }}</span>
</div>
<div v-if="panel.data?.job?.assignedTech" class="sb-rp-field">
<span class="sb-rp-lbl">Date de fin</span>
<input type="date" class="sb-form-input" :value="panel.data?.job?.endDate || ''"
@change="emit('set-end-date', panel.data.job, $event.target.value)" style="margin-top:2px" />
</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Statut</span>{{ panel.data?.job?.status }}</div>
<div v-if="panel.data?.job?.note" class="sb-rp-field"><span class="sb-rp-lbl">Note</span>{{ panel.data.job.note }}</div>
<div class="sb-rp-field">
<span class="sb-rp-lbl">Tags</span>
<TagInput v-if="panel.data?.job"
:model-value="panel.data.job.tags || []"
@update:model-value="v => emit('update-tags', panel.data.job, v)"
:all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
</div>
<div v-if="panel.data?.job?.assistants?.length" class="sb-rp-field">
<span class="sb-rp-lbl">Assistants</span>
<div v-for="a in panel.data.job.assistants" :key="a.techId" style="display:flex;align-items:center;gap:6px;margin-top:3px">
<span class="sb-assist-badge" :style="'background:'+TECH_COLORS[store.technicians.find(t=>t.id===a.techId)?.colorIdx||0]">
{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</span>
<span style="font-size:0.72rem">{{ a.techName }} · {{ fmtDur(a.duration) }}{{ a.note ? ' · '+a.note : '' }}</span>
<button style="margin-left:auto;background:none;border:none;color:#ef4444;cursor:pointer;font-size:0.7rem"
@click="emit('remove-assistant', panel.data.job.id, a.techId)"></button>
</div>
</div>
</div>
<div class="sb-rp-actions">
<button class="sb-rp-primary" @click="emit('edit', panel.data.job)"> Modifier</button>
<button class="sb-rp-btn" @click="emit('move', panel.data.job, panel.data.tech?.id)"> Déplacer / Réassigner</button>
<button class="sb-rp-btn" @click="emit('geofix', panel.data.job)">📍 Géofixer sur la carte</button>
<button v-if="panel.data?.job?.assignedTech" class="sb-rp-btn sb-ctx-warn" @click="emit('unassign', panel.data.job)"> Désaffecter</button>
</div>
</template>
<!-- PENDING REQUEST -->
<template v-if="panel.mode==='pending'">
<div class="sb-rp-body">
<div class="sb-rp-color-bar" :style="'background:'+(panel.data?.urgency==='urgent'?'#ef4444':'#f59e0b')"></div>
<div v-if="panel.data?.urgency==='urgent'" class="sb-rp-urgent-tag">🚨 Urgent</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Client</span><strong>{{ panel.data?.customer_name }}</strong></div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Téléphone</span>{{ panel.data?.phone || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Service</span>{{ panel.data?.service_type }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Problème</span>{{ panel.data?.problem_type || '—' }}</div>
<div class="sb-rp-field"><span class="sb-rp-lbl">Adresse</span>{{ panel.data?.address }}</div>
<div v-if="panel.data?.budget_label" class="sb-rp-field"><span class="sb-rp-lbl">Budget</span>{{ panel.data?.budget_label }}</div>
<div class="sbf-title" style="margin-top:0.75rem">Assigner à</div>
<div class="sb-assign-grid">
<button v-for="tech in store.technicians" :key="tech.id"
class="sb-assign-btn" :style="'border-color:'+TECH_COLORS[tech.colorIdx]"
@click="emit('assign-pending', tech.id)">
<span class="sb-assign-dot" :style="'background:'+TECH_COLORS[tech.colorIdx]"></span>
{{ tech.fullName }}
</button>
</div>
</div>
</template>
</aside>
</transition>
</template>

View File

@ -0,0 +1,128 @@
<script setup>
import { inject } from 'vue'
import { ICON, fmtDur, shortAddr, jobStatusIcon, dayLoadColor, stOf } from 'src/composables/useHelpers'
const props = defineProps({
tech: Object,
segments: Array, // from techDayJobsWithTravel
hourTicks: Array,
totalW: Number,
pxPerHr: Number,
hStart: Number,
hEnd: Number,
rowH: Number,
isSelected: Boolean,
isElevated: Boolean,
dropGhostX: { type: Number, default: null },
})
const emit = defineEmits([
'select-tech', 'ctx-tech', 'drag-tech-start', 'reorder-drop',
'timeline-dragover', 'timeline-dragleave', 'timeline-drop',
'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
'assist-ctx', 'hover-job', 'unhover-job',
'block-move', 'block-resize',
])
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const selectedJob = inject('selectedJob')
const hoveredJobId = inject('hoveredJobId')
const periodLoadH = inject('periodLoadH')
const getTagColor = inject('getTagColor')
const isJobMultiSelected = inject('isJobMultiSelected')
</script>
<template>
<div class="sb-row" :class="{ 'sb-row-sel': isSelected, 'sb-row-elevated': isElevated }"
:style="'height:'+rowH+'px'" :data-tech-id="tech.id">
<!-- Resource cell -->
<div class="sb-res-cell" @click="emit('select-tech', tech)" @contextmenu.prevent="emit('ctx-tech', $event, tech)"
draggable="true" @dragstart="emit('drag-tech-start', $event, tech)"
@dragover.prevent="()=>{}" @drop.prevent="emit('reorder-drop', $event, tech)">
<div class="sb-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]">
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</div>
<div class="sb-res-info">
<div class="sb-res-name">{{ tech.fullName }}
<span v-for="t in (tech.tags||[]).slice(0,3)" :key="t" class="sb-res-tag-dot" :style="'background:'+getTagColor(t)" :title="t"></span>
</div>
<div class="sb-res-sub">
<span class="sb-st" :class="stOf(tech).cls">{{ stOf(tech).label }}</span>
<span class="sb-load">{{ fmtDur(periodLoadH(tech)) }}</span>
</div>
<div class="sb-util-bar">
<div class="sb-util-fill" :style="{ width: Math.min(100,periodLoadH(tech)/8*100)+'%', background: dayLoadColor(periodLoadH(tech)/8) }"></div>
</div>
</div>
</div>
<!-- Timeline -->
<div class="sb-timeline" :style="'width:'+totalW+'px'"
@dragover.prevent="emit('timeline-dragover', $event, tech)"
@dragleave="emit('timeline-dragleave', $event)"
@drop.prevent="emit('timeline-drop', $event, tech)">
<!-- Hour guides -->
<div v-for="tick in hourTicks.filter(t=>!t.isDay)" :key="'hg-'+tick.x"
class="sb-hour-guide" :style="'left:'+tick.x+'px'"></div>
<template v-for="h in (hEnd - hStart)" :key="'qg-'+h">
<div v-for="q in [1,2,3]" :key="'q-'+h+'-'+q" class="sb-quarter-guide"
:style="'left:'+(((h + q*0.25) * pxPerHr))+'px'"></div>
</template>
<div class="sb-capacity-line" :style="'left:'+((16 - hStart) * pxPerHr)+'px'" title="8h"></div>
<div v-if="dropGhostX!=null" class="sb-drop-line" :style="'left:'+dropGhostX+'px'"></div>
<template v-for="seg in segments" :key="seg.type+'-'+seg.job.id+(seg.isAssist?'-a':'')+(seg.type==='travel'?'-t':'')">
<!-- Travel -->
<div v-if="seg.type==='travel'" class="sb-travel-trail"
:class="[seg.fromRoute?'sb-travel-route':'sb-travel-est', seg.isAssist?'sb-travel-assist':'']"
:style="{ ...seg.style, background:seg.color+(seg.fromRoute?'40':'22'), borderLeft:'2px solid '+seg.color+(seg.fromRoute?'88':'44') }">
<span v-if="parseFloat(seg.style.width)>36" class="sb-travel-lbl">{{ seg.fromRoute?'':'~' }}{{ seg.travelMin }}min</span>
</div>
<!-- Assist block -->
<div v-else-if="seg.type==='assist'" class="sb-block sb-block-assist"
:class="{ 'sb-block-assist-pinned':seg.assistPinned, 'sb-block-sel':selectedJob?.isAssist&&selectedJob?.job?.id===seg.job.id&&selectedJob?.assistTechId===seg.assistTechId, 'sb-block-linked':(selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id }"
:style="{ ...seg.style, background:((selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist)||hoveredJobId===seg.job.id)?jobColor(seg.job)+'dd':(seg.assistPinned?jobColor(seg.job)+'99':jobColor(seg.job)+'44') }"
:draggable="seg.assistPinned?'true':'false'"
@dragstart="seg.assistPinned && emit('job-dragstart',$event,seg.job,tech.id,true)"
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
@click.stop="emit('job-click',seg.job,seg.job.assignedTech,true,seg.assistTechId,$event)"
@dblclick.stop="emit('job-dblclick',seg.job)"
@contextmenu.prevent="emit('assist-ctx',$event,seg.job,seg.assistTechId)">
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
<div class="sb-block-inner">
<div class="sb-block-title"><span v-if="seg.assistPinned" class="sb-block-pin" title="Priorisé" v-html="ICON.pin"></span>{{ seg.assistNote||seg.job.subject }}</div>
<div class="sb-block-meta">{{ fmtDur(seg.assistDur) }} · {{ seg.job.subject }}{{ seg.job.address?' · '+shortAddr(seg.job.address):'' }}</div>
</div>
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'assist',seg.assistTechId)"></div>
</div>
<!-- Job block -->
<div v-else class="sb-block"
:class="{ 'sb-block-done':seg.job.status==='completed', 'sb-block-sel':selectedJob?.job?.id===seg.job.id&&!selectedJob?.isAssist, 'sb-block-multi':isJobMultiSelected(seg.job.id), 'sb-block-linked':selectedJob?.job?.id===seg.job.id&&selectedJob?.isAssist, 'sb-block-team':seg.job.assistants?.length }"
:style="{ ...seg.style, background:jobColor(seg.job)+'dd' }"
:data-job-id="seg.job.id" draggable="true"
@dragstart="emit('job-dragstart',$event,seg.job,tech.id,false)"
@mouseenter="emit('hover-job',seg.job.id)" @mouseleave="emit('unhover-job')"
@click.stop="emit('job-click',seg.job,tech.id,false,null,$event)"
@dblclick.stop="emit('job-dblclick',seg.job)"
@contextmenu.prevent="emit('job-ctx',$event,seg.job,tech.id)">
<div class="sb-move-handle" @mousedown.stop.prevent="emit('block-move',$event,seg.job,$event.target.parentElement)" title="Déplacer"></div>
<div class="sb-block-color-bar" :style="'background:'+jobColor(seg.job)"></div>
<div class="sb-block-inner">
<div class="sb-block-title"><span v-if="seg.pinned" class="sb-block-pin" title="Heure fixée" v-html="ICON.pin"></span>{{ seg.job.subject }}</div>
<div class="sb-block-meta">{{ seg.pinnedTime||'' }}{{ seg.pinnedTime?' · ':'' }}{{ fmtDur(seg.job.duration) }}</div>
<div v-if="seg.job.address" class="sb-block-addr"><span v-html="ICON.mapPin"></span> {{ shortAddr(seg.job.address) }}</div>
</div>
<div v-if="seg.job.assistants?.length" class="sb-block-assistants">
<span v-for="a in seg.job.assistants" :key="a.techId" class="sb-assist-badge"
:style="'background:'+TECH_COLORS[$root?.$store?.technicians?.find(t=>t.id===a.techId)?.colorIdx||0]"
:title="a.techName">{{ (a.techName||'').split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</span>
</div>
<span v-if="jobStatusIcon(seg.job).svg" class="sb-block-status-icon" :class="jobStatusIcon(seg.job).cls" :title="seg.job.status" v-html="jobStatusIcon(seg.job).svg"></span>
<div class="sb-resize-handle" @mousedown.stop.prevent="emit('block-resize',$event,seg.job,'job')"></div>
</div>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,112 @@
<script setup>
import { inject } from 'vue'
import {
localDateStr, fmtDur, shortAddr, dayLoadColor, stOf,
ICON, jobSpansDate,
} from 'src/composables/useHelpers'
const props = defineProps({
filteredResources: Array,
dayColumns: Array,
selectedTechId: String,
dropGhost: Object,
todayStr: String,
})
const emit = defineEmits([
'go-to-day', 'select-tech', 'ctx-tech',
'tech-reorder-start', 'tech-reorder-drop',
'cal-drop', 'job-dragstart', 'job-click', 'job-dblclick', 'job-ctx',
'clear-filters',
])
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const selectedJob = inject('selectedJob')
const isJobMultiSelected = inject('isJobMultiSelected')
const getTagColor = inject('getTagColor')
function isDayToday (d) { return localDateStr(d) === props.todayStr }
defineExpose({ isDayToday })
</script>
<template>
<div class="sb-grid sb-grid-cal">
<!-- Header -->
<div class="sb-grid-hdr">
<div class="sb-res-hdr">Ressources <span class="sbf-count">{{ filteredResources.length }}</span></div>
<div class="sb-cal-hdr">
<div v-for="d in dayColumns" :key="'ch-'+localDateStr(d)"
class="sb-cal-hdr-cell" :class="{ 'sb-col-today': isDayToday(d) }"
style="cursor:pointer" @click="emit('go-to-day', d)">
<span class="sb-cal-wd">{{ d.toLocaleDateString('fr-CA',{weekday:'short'}) }}</span>
<span class="sb-cal-dn" :class="{ 'sb-today-bubble': isDayToday(d) }">{{ d.getDate() }}</span>
</div>
</div>
</div>
<!-- Loading / empty -->
<div v-if="store.loading" class="sb-loading-row">Chargement</div>
<div v-else-if="!filteredResources.length" class="sb-empty-row">
Aucune ressource.
<button class="sbf-primary-btn" style="display:inline-block;margin-left:0.75rem" @click="emit('clear-filters')">Réinitialiser</button>
</div>
<!-- Rows -->
<div v-for="tech in filteredResources" :key="tech.id"
class="sb-row sb-row-cal" :class="{ 'sb-row-sel': selectedTechId===tech.id }">
<div class="sb-res-cell" @click="emit('select-tech', tech)" @contextmenu.prevent="emit('ctx-tech', $event, tech)"
draggable="true" @dragstart="emit('tech-reorder-start', $event, tech)"
@dragover.prevent="()=>{}" @drop.prevent="emit('tech-reorder-drop', $event, tech)">
<div class="sb-avatar" :style="'background:'+TECH_COLORS[tech.colorIdx]">
{{ tech.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</div>
<div class="sb-res-info">
<div class="sb-res-name">{{ tech.fullName }}
<span v-for="t in (tech.tags||[]).slice(0,3)" :key="t" class="sb-res-tag-dot" :style="'background:'+getTagColor(t)" :title="t"></span>
</div>
<div class="sb-res-sub">
<span class="sb-st" :class="stOf(tech).cls">{{ stOf(tech).label }}</span>
</div>
</div>
</div>
<div class="sb-cal-row">
<div v-for="d in dayColumns" :key="localDateStr(d)"
class="sb-cal-cell" :class="{ 'sb-bg-today': isDayToday(d), 'sb-bg-alt': dayColumns.indexOf(d)%2===1 }"
:data-date-str="localDateStr(d)"
@dblclick="emit('go-to-day', d)"
@dragover.prevent="()=>{}" @dragleave="()=>{}"
@drop.prevent="emit('cal-drop', $event, tech, localDateStr(d))">
<div v-if="dropGhost?.techId===tech.id && dropGhost.dateStr===localDateStr(d)" class="sb-cal-drop"></div>
<template v-for="job in [...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))), ...(tech.assistJobs||[]).filter(j=>jobSpansDate(j,localDateStr(d))&&j.assistants.find(a=>a.techId===tech.id)?.pinned).map(j=>({...j,_isAssistChip:true,_assistDur:j.assistants.find(a=>a.techId===tech.id)?.duration||j.duration}))]" :key="job.id+(job._isAssistChip?'-a':'')">
<div class="sb-chip"
:class="{ 'sb-chip-sel': selectedJob?.job?.id===job.id, 'sb-chip-multi': isJobMultiSelected(job.id), 'sb-chip-assist': job._isAssistChip }"
:data-job-id="job.id"
:style="'border-left-color:'+jobColor(job)+';background:'+jobColor(job)+'cc;color:#fff'"
:draggable="job._isAssistChip ? 'false' : 'true'"
@dragstart="!job._isAssistChip && emit('job-dragstart', $event, job, tech.id)"
@click.stop="emit('job-click', job, tech.id, false, null, $event)"
@dblclick.stop="emit('job-dblclick', job)"
@contextmenu.prevent="emit('job-ctx', $event, job, tech.id)">
<div class="sb-chip-line1">
<span v-if="job.priority==='high'" class="sb-chip-urgent"></span>
<span v-if="job._isAssistChip" class="sb-chip-assist-tag" v-html="ICON.pin"></span>
{{ job.subject }}
</div>
<div v-if="job.address" class="sb-chip-line2"><span v-html="ICON.mapPin"></span> {{ shortAddr(job.address) }}</div>
</div>
</template>
<!-- Day load bar -->
<div v-if="[...tech.queue.filter(j=>jobSpansDate(j,localDateStr(d)))].length" class="sb-day-load">
<div class="sb-day-load-track">
<div class="sb-day-load-fill" :style="{ width: Math.min(100, tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/8*100)+'%', background: dayLoadColor(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)/8) }"></div>
</div>
<span class="sb-day-load-label">{{ fmtDur(tech.queue.filter(j=>jobSpansDate(j,localDateStr(d))).reduce((a,j)=>a+(parseFloat(j.duration)||0),0)) }}/8h</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,93 @@
<script setup>
import { inject } from 'vue'
import TagInput from 'src/components/TagInput.vue'
const props = defineProps({ modelValue: Object })
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const store = inject('store')
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
const getTagColor = inject('getTagColor')
const onCreateTag = inject('onCreateTag')
const searchAddr = inject('searchAddr')
const addrResults = inject('addrResults')
const selectAddr = inject('selectAddr')
function close () { emit('update:modelValue', null); emit('cancel') }
</script>
<template>
<div v-if="modelValue" class="sb-overlay" @click.self="close">
<div class="sb-modal sb-modal-wo">
<div class="sb-modal-hdr">
<span>+ Nouveau work order</span>
<button class="sb-rp-close" @click="close"></button>
</div>
<div class="sb-modal-body sb-wo-body">
<div class="sb-wo-form">
<div class="sb-form-row">
<label class="sb-form-lbl">Titre *</label>
<input class="sb-form-input" v-model="modelValue.subject" placeholder="Ex: Remplacement modem" autofocus />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Adresse</label>
<div class="sb-addr-wrap">
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple, Montréal"
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
<div v-if="addrResults.length" class="sb-addr-dropdown">
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
@mousedown.prevent="selectAddr(a, modelValue)">
<strong>{{ a.address_full }}</strong>
<span v-if="a.code_postal" class="sb-addr-cp">{{ a.code_postal }}</span>
<span v-if="a.ville" class="sb-addr-city">{{ a.ville }}</span>
</div>
</div>
</div>
<div v-if="modelValue.latitude" class="sb-addr-confirmed">
{{ modelValue.ville || '' }} {{ modelValue.code_postal || '' }}
</div>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Note</label>
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Durée (h)</label>
<input type="number" class="sb-form-input" v-model.number="modelValue.duration_h" min="0.5" max="12" step="0.5" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Priorité</label>
<select class="sb-form-sel" v-model="modelValue.priority">
<option value="low">Basse</option>
<option value="medium">Moyenne</option>
<option value="high">Haute</option>
</select>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Tags / Skills</label>
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Technicien</label>
<select class="sb-form-sel" v-model="modelValue.techId">
<option value=""> Non assigné </option>
<option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option>
</select>
</div>
<div class="sb-form-row" v-if="modelValue.techId">
<label class="sb-form-lbl">Date planifiée</label>
<input type="date" class="sb-form-input" v-model="modelValue.date" />
</div>
</div>
<div v-if="modelValue.latitude" class="sb-wo-minimap">
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
alt="Carte" class="sb-minimap-img" />
</div>
</div>
<div class="sb-modal-ftr">
<button class="sbf-primary-btn" :disabled="!modelValue.subject?.trim()" @click="emit('confirm')"> Créer</button>
<button class="sb-rp-btn" @click="close">Annuler</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,557 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { fetchSettings, saveSettings, createDocType } from 'src/api/settings'
import { MAPBOX_TOKEN } from 'src/config/erpnext'
const router = useRouter()
// Valeurs par défaut (pré-remplissage)
const form = ref({
// ERPNext
erp_url: window.location.origin,
erp_api_key: '',
erp_api_secret: '',
// Mapbox
mapbox_token: MAPBOX_TOKEN,
// Twilio
twilio_account_sid: '',
twilio_auth_token: '',
twilio_from_number: '',
// Stripe
stripe_mode: 'test',
stripe_publishable_key: '',
stripe_secret_key: '',
stripe_webhook_secret:'',
// n8n
n8n_url: 'http://localhost:5678',
n8n_api_key: '',
n8n_webhook_base: 'http://localhost:5678/webhook',
// Templates SMS
sms_enroute: 'Bonjour {client_name}, votre technicien {tech_name} est en route et arrivera dans environ {eta} minutes. Réf: {job_id}',
sms_completed: 'Bonjour {client_name}, votre service ({job_id}) a été complété avec succès. Merci de votre confiance !',
sms_assigned: 'Nouveau job assigné : {job_id} — {client_name}, {address}. Durée estimée : {duration}h.',
})
// État page
const loading = ref(true)
const docTypeError = ref(false)
const initStatus = ref(null) // null | 'creating' | 'done' | 'error'
const initError = ref('')
const saveStatus = ref(null) // null | 'saving' | 'saved' | 'error'
const saveError = ref('')
// Statuts de connexion
const st = ref({ erp: null, mapbox: null, twilio: null, stripe: null, n8n: null })
// null | 'testing' | 'ok' | 'error' | 'warn'
// Révéler / masquer les mots de passe
const show = ref({
erp_api_secret: false, twilio_auth_token: false,
stripe_secret_key: false, stripe_webhook_secret: false, n8n_api_key: false,
})
// Chargement initial
onMounted(async () => {
try {
const data = await fetchSettings()
Object.keys(form.value).forEach(k => {
if (data[k] !== undefined && data[k] !== null && data[k] !== '') {
form.value[k] = data[k]
}
})
} catch (e) {
if (e.message === 'DOCTYPE_NOT_FOUND') docTypeError.value = true
} finally {
loading.value = false
}
})
// Sauvegarde
async function init () {
initStatus.value = 'creating'
initError.value = ''
try {
await createDocType()
initStatus.value = 'done'
docTypeError.value = false
// Reload settings after creation
const data = await fetchSettings().catch(() => ({}))
Object.keys(form.value).forEach(k => {
if (data[k] !== undefined && data[k] !== null && data[k] !== '') form.value[k] = data[k]
})
} catch (e) {
initStatus.value = 'error'
initError.value = e.message
}
}
async function save () {
saveStatus.value = 'saving'
saveError.value = ''
try {
await saveSettings(form.value)
saveStatus.value = 'saved'
setTimeout(() => { saveStatus.value = null }, 2500)
} catch (e) {
if (e.message === 'DOCTYPE_NOT_FOUND') { docTypeError.value = true }
saveStatus.value = 'error'
saveError.value = e.message === 'DOCTYPE_NOT_FOUND'
? 'DocType manquant — cliquez sur Initialiser'
: e.message
}
}
// Tests de connexion
async function testErp () {
st.value.erp = 'testing'
try {
const r = await fetch(`${form.value.erp_url}/api/method/frappe.auth.get_logged_user`, { credentials: 'include' })
const d = await r.json()
st.value.erp = (d.message && d.message !== 'Guest') ? 'ok' : 'error'
} catch { st.value.erp = 'error' }
}
async function testMapbox () {
st.value.mapbox = 'testing'
try {
const r = await fetch(`https://api.mapbox.com/tokens/v2?access_token=${form.value.mapbox_token}`)
st.value.mapbox = r.ok ? 'ok' : 'error'
} catch { st.value.mapbox = 'error' }
}
function testTwilio () {
const sid = form.value.twilio_account_sid
if (!sid) { st.value.twilio = 'warn'; return }
st.value.twilio = (sid.startsWith('AC') && sid.length === 34) ? 'ok' : 'error'
}
function testStripe () {
const key = form.value.stripe_secret_key
if (!key) { st.value.stripe = 'warn'; return }
st.value.stripe = (key.startsWith('sk_test_') || key.startsWith('sk_live_')) ? 'ok' : 'error'
}
async function testN8n () {
st.value.n8n = 'testing'
try {
const r = await fetch(`${form.value.n8n_url}/healthz`)
st.value.n8n = r.ok ? 'ok' : 'error'
} catch { st.value.n8n = 'error' }
}
// Helpers
function stLabel (s) {
return { ok: '● Connecté', error: '✗ Erreur', warn: '○ Non configuré', testing: '… Test…' }[s] ?? '○ Non testé'
}
function stClass (s) {
return { ok: 'st-ok', error: 'st-error', warn: 'st-warn', testing: 'st-testing' }[s] ?? 'st-none'
}
</script>
<template>
<div class="admin-root">
<!-- Header -->
<div class="admin-header">
<div class="admin-header-left">
<button class="btn-back" @click="router.push('/')"> Dispatch</button>
<div class="admin-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
Paramètres de la plateforme
</div>
</div>
<div class="admin-header-right">
<span v-if="saveStatus === 'saved'" class="save-feedback ok"> Sauvegardé</span>
<span v-if="saveStatus === 'error'" class="save-feedback err"> {{ saveError }}</span>
<button class="btn-save" :disabled="saveStatus === 'saving' || docTypeError" @click="save">
{{ saveStatus === 'saving' ? 'Sauvegarde…' : 'Sauvegarder' }}
</button>
</div>
</div>
<!-- DocType manquant bouton d'initialisation -->
<div v-if="docTypeError" class="doctype-error">
<strong> Première utilisation DocType non initialisé</strong>
<p>
Le DocType <code>Dispatch Settings</code> n'existe pas encore dans ERPNext.
Cliquez sur le bouton ci-dessous pour le créer automatiquement.
</p>
<div style="display:flex;align-items:center;gap:1rem;margin-top:0.75rem;flex-wrap:wrap;">
<button class="btn-init" :disabled="initStatus === 'creating'" @click="init">
{{ initStatus === 'creating' ? '⏳ Création en cours…' : '⚡ Initialiser dans ERPNext' }}
</button>
<span v-if="initStatus === 'done'" style="color:#10b981;font-weight:700;"> DocType créé paramètres disponibles</span>
<span v-if="initStatus === 'error'" style="color:#f43f5e;font-size:0.8rem;"> {{ initError }}</span>
</div>
<p style="margin-top:0.75rem;font-size:0.78rem;color:#64748b;">
Requiert le rôle <strong>System Manager</strong> dans ERPNext.
</p>
</div>
<!-- Chargement -->
<div v-if="loading" class="loading-state">Chargement des paramètres</div>
<!-- Formulaire -->
<div v-else-if="!docTypeError" class="admin-body">
<!-- ERPNext -->
<div class="settings-card">
<div class="card-header">
<span class="card-icon">🔗</span>
<span class="card-title">ERPNext / Frappe</span>
<span class="st-badge" :class="stClass(st.erp)">{{ stLabel(st.erp) }}</span>
<button class="btn-test" @click="testErp">Tester</button>
</div>
<div class="fields">
<div class="field">
<label>URL du serveur</label>
<input v-model="form.erp_url" type="text" placeholder="http://localhost:8080" />
<span class="field-hint">Vide = même origine que l'app</span>
</div>
<div class="field-row">
<div class="field">
<label>API Key</label>
<input v-model="form.erp_api_key" type="text" placeholder="Profil → API Access → API Key" />
</div>
<div class="field">
<label>API Secret</label>
<div class="input-pw">
<input v-model="form.erp_api_secret" :type="show.erp_api_secret ? 'text' : 'password'" placeholder="••••••••••••••" />
<button class="btn-reveal" @click="show.erp_api_secret = !show.erp_api_secret">{{ show.erp_api_secret ? '🙈' : '👁' }}</button>
</div>
</div>
</div>
</div>
</div>
<!-- Mapbox -->
<div class="settings-card">
<div class="card-header">
<span class="card-icon">🗺</span>
<span class="card-title">Mapbox</span>
<span class="st-badge" :class="stClass(st.mapbox)">{{ stLabel(st.mapbox) }}</span>
<button class="btn-test" @click="testMapbox">Tester</button>
</div>
<div class="fields">
<div class="field">
<label>Token public (pk_)</label>
<input v-model="form.mapbox_token" type="text" placeholder="pk.eyJ1Ij…" />
<span class="field-hint">Token public visible navigateur. Limitez le scope dans le dashboard Mapbox.</span>
</div>
</div>
</div>
<!-- Twilio -->
<div class="settings-card">
<div class="card-header">
<span class="card-icon">💬</span>
<span class="card-title">Twilio SMS</span>
<span class="st-badge" :class="stClass(st.twilio)">{{ stLabel(st.twilio) }}</span>
<button class="btn-test" @click="testTwilio">Vérifier</button>
</div>
<div class="fields">
<div class="field-row">
<div class="field">
<label>Account SID</label>
<input v-model="form.twilio_account_sid" type="text" placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
<span class="field-hint">Commence par AC, 34 caractères console.twilio.com</span>
</div>
<div class="field">
<label>Auth Token</label>
<div class="input-pw">
<input v-model="form.twilio_auth_token" :type="show.twilio_auth_token ? 'text' : 'password'" placeholder="••••••••••••••" />
<button class="btn-reveal" @click="show.twilio_auth_token = !show.twilio_auth_token">{{ show.twilio_auth_token ? '🙈' : '👁' }}</button>
</div>
</div>
</div>
<div class="field" style="max-width:260px">
<label>Numéro expéditeur</label>
<input v-model="form.twilio_from_number" type="text" placeholder="+15141234567" />
<span class="field-hint">Format E.164 numéro Twilio acheté</span>
</div>
</div>
</div>
<!-- Stripe -->
<div class="settings-card">
<div class="card-header">
<span class="card-icon">💳</span>
<span class="card-title">Stripe Paiements</span>
<span class="st-badge" :class="stClass(st.stripe)">{{ stLabel(st.stripe) }}</span>
<button class="btn-test" @click="testStripe">Vérifier</button>
</div>
<div class="fields">
<div class="field" style="max-width:200px">
<label>Mode</label>
<select v-model="form.stripe_mode">
<option value="test">Test</option>
<option value="live">Production (live)</option>
</select>
</div>
<div class="field-row">
<div class="field">
<label>Clé publique (pk_)</label>
<input v-model="form.stripe_publishable_key" type="text" placeholder="pk_test_…" />
</div>
<div class="field">
<label>Clé secrète (sk_)</label>
<div class="input-pw">
<input v-model="form.stripe_secret_key" :type="show.stripe_secret_key ? 'text' : 'password'" placeholder="sk_test_…" />
<button class="btn-reveal" @click="show.stripe_secret_key = !show.stripe_secret_key">{{ show.stripe_secret_key ? '🙈' : '👁' }}</button>
</div>
</div>
<div class="field">
<label>Webhook Secret (whsec_)</label>
<div class="input-pw">
<input v-model="form.stripe_webhook_secret" :type="show.stripe_webhook_secret ? 'text' : 'password'" placeholder="whsec_…" />
<button class="btn-reveal" @click="show.stripe_webhook_secret = !show.stripe_webhook_secret">{{ show.stripe_webhook_secret ? '🙈' : '👁' }}</button>
</div>
</div>
</div>
</div>
</div>
<!-- n8n -->
<div class="settings-card">
<div class="card-header">
<span class="card-icon"></span>
<span class="card-title">n8n Automatisation</span>
<span class="st-badge" :class="stClass(st.n8n)">{{ stLabel(st.n8n) }}</span>
<button class="btn-test" @click="testN8n">Tester</button>
</div>
<div class="fields">
<div class="field-row">
<div class="field">
<label>URL n8n</label>
<input v-model="form.n8n_url" type="text" placeholder="http://localhost:5678" />
</div>
<div class="field">
<label>API Key n8n</label>
<div class="input-pw">
<input v-model="form.n8n_api_key" :type="show.n8n_api_key ? 'text' : 'password'" placeholder="••••••••••••••" />
<button class="btn-reveal" @click="show.n8n_api_key = !show.n8n_api_key">{{ show.n8n_api_key ? '🙈' : '👁' }}</button>
</div>
</div>
</div>
<div class="field">
<label>Base URL webhooks ERPNext n8n</label>
<input v-model="form.n8n_webhook_base" type="text" placeholder="http://localhost:5678/webhook" />
<span class="field-hint">Préfixe utilisé pour configurer les webhooks ERPNext. Ex: {base}/job-enroute</span>
</div>
</div>
</div>
<!-- Templates SMS -->
<div class="settings-card">
<div class="card-header">
<span class="card-icon">📝</span>
<span class="card-title">Templates SMS</span>
</div>
<div class="fields">
<span class="field-hint" style="margin-bottom:0.75rem;display:block;">
Variables disponibles : <code>{client_name}</code> <code>{tech_name}</code> <code>{job_id}</code> <code>{eta}</code> <code>{address}</code> <code>{duration}</code>
</span>
<div class="field">
<label>Technicien en route</label>
<textarea v-model="form.sms_enroute" rows="2"></textarea>
</div>
<div class="field">
<label>Service complété</label>
<textarea v-model="form.sms_completed" rows="2"></textarea>
</div>
<div class="field">
<label>Job assigné (notification technicien)</label>
<textarea v-model="form.sms_assigned" rows="2"></textarea>
</div>
</div>
</div>
<!-- Bouton bas -->
<div class="bottom-bar">
<span v-if="saveStatus === 'saved'" class="save-feedback ok"> Paramètres sauvegardés dans ERPNext</span>
<span v-if="saveStatus === 'error'" class="save-feedback err"> {{ saveError }}</span>
<button class="btn-save large" :disabled="saveStatus === 'saving'" @click="save">
{{ saveStatus === 'saving' ? 'Sauvegarde…' : 'Sauvegarder les paramètres' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Thème (reprend les variables CSS de DispatchPage) ── */
.admin-root {
min-height: 100vh;
background: var(--bg, #0f1117);
color: var(--text-primary, #f1f5f9);
font-family: 'Inter', sans-serif;
font-size: 0.875rem;
}
/* ── Header ── */
.admin-header {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--sidebar-bg, #161b27);
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
backdrop-filter: blur(12px);
}
.admin-header-left { display: flex; align-items: center; gap: 1rem; }
.admin-header-right { display: flex; align-items: center; gap: 0.75rem; }
.admin-title {
display: flex; align-items: center; gap: 0.5rem;
font-size: 1rem; font-weight: 700; color: var(--text-primary, #f1f5f9);
}
.btn-back {
background: var(--card-bg, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.08));
color: var(--text-secondary, #94a3b8);
border-radius: 6px; padding: 0.3rem 0.75rem;
cursor: pointer; font-size: 0.8rem; font-weight: 600;
transition: all 0.15s;
}
.btn-back:hover { color: var(--text-primary, #f1f5f9); border-color: var(--accent, #6366f1); }
.btn-save {
background: var(--accent, #6366f1); border: none; color: white;
border-radius: 8px; padding: 0.45rem 1.25rem;
cursor: pointer; font-size: 0.82rem; font-weight: 700;
transition: opacity 0.15s;
}
.btn-save:hover:not(:disabled) { opacity: 0.85; }
.btn-save:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-save.large { padding: 0.6rem 2rem; font-size: 0.9rem; }
.save-feedback { font-size: 0.8rem; font-weight: 600; }
.save-feedback.ok { color: #10b981; }
.save-feedback.err { color: #f43f5e; }
/* ── Body ── */
.admin-body {
max-width: 860px; margin: 0 auto;
padding: 1.5rem 1.5rem 4rem;
display: flex; flex-direction: column; gap: 1.25rem;
}
/* ── Cards ── */
.settings-card {
background: var(--card-bg, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 12px; overflow: hidden;
}
.card-header {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.9rem 1.25rem;
background: var(--sidebar-bg, #161b27);
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
}
.card-icon { font-size: 1.1rem; }
.card-title { font-size: 0.9rem; font-weight: 700; flex: 1; }
/* ── Status badges ── */
.st-badge {
font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.5rem;
border-radius: 8px; white-space: nowrap;
}
.st-ok { background: rgba(16,185,129,0.15); color: #10b981; }
.st-error { background: rgba(244,63,94,0.15); color: #f43f5e; }
.st-warn { background: rgba(245,158,11,0.15); color: #f59e0b; }
.st-testing { background: rgba(99,102,241,0.15); color: #6366f1; }
.st-none { background: rgba(148,163,184,0.1); color: #64748b; }
.btn-test {
background: var(--card-bg, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.08));
color: var(--text-secondary, #94a3b8);
border-radius: 6px; padding: 0.2rem 0.6rem;
cursor: pointer; font-size: 0.72rem; font-weight: 600;
transition: all 0.15s;
}
.btn-test:hover { border-color: var(--accent, #6366f1); color: var(--accent, #6366f1); }
/* ── Fields ── */
.fields { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
.field-row { display: flex; gap: 1rem; flex-wrap: wrap; }
.field-row .field { flex: 1; min-width: 180px; }
.field { display: flex; flex-direction: column; gap: 0.3rem; }
.field label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--text-secondary, #94a3b8);
}
.field input, .field select, .field textarea {
background: var(--bg, #0f1117);
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 6px; color: var(--text-primary, #f1f5f9);
padding: 0.45rem 0.75rem; font-size: 0.82rem;
font-family: 'Inter', monospace; outline: none;
transition: border-color 0.15s;
}
.field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--accent, #6366f1);
}
.field textarea { resize: vertical; line-height: 1.5; }
.field select { cursor: pointer; }
.field-hint { font-size: 0.7rem; color: var(--text-secondary, #64748b); line-height: 1.4; }
.field code {
font-family: monospace; font-size: 0.7rem;
background: rgba(99,102,241,0.12); color: #a5b4fc;
padding: 0.1rem 0.3rem; border-radius: 3px;
}
/* ── Password reveal ── */
.input-pw { display: flex; gap: 0; }
.input-pw input {
flex: 1; border-radius: 6px 0 0 6px;
border-right: none !important;
}
.btn-reveal {
background: var(--bg, #0f1117);
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-left: none; border-radius: 0 6px 6px 0;
padding: 0 0.55rem; cursor: pointer; font-size: 0.9rem;
transition: background 0.15s;
}
.btn-reveal:hover { background: var(--card-bg, rgba(255,255,255,0.04)); }
.btn-init {
background: var(--accent, #6366f1); border: none; color: white;
border-radius: 8px; padding: 0.55rem 1.25rem;
cursor: pointer; font-size: 0.85rem; font-weight: 700;
transition: opacity 0.15s;
}
.btn-init:hover:not(:disabled) { opacity: 0.85; }
.btn-init:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Erreur DocType ── */
.doctype-error {
max-width: 780px; margin: 2rem auto;
background: rgba(244,63,94,0.08);
border: 1px solid rgba(244,63,94,0.25);
border-radius: 12px; padding: 1.5rem;
line-height: 1.7;
}
.doctype-error strong { color: #f43f5e; display: block; margin-bottom: 0.5rem; }
.doctype-error pre {
background: rgba(0,0,0,0.4); border-radius: 8px; padding: 0.9rem 1rem;
font-size: 0.75rem; white-space: pre-wrap; overflow-x: auto;
color: #a5b4fc; margin: 0.75rem 0;
}
.doctype-error code {
font-family: monospace; font-size: 0.82rem; color: #a5b4fc;
}
/* ── Chargement ── */
.loading-state {
text-align: center; padding: 4rem;
color: var(--text-secondary, #94a3b8); font-style: italic;
}
/* ── Bottom bar ── */
.bottom-bar {
display: flex; align-items: center; justify-content: flex-end;
gap: 1rem; padding-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,583 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { MAPBOX_TOKEN } from 'src/config/erpnext'
import { createServiceRequest } from 'src/api/service-request'
const router = useRouter()
// Services télécom
const SERVICES = [
{ id: 'internet', icon: '🌐', label: 'Internet', desc: 'Connexion lente, coupures, Wi-Fi' },
{ id: 'tv', icon: '📺', label: 'Télévision', desc: 'Câble, satellite, IPTV, décodeur' },
{ id: 'telephone', icon: '📞', label: 'Téléphonie', desc: 'Résidentiel, VoIP, interphones' },
{ id: 'multi', icon: '🔧', label: 'Services multiples', desc: 'Problème combiné' },
]
const PROBLEMS = {
internet: [
'Pas de connexion internet', 'Connexion intermittente', 'Vitesse très lente',
'Signal Wi-Fi faible', 'Modem / routeur défaillant', 'Installation câblage réseau',
'Configuration réseau (IP, DNS)', 'Autre',
],
tv: [
"Pas de signal TV", 'Image pixelisée / gelée', 'Canaux manquants',
'Décodeur défaillant', 'Installation antenne / câble', 'Configuration IPTV',
'Télécommande défectueuse', 'Autre',
],
telephone: [
"Pas de tonalité", 'Mauvaise qualité audio', 'Ligne coupée',
'Installation VoIP', 'Portabilité de numéro', 'Installation câblage téléphonique',
'Configuration central téléphonique', 'Autre',
],
multi: ['Décrire le problème dans la zone de texte ci-dessous'],
}
const TIME_SLOTS = [
{ id: 'morning', label: 'Matin', sub: '8h12h', icon: '🌅' },
{ id: 'afternoon', label: 'Après-midi', sub: '12h17h', icon: '☀️' },
{ id: 'evening', label: 'Soir', sub: '17h20h', icon: '🌙' },
]
const BUDGET_OPTIONS = [
{ id: 'b50', label: '50100 $', min: 50, max: 100 },
{ id: 'b100', label: '100200 $', min: 100, max: 200 },
{ id: 'b200', label: '200350 $', min: 200, max: 350 },
{ id: 'b350', label: '350 $+', min: 350, max: null },
]
const TOTAL_STEPS = 5
const step = ref(1)
// Étape 1 : type de service
const selectedService = ref(null)
// Étape 2 : description du problème
const selectedProblem = ref(null)
const description = ref('')
// Étape 3 : adresse
const address = ref(null)
const addressQuery = ref('')
const addressSuggestions = ref([])
const addressLoading = ref(false)
let debounceTimer = null
function onAddressInput (e) {
addressQuery.value = e.target.value
address.value = null
clearTimeout(debounceTimer)
if (addressQuery.value.length < 3) { addressSuggestions.value = []; return }
debounceTimer = setTimeout(fetchSuggestions, 350)
}
async function fetchSuggestions () {
addressLoading.value = true
try {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(addressQuery.value)}.json`
+ `?access_token=${MAPBOX_TOKEN}&country=CA&language=fr&limit=5`
const r = await fetch(url)
const d = await r.json()
addressSuggestions.value = d.features || []
} catch (_) { addressSuggestions.value = [] }
addressLoading.value = false
}
function selectAddress (f) {
address.value = f
addressQuery.value = f.place_name
addressSuggestions.value = []
}
// Étape 4 : 3 dates préférées
const minDate = computed(() => new Date().toISOString().split('T')[0])
const preferredDates = ref([
{ date: '', timeSlots: [] }, // timeSlots = array of slot IDs (multi-select)
{ date: '', timeSlots: [] },
{ date: '', timeSlots: [] },
])
const urgency = ref('normal')
const budgetId = ref(null) // selected BUDGET_OPTIONS id
const activeDateIdx = ref(0) // which date card is open
function dateLabel (iso) {
if (!iso) return null
const d = new Date(iso + 'T12:00:00')
return d.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
}
function toggleSlot (pd, slotId) {
if (pd.timeSlots.includes(slotId)) {
pd.timeSlots = pd.timeSlots.filter(s => s !== slotId)
} else {
pd.timeSlots = [...pd.timeSlots, slotId]
}
}
const validDates = computed(() => preferredDates.value.filter(d => d.date && d.timeSlots.length > 0))
// Étape 5 : contact
const contact = ref({ name: '', phone: '', email: '' })
// Validation
const canNext = computed(() => {
if (step.value === 1) return !!selectedService.value
if (step.value === 2) return !!selectedProblem.value
if (step.value === 3) return !!address.value
if (step.value === 4) return validDates.value.length >= 1 && !!budgetId.value
if (step.value === 5) return contact.value.name.trim() && contact.value.phone.trim()
return false
})
function next () { if (canNext.value && step.value < TOTAL_STEPS) step.value++ }
function prev () { if (step.value > 1) step.value-- }
// Soumission
const submitting = ref(false)
const confirmed = ref(false)
const refNumber = ref('')
async function submit () {
if (!canNext.value) return
submitting.value = true
try {
const result = await createServiceRequest({
service_type: selectedService.value,
problem_type: selectedProblem.value,
description: description.value,
address: address.value?.place_name || addressQuery.value,
coordinates: address.value?.center || [0, 0],
preferred_dates: validDates.value.map(d => ({
date: d.date,
time_slots: d.timeSlots,
time_slot: d.timeSlots[0] || '', // backward-compat primary slot
})),
urgency: urgency.value,
budget: BUDGET_OPTIONS.find(b => b.id === budgetId.value) || null,
contact: contact.value,
})
refNumber.value = result.ref
confirmed.value = true
} catch (e) {
console.error(e)
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="booking-root">
<!-- Confirmation -->
<div v-if="confirmed" class="confirm-screen">
<div class="confirm-card">
<div class="confirm-icon"></div>
<h2>Demande envoyée !</h2>
<p>Nos techniciens vont examiner votre demande et vous proposer une confirmation de rendez-vous.</p>
<div class="ref-box">
<span class="ref-label">Numéro de référence</span>
<span class="ref-val">{{ refNumber }}</span>
</div>
<p class="confirm-sub">Vous recevrez une confirmation par SMS ou courriel une fois une date confirmée.</p>
<button class="btn-primary" @click="$router.push('/')">Retour à l'accueil</button>
</div>
</div>
<!-- Wizard -->
<template v-else>
<!-- Header -->
<div class="booking-header">
<button class="btn-back" @click="step > 1 ? prev() : $router.push('/')" aria-label="Retour"></button>
<div class="header-center">
<div class="header-logo">🌐</div>
<span>Demande de service</span>
</div>
<div class="step-pill">{{ step }}/{{ TOTAL_STEPS }}</div>
</div>
<!-- Progress bar -->
<div class="progress-bar"><div class="progress-fill" :style="{ width: (step / TOTAL_STEPS * 100) + '%' }"></div></div>
<!-- Content -->
<div class="booking-body">
<!-- Étape 1 : Sélection du service -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 1" key="s1" class="step-content">
<div class="step-title">Quel service avez-vous besoin ?</div>
<div class="service-grid">
<button v-for="s in SERVICES" :key="s.id"
class="service-card"
:class="{ selected: selectedService === s.id }"
@click="selectedService = s.id; selectedProblem = null">
<span class="svc-icon">{{ s.icon }}</span>
<span class="svc-label">{{ s.label }}</span>
<span class="svc-desc">{{ s.desc }}</span>
<span v-if="selectedService === s.id" class="svc-check"></span>
</button>
</div>
</div>
</transition>
<!-- Étape 2 : Description du problème -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 2" key="s2" class="step-content">
<div class="step-title">Quel est le problème ?</div>
<div class="service-label-chip">
{{ SERVICES.find(s => s.id === selectedService)?.icon }}
{{ SERVICES.find(s => s.id === selectedService)?.label }}
</div>
<div class="problem-list">
<button v-for="p in PROBLEMS[selectedService]" :key="p"
class="problem-item"
:class="{ selected: selectedProblem === p }"
@click="selectedProblem = p">
<span class="problem-radio">{{ selectedProblem === p ? '●' : '○' }}</span>
{{ p }}
</button>
</div>
<textarea class="textarea-desc" v-model="description"
placeholder="Détails supplémentaires (optionnel)…"
rows="3"></textarea>
</div>
</transition>
<!-- Étape 3 : Adresse -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 3" key="s3" class="step-content">
<div class="step-title">Adresse de l'intervention</div>
<div class="address-wrap">
<div class="input-group">
<span class="input-icon">📍</span>
<input class="addr-input" type="text"
:value="addressQuery"
@input="onAddressInput"
placeholder="Entrez votre adresse…"
autocomplete="off" />
<span v-if="addressLoading" class="input-spin"></span>
</div>
<div v-if="addressSuggestions.length" class="suggestions">
<button v-for="f in addressSuggestions" :key="f.id"
class="suggestion-item"
@click="selectAddress(f)">
📍 {{ f.place_name }}
</button>
</div>
<div v-if="address" class="addr-confirmed">
{{ address.place_name }}
</div>
</div>
</div>
</transition>
<!-- Étape 4 : 3 dates préférées -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 4" key="s4" class="step-content">
<div class="step-title">Disponibilités &amp; budget</div>
<p class="step-sub">Indiquez jusqu'à 3 dates et les plages horaires qui vous conviennent. Nous confirmerons la meilleure date.</p>
<!-- Urgence toggle -->
<div class="urgency-row">
<button class="urgency-btn" :class="{ active: urgency === 'normal' }" @click="urgency = 'normal'">Standard</button>
<button class="urgency-btn urgency-urgent" :class="{ active: urgency === 'urgent' }" @click="urgency = 'urgent'">Urgent 🚨</button>
</div>
<!-- 3 date cards -->
<div v-for="(pd, i) in preferredDates" :key="i" class="date-card"
:class="{ 'date-card-filled': pd.date && pd.timeSlots.length > 0, 'date-card-active': activeDateIdx === i }"
@click="activeDateIdx = i">
<div class="date-card-header">
<span class="date-priority">{{ ['1re', '2e', '3e'][i] }} priorité</span>
<span v-if="pd.date && pd.timeSlots.length > 0" class="date-summary">
{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}
</span>
<span v-else class="date-empty">Non définie</span>
<span class="date-toggle">{{ activeDateIdx === i ? '▲' : '▼' }}</span>
</div>
<div v-if="activeDateIdx === i" class="date-card-body">
<input type="date" class="date-input" v-model="pd.date" :min="minDate" />
<div class="slot-label">Plage(s) horaire</div>
<div class="slot-checks">
<label v-for="slot in TIME_SLOTS" :key="slot.id"
class="slot-check-row"
:class="{ checked: pd.timeSlots.includes(slot.id) }"
@click.stop="toggleSlot(pd, slot.id)">
<span class="slot-checkbox">
<span v-if="pd.timeSlots.includes(slot.id)" class="slot-checkbox-tick"></span>
</span>
<span class="slot-check-icon">{{ slot.icon }}</span>
<span class="slot-check-text">
<strong>{{ slot.label }}</strong>
<span>{{ slot.sub }}</span>
</span>
</label>
<label class="slot-check-row"
:class="{ checked: pd.timeSlots.includes('flexible') }"
@click.stop="pd.timeSlots = pd.timeSlots.includes('flexible') ? [] : ['flexible']">
<span class="slot-checkbox">
<span v-if="pd.timeSlots.includes('flexible')" class="slot-checkbox-tick"></span>
</span>
<span class="slot-check-icon">🕐</span>
<span class="slot-check-text">
<strong>Je suis flexible</strong>
<span>Au choix du technicien</span>
</span>
</label>
</div>
</div>
</div>
<p v-if="validDates.length === 0" class="hint-text">Remplissez au moins une date pour continuer.</p>
<p v-else class="hint-ok"> {{ validDates.length }} date{{ validDates.length > 1 ? 's' : '' }} sélectionnée{{ validDates.length > 1 ? 's' : '' }}</p>
<!-- Budget estimé -->
<div class="budget-section">
<div class="budget-title">Budget estimé</div>
<p class="budget-sub">Les techniciens soumettront leur tarif en fonction de votre budget.</p>
<div class="budget-grid">
<button v-for="b in BUDGET_OPTIONS" :key="b.id"
class="budget-btn"
:class="{ selected: budgetId === b.id }"
@click="budgetId = b.id">
{{ b.label }}
</button>
</div>
<p v-if="!budgetId" class="hint-text">Sélectionnez un budget pour continuer.</p>
</div>
</div>
</transition>
<!-- Étape 5 : Contact + résumé -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 5" key="s5" class="step-content">
<div class="step-title">Vos coordonnées</div>
<div class="form-fields">
<div class="field-group">
<label>Nom complet *</label>
<input v-model="contact.name" type="text" placeholder="Jean Tremblay" class="field-input" />
</div>
<div class="field-group">
<label>Téléphone *</label>
<input v-model="contact.phone" type="tel" placeholder="514 555-0000" class="field-input" />
</div>
<div class="field-group">
<label>Courriel</label>
<input v-model="contact.email" type="email" placeholder="jean@exemple.com" class="field-input" />
</div>
</div>
<!-- Résumé -->
<div class="summary-box">
<div class="summary-row">
<span>Service</span>
<strong>{{ SERVICES.find(s => s.id === selectedService)?.label }}</strong>
</div>
<div class="summary-row">
<span>Problème</span>
<strong>{{ selectedProblem }}</strong>
</div>
<div class="summary-row">
<span>Adresse</span>
<strong>{{ address?.place_name || addressQuery }}</strong>
</div>
<div class="summary-row" v-for="(pd, i) in validDates" :key="i">
<span>Date {{ i + 1 }}</span>
<strong>{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}</strong>
</div>
<div class="summary-row">
<span>Budget</span>
<strong>{{ BUDGET_OPTIONS.find(b => b.id === budgetId)?.label || '—' }}</strong>
</div>
<div class="summary-row" v-if="urgency === 'urgent'">
<span>Urgence</span>
<strong style="color:#f43f5e">🚨 Urgent</strong>
</div>
</div>
</div>
</transition>
</div><!-- /booking-body -->
<!-- Footer nav -->
<div class="booking-footer">
<button v-if="step < TOTAL_STEPS" class="btn-next" :disabled="!canNext" @click="next">
Continuer
</button>
<button v-else class="btn-next btn-submit" :disabled="!canNext || submitting" @click="submit">
{{ submitting ? 'Envoi en cours…' : 'Envoyer la demande ✓' }}
</button>
</div>
</template>
</div>
</template>
<style scoped>
/* ── Tokens ── */
.booking-root {
--accent: #6366f1;
--accent2: #818cf8;
--bg: #0f1117;
--surface: rgba(255,255,255,0.04);
--surface2: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.09);
--text: #f1f5f9;
--text2: #94a3b8;
--green: #10b981;
--red: #f43f5e;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
display: flex;
flex-direction: column;
max-width: 560px;
margin: 0 auto;
}
/* ── Header ── */
.booking-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem 0.75rem;
position: sticky; top: 0; z-index: 10;
background: rgba(15,17,23,0.9); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.btn-back { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 8px; width: 36px; height: 36px; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.btn-back:hover { border-color: var(--accent); }
.header-center { display: flex; align-items: center; gap: 0.5rem; font-weight: 700; font-size: 0.95rem; }
.header-logo { font-size: 1.3rem; }
.step-pill { background: rgba(99,102,241,0.2); color: var(--accent2); border: 1px solid rgba(99,102,241,0.3); border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.75rem; font-weight: 700; }
/* ── Progress ── */
.progress-bar { height: 3px; background: var(--border); }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width 0.4s ease; border-radius: 0 2px 2px 0; }
/* ── Body ── */
.booking-body { flex: 1; overflow-y: auto; padding: 1.5rem 1.25rem 6rem; }
.step-content { animation: fadeUp 0.25s ease; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.step-title { font-size: 1.35rem; font-weight: 800; margin-bottom: 0.35rem; }
.step-sub { color: var(--text2); font-size: 0.85rem; margin-bottom: 1.25rem; line-height: 1.6; }
/* ── Service grid ── */
.service-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-top: 1.25rem; }
.service-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; padding: 1.1rem 0.9rem; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: 0.2rem; transition: all 0.18s; position: relative; }
.service-card:hover { border-color: rgba(99,102,241,0.4); background: var(--surface2); }
.service-card.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); box-shadow: 0 0 0 3px rgba(99,102,241,0.18); }
.svc-icon { font-size: 1.8rem; margin-bottom: 0.25rem; }
.svc-label { font-size: 0.95rem; font-weight: 700; }
.svc-desc { font-size: 0.72rem; color: var(--text2); }
.svc-check { position: absolute; top: 0.75rem; right: 0.75rem; background: var(--accent); color: white; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 800; }
/* ── Problem list ── */
.service-label-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); color: var(--accent2); border-radius: 20px; padding: 0.3rem 0.85rem; font-size: 0.82rem; font-weight: 600; margin-bottom: 1.25rem; }
.problem-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.problem-item { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.88rem; display: flex; align-items: center; gap: 0.75rem; transition: all 0.15s; }
.problem-item:hover { border-color: rgba(99,102,241,0.35); }
.problem-item.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); color: white; }
.problem-radio { font-size: 1rem; color: var(--accent); flex-shrink: 0; }
.textarea-desc { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.85rem 1rem; color: var(--text); font-size: 0.88rem; resize: vertical; font-family: inherit; box-sizing: border-box; }
.textarea-desc:focus { border-color: var(--accent); outline: none; }
/* ── Address ── */
.address-wrap { margin-top: 1rem; }
.input-group { position: relative; display: flex; align-items: center; }
.input-icon { position: absolute; left: 0.9rem; font-size: 1rem; pointer-events: none; }
.addr-input { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 12px; padding: 0.85rem 3rem 0.85rem 2.5rem; color: var(--text); font-size: 0.9rem; box-sizing: border-box; }
.addr-input:focus { border-color: var(--accent); outline: none; }
.input-spin { position: absolute; right: 0.9rem; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.suggestions { background: #1a1d27; border: 1px solid var(--border); border-radius: 12px; margin-top: 0.5rem; overflow: hidden; }
.suggestion-item { width: 100%; background: none; border: none; border-bottom: 1px solid var(--border); color: var(--text); padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.82rem; transition: background 0.12s; }
.suggestion-item:last-child { border-bottom: none; }
.suggestion-item:hover { background: var(--surface2); }
.addr-confirmed { margin-top: 0.85rem; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; color: var(--green); }
/* ── Dates ── */
.urgency-row { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
.urgency-btn { flex: 1; background: var(--surface); border: 1.5px solid var(--border); color: var(--text2); border-radius: 10px; padding: 0.65rem; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.15s; }
.urgency-btn.active { border-color: var(--accent); background: rgba(99,102,241,0.12); color: var(--text); }
.urgency-urgent.active { border-color: var(--red); background: rgba(244,63,94,0.1); color: var(--red); }
.date-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; margin-bottom: 0.75rem; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
.date-card:hover { border-color: rgba(99,102,241,0.35); }
.date-card-filled { border-color: rgba(16,185,129,0.4); }
.date-card-active { border-color: var(--accent); }
.date-card-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.9rem 1rem; }
.date-priority { background: rgba(99,102,241,0.15); color: var(--accent2); border-radius: 6px; padding: 0.15rem 0.5rem; font-size: 0.7rem; font-weight: 700; flex-shrink: 0; }
.date-summary { flex: 1; font-size: 0.82rem; font-weight: 600; }
.date-empty { flex: 1; font-size: 0.82rem; color: var(--text2); font-style: italic; }
.date-toggle { color: var(--text2); font-size: 0.65rem; }
.date-card-body { padding: 0 1rem 1rem; border-top: 1px solid var(--border); }
.date-input { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.85rem; color: var(--text); font-size: 0.9rem; margin: 0.75rem 0; box-sizing: border-box; }
.date-input:focus { border-color: var(--accent); outline: none; }
.hint-text { font-size: 0.8rem; color: var(--text2); text-align: center; margin-top: 0.5rem; }
.hint-ok { font-size: 0.8rem; color: var(--green); text-align: center; margin-top: 0.5rem; font-weight: 600; }
/* ── Slot checkboxes ── */
.slot-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text2); margin-bottom: 0.5rem; }
.slot-checks { display: flex; flex-direction: column; gap: 0.4rem; }
.slot-check-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.75rem; border-radius: 10px; border: 1.5px solid var(--border); cursor: pointer; background: var(--surface2); transition: all 0.15s; user-select: none; }
.slot-check-row:hover { border-color: rgba(99,102,241,0.3); }
.slot-check-row.checked { border-color: var(--accent); background: rgba(99,102,241,0.1); }
.slot-checkbox { width: 20px; height: 20px; border-radius: 5px; border: 1.5px solid var(--border); flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: var(--surface); }
.slot-check-row.checked .slot-checkbox { background: var(--accent); border-color: var(--accent); }
.slot-checkbox-tick { color: white; font-size: 0.7rem; font-weight: 800; }
.slot-check-icon { font-size: 1.1rem; }
.slot-check-text { display: flex; flex-direction: column; gap: 0.05rem; }
.slot-check-text strong { font-size: 0.85rem; color: var(--text); }
.slot-check-text span { font-size: 0.7rem; color: var(--text2); }
/* ── Budget ── */
.budget-section { margin-top: 1.5rem; border-top: 1px solid var(--border); padding-top: 1.25rem; }
.budget-title { font-size: 1rem; font-weight: 800; margin-bottom: 0.25rem; }
.budget-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 0.85rem; }
.budget-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.budget-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 0.5rem; cursor: pointer; color: var(--text); font-size: 0.9rem; font-weight: 700; transition: all 0.15s; }
.budget-btn:hover { border-color: rgba(99,102,241,0.35); }
.budget-btn.selected { border-color: var(--accent); background: rgba(99,102,241,0.12); color: #a5b4fc; }
/* ── Contact + résumé ── */
.form-fields { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
.field-group { display: flex; flex-direction: column; gap: 0.35rem; }
.field-group label { font-size: 0.78rem; font-weight: 700; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; color: var(--text); font-size: 0.9rem; }
.field-input:focus { border-color: var(--accent); outline: none; }
.summary-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
.summary-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
.summary-row:last-child { border-bottom: none; }
.summary-row span { color: var(--text2); flex-shrink: 0; }
.summary-row strong { text-align: right; }
/* ── Footer ── */
.booking-footer {
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 560px; padding: 1rem 1.25rem;
background: linear-gradient(to top, var(--bg) 70%, transparent);
}
.btn-next { width: 100%; background: var(--accent); border: none; color: white; border-radius: 14px; padding: 1rem; font-size: 1rem; font-weight: 700; cursor: pointer; transition: opacity 0.15s; }
.btn-next:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-next:hover:not(:disabled) { opacity: 0.88; }
.btn-submit { background: linear-gradient(135deg, var(--accent), #a855f7); }
/* ── Confirmation ── */
.confirm-screen { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
.confirm-card { text-align: center; max-width: 380px; }
.confirm-icon { width: 72px; height: 72px; background: rgba(16,185,129,0.15); border: 2px solid var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2rem; color: var(--green); margin: 0 auto 1.5rem; }
.confirm-card h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.75rem; }
.confirm-card p { color: var(--text2); line-height: 1.6; margin-bottom: 1.5rem; font-size: 0.9rem; }
.ref-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1.25rem; display: flex; flex-direction: column; gap: 0.35rem; }
.ref-label { font-size: 0.72rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; }
.ref-val { font-size: 1.5rem; font-weight: 800; color: var(--accent2); letter-spacing: 0.08em; }
.confirm-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 2rem; }
.btn-primary { background: var(--accent); border: none; color: white; border-radius: 12px; padding: 0.85rem 2rem; font-size: 0.95rem; font-weight: 700; cursor: pointer; }
/* ── Transitions ── */
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.22s ease; }
.fade-up-enter-from { opacity: 0; transform: translateY(12px); }
.fade-up-leave-to { opacity: 0; transform: translateY(-8px); }
</style>

View File

@ -0,0 +1,716 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { registerContractor } from 'src/api/contractor'
const router = useRouter()
const ALL_SERVICES = [
{ id: 'informatique', icon: '💻', label: 'Informatique' },
{ id: 'formatage', icon: '🖥️', label: 'Formatage PC' },
{ id: 'nettoyage', icon: '🧹', label: 'Nettoyage' },
{ id: 'camera', icon: '📷', label: 'Caméras sécurité' },
{ id: 'plomberie', icon: '🔧', label: 'Plomberie' },
{ id: 'electricite', icon: '⚡', label: 'Électricité' },
{ id: 'climatisation', icon: '❄️', label: 'Climatisation' },
{ id: 'telephone', icon: '📱', label: 'Téléphones' },
{ id: 'serrurerie', icon: '🔒', label: 'Serrurerie' },
{ id: 'peinture', icon: '🎨', label: 'Peinture' },
{ id: 'jardinage', icon: '🌿', label: 'Entretien extérieur' },
{ id: 'autre', icon: '🔨', label: 'Autre' },
]
const DAYS = [
{ id: 'mon', label: 'Lun' },
{ id: 'tue', label: 'Mar' },
{ id: 'wed', label: 'Mer' },
{ id: 'thu', label: 'Jeu' },
{ id: 'fri', label: 'Ven' },
{ id: 'sat', label: 'Sam' },
{ id: 'sun', label: 'Dim' },
]
const TOTAL_STEPS = 4
const step = ref(1)
// Step 1 Profil
const profile = ref({
firstname: '',
lastname: '',
phone: '',
email: '',
company: '',
license: '',
})
// Step 2 Services
// selectedServices: { [id]: { rate: '', rateType: 'hourly' } }
const selectedServices = ref({})
function toggleService (svc) {
if (selectedServices.value[svc.id]) {
const copy = { ...selectedServices.value }
delete copy[svc.id]
selectedServices.value = copy
} else {
selectedServices.value = {
...selectedServices.value,
[svc.id]: { rate: '', rateType: 'hourly' },
}
}
}
function isSelected (id) { return !!selectedServices.value[id] }
const selectedServiceList = computed(() =>
ALL_SERVICES
.filter(s => selectedServices.value[s.id])
.map(s => ({
...s,
rate: selectedServices.value[s.id].rate,
rateType: selectedServices.value[s.id].rateType,
}))
)
// Step 3 Zone & disponibilité
const availability = ref({
city: '',
radius: '25km',
days: ['mon','tue','wed','thu','fri'],
urgent: false,
})
function toggleDay (id) {
const days = availability.value.days
if (days.includes(id)) {
availability.value.days = days.filter(d => d !== id)
} else {
availability.value.days = [...days, id]
}
}
// Submit
const submitting = ref(false)
const submitError = ref('')
const contractorRef = ref('')
async function submit () {
submitting.value = true
submitError.value = ''
try {
const ref = await registerContractor({
profile: profile.value,
services: selectedServiceList.value,
availability: availability.value,
})
contractorRef.value = ref
step.value = 5
} catch (e) {
submitError.value = e.message || 'Erreur lors de la soumission.'
} finally {
submitting.value = false
}
}
// Navigation
const canNext = computed(() => {
if (step.value === 1) {
const p = profile.value
return p.firstname.trim().length >= 2
&& p.lastname.trim().length >= 2
&& p.phone.replace(/\D/g, '').length >= 10
&& p.email.includes('@')
}
if (step.value === 2) return selectedServiceList.value.length >= 1
&& selectedServiceList.value.every(s => s.rate.trim() !== '')
if (step.value === 3) return availability.value.city.trim().length >= 2
&& availability.value.days.length >= 1
return true
})
function next () { if (canNext.value && step.value < TOTAL_STEPS) step.value++ }
function prev () { if (step.value > 1) step.value-- }
</script>
<template>
<div class="ct-root">
<!-- Header -->
<div class="ct-header">
<button class="btn-back" @click="router.push('/')"> Retour</button>
<div class="ct-brand">Dispatch</div>
<div v-if="step <= TOTAL_STEPS" class="step-dots">
<span
v-for="i in TOTAL_STEPS"
:key="i"
class="dot"
:class="{ active: step === i, done: step > i }"
/>
</div>
</div>
<!-- Hero intro (before step 1) not shown, header serves this role -->
<!-- Body -->
<div class="ct-body">
<!-- Step 1 Profil -->
<div v-if="step === 1" class="step-panel">
<div class="step-eyebrow">Étape 1 sur {{ TOTAL_STEPS }}</div>
<h1 class="step-title">Votre profil</h1>
<p class="step-sub">
Rejoignez notre réseau de techniciens et sous-traitants.<br>
Nous vous contactons sous 24h après révision de votre profil.
</p>
<div class="form-grid">
<div class="field">
<label>Prénom *</label>
<input v-model="profile.firstname" type="text" placeholder="Jean" />
</div>
<div class="field">
<label>Nom *</label>
<input v-model="profile.lastname" type="text" placeholder="Tremblay" />
</div>
<div class="field">
<label>Téléphone *</label>
<input v-model="profile.phone" type="tel" placeholder="514-555-0123" />
</div>
<div class="field">
<label>Courriel *</label>
<input v-model="profile.email" type="email" placeholder="jean@exemple.com" />
</div>
<div class="field span2">
<label>Entreprise (optionnel)</label>
<input v-model="profile.company" type="text" placeholder="Technologies XYZ inc." />
</div>
<div class="field span2">
<label>Numéro RBQ / Licence (optionnel)</label>
<input v-model="profile.license" type="text" placeholder="8301-1234-56" />
<span class="field-hint">Requis pour plomberie, électricité et certains travaux de construction</span>
</div>
</div>
</div>
<!-- Step 2 Services -->
<div v-if="step === 2" class="step-panel">
<div class="step-eyebrow">Étape 2 sur {{ TOTAL_STEPS }}</div>
<h1 class="step-title">Vos services et tarifs</h1>
<p class="step-sub">Sélectionnez les services que vous offrez et indiquez votre tarif pour chacun</p>
<div class="service-grid">
<button
v-for="s in ALL_SERVICES"
:key="s.id"
class="service-chip"
:class="{ selected: isSelected(s.id) }"
@click="toggleService(s)"
>
<span>{{ s.icon }}</span>
<span class="chip-label">{{ s.label }}</span>
<span v-if="isSelected(s.id)" class="chip-check"></span>
</button>
</div>
<!-- Rate inputs for selected services -->
<div v-if="selectedServiceList.length" class="rates-section">
<div class="rates-title">Tarifs pour les services sélectionnés</div>
<div
v-for="s in selectedServiceList"
:key="s.id"
class="rate-row"
>
<div class="rate-svc">
<span class="rate-icon">{{ s.icon }}</span>
<span class="rate-label">{{ s.label }}</span>
</div>
<div class="rate-inputs">
<input
v-model="selectedServices[s.id].rate"
type="number"
min="0"
placeholder="75"
class="rate-amount"
/>
<span class="rate-currency">$</span>
<select v-model="selectedServices[s.id].rateType" class="rate-type">
<option value="hourly">/ heure</option>
<option value="flat">forfait</option>
</select>
</div>
</div>
</div>
<div v-if="!selectedServiceList.length" class="hint-box">
Sélectionnez au moins un service ci-dessus
</div>
</div>
<!-- Step 3 Zone & disponibilité -->
<div v-if="step === 3" class="step-panel">
<div class="step-eyebrow">Étape 3 sur {{ TOTAL_STEPS }}</div>
<h1 class="step-title">Zone et disponibilité</h1>
<p class="step-sub">Définissez vous opérez et quand vous êtes disponible</p>
<div class="zone-section">
<div class="field">
<label>Ville principale *</label>
<input v-model="availability.city" type="text" placeholder="Montréal, Laval, Longueuil…" />
</div>
<div class="field">
<label>Rayon d'intervention</label>
<div class="radius-group">
<button
v-for="r in ['10km','25km','50km','Province']"
:key="r"
class="radius-btn"
:class="{ selected: availability.radius === r }"
@click="availability.radius = r"
>{{ r }}</button>
</div>
</div>
<div class="field">
<label>Jours disponibles *</label>
<div class="days-group">
<button
v-for="d in DAYS"
:key="d.id"
class="day-btn"
:class="{ selected: availability.days.includes(d.id) }"
@click="toggleDay(d.id)"
>{{ d.label }}</button>
</div>
</div>
<label class="urgent-row">
<input type="checkbox" v-model="availability.urgent" />
<span>Disponible pour les urgences (interventions rapides)</span>
</label>
</div>
</div>
<!-- Step 4 Révision -->
<div v-if="step === 4" class="step-panel">
<div class="step-eyebrow">Étape 4 sur {{ TOTAL_STEPS }} Révision</div>
<h1 class="step-title">Confirmer votre inscription</h1>
<p class="step-sub">Vérifiez vos informations avant de soumettre</p>
<div class="review-card">
<div class="review-section">
<div class="review-section-title">Profil</div>
<div class="review-row"><span>Nom</span><strong>{{ profile.firstname }} {{ profile.lastname }}</strong></div>
<div class="review-row"><span>Téléphone</span><strong>{{ profile.phone }}</strong></div>
<div class="review-row"><span>Courriel</span><strong>{{ profile.email }}</strong></div>
<div v-if="profile.company" class="review-row"><span>Entreprise</span><strong>{{ profile.company }}</strong></div>
<div v-if="profile.license" class="review-row"><span>Licence</span><strong>{{ profile.license }}</strong></div>
</div>
<div class="review-section">
<div class="review-section-title">Services offerts</div>
<div v-for="s in selectedServiceList" :key="s.id" class="review-row">
<span>{{ s.icon }} {{ s.label }}</span>
<strong>{{ s.rate }} $ / {{ s.rateType === 'hourly' ? 'heure' : 'forfait' }}</strong>
</div>
</div>
<div class="review-section">
<div class="review-section-title">Zone et disponibilité</div>
<div class="review-row"><span>Ville</span><strong>{{ availability.city }}</strong></div>
<div class="review-row"><span>Rayon</span><strong>{{ availability.radius }}</strong></div>
<div class="review-row">
<span>Jours</span>
<strong>
{{ DAYS.filter(d => availability.days.includes(d.id)).map(d => d.label).join(', ') }}
</strong>
</div>
<div v-if="availability.urgent" class="review-row">
<span>Urgences</span><strong>Disponible</strong>
</div>
</div>
</div>
<div v-if="submitError" class="submit-error">{{ submitError }}</div>
</div>
<!-- Step 5 Confirmation -->
<div v-if="step === 5" class="step-panel step-confirm">
<div class="confirm-anim">🎉</div>
<h1 class="step-title">Candidature reçue !</h1>
<p class="step-sub">
Votre profil est en cours de révision.<br>
Un responsable vous contactera sous 24h.
</p>
<div class="confirm-ref">
Référence : <strong>{{ contractorRef }}</strong>
</div>
<div class="next-steps">
<div class="next-step-title">Prochaines étapes</div>
<div class="next-step-item">
<span class="ns-num">1</span>
<span>Vérification de votre profil et de vos certifications</span>
</div>
<div class="next-step-item">
<span class="ns-num">2</span>
<span>Entretien téléphonique avec notre équipe</span>
</div>
<div class="next-step-item">
<span class="ns-num">3</span>
<span>Activation de votre compte et réception de vos premiers jobs</span>
</div>
</div>
<button class="btn-primary-lg" @click="router.push('/')">Retour à l'accueil</button>
</div>
</div><!-- /ct-body -->
<!-- Footer nav -->
<div v-if="step <= TOTAL_STEPS" class="ct-footer">
<button v-if="step > 1" class="btn-prev" @click="prev"> Précédent</button>
<div v-else class="footer-spacer" />
<div class="footer-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: ((step - 1) / TOTAL_STEPS * 100) + '%' }" />
</div>
</div>
<button
v-if="step < TOTAL_STEPS"
class="btn-next"
:disabled="!canNext"
@click="next"
>
Suivant
</button>
<button
v-else
class="btn-submit"
:disabled="!canNext || submitting"
@click="submit"
>
{{ submitting ? 'Envoi…' : 'Soumettre mon profil' }}
</button>
</div>
</div>
</template>
<style scoped>
/* ── Root ── */
.ct-root {
min-height: 100vh;
background: var(--bg, #0f1117);
color: var(--text-primary, #f1f5f9);
display: flex; flex-direction: column;
font-family: 'Inter', sans-serif;
}
/* ── Header ── */
.ct-header {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 1rem;
padding: 0.9rem 1.5rem;
background: rgba(15,17,23,0.92);
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
backdrop-filter: blur(12px);
}
.ct-brand {
font-size: 1rem; font-weight: 800;
color: #10b981; flex: 1;
}
.btn-back {
background: none; border: 1px solid var(--border, rgba(255,255,255,0.08));
color: var(--text-secondary, #94a3b8);
border-radius: 6px; padding: 0.3rem 0.75rem;
cursor: pointer; font-size: 0.8rem; font-weight: 600;
transition: color 0.15s, border-color 0.15s;
}
.btn-back:hover { color: var(--text-primary, #f1f5f9); border-color: #10b981; }
.step-dots { display: flex; gap: 6px; align-items: center; }
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--border, rgba(255,255,255,0.12));
transition: all 0.25s;
}
.dot.active { background: #10b981; width: 22px; border-radius: 4px; }
.dot.done { background: #10b981; opacity: 0.5; }
/* ── Body ── */
.ct-body {
flex: 1; overflow-y: auto;
padding: 2rem 1.5rem 6rem;
max-width: 680px; margin: 0 auto; width: 100%;
}
.step-panel { animation: fadeUp 0.25s ease; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.step-eyebrow {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: #10b981; margin-bottom: 0.5rem;
}
.step-title { font-size: 1.6rem; font-weight: 800; margin: 0 0 0.4rem; line-height: 1.2; }
.step-sub {
color: var(--text-secondary, #94a3b8);
font-size: 0.92rem; margin: 0 0 1.75rem; line-height: 1.5;
}
/* ── Step 1 — Form grid ── */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.span2 { grid-column: span 2; }
/* ── Step 2 — Service chips ── */
.service-grid {
display: flex; flex-wrap: wrap; gap: 0.5rem;
margin-bottom: 1.5rem;
}
.service-chip {
display: flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 0.85rem; border-radius: 99px;
background: var(--card-bg, rgba(255,255,255,0.04));
border: 2px solid var(--border, rgba(255,255,255,0.08));
cursor: pointer; font-size: 0.82rem;
transition: all 0.18s; color: var(--text-primary, #f1f5f9);
}
.service-chip:hover { border-color: rgba(16,185,129,0.4); }
.service-chip.selected {
border-color: #10b981;
background: rgba(16,185,129,0.1);
}
.chip-label { font-weight: 600; }
.chip-check { color: #10b981; font-weight: 700; font-size: 0.7rem; }
.rates-section {
background: var(--card-bg, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 12px; overflow: hidden;
}
.rates-title {
padding: 0.65rem 1rem;
background: var(--sidebar-bg, #161b27);
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
}
.rate-row {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
}
.rate-row:last-child { border-bottom: none; }
.rate-svc { display: flex; align-items: center; gap: 0.5rem; }
.rate-icon { font-size: 1rem; }
.rate-label { font-size: 0.85rem; font-weight: 600; }
.rate-inputs { display: flex; align-items: center; gap: 0.35rem; }
.rate-amount {
width: 72px; background: var(--bg, #0f1117);
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 6px; color: var(--text-primary, #f1f5f9);
padding: 0.35rem 0.5rem; font-size: 0.85rem; text-align: right;
outline: none; transition: border-color 0.15s;
}
.rate-amount:focus { border-color: #10b981; }
.rate-currency { font-size: 0.82rem; color: var(--text-secondary, #94a3b8); }
.rate-type {
background: var(--bg, #0f1117);
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 6px; color: var(--text-primary, #f1f5f9);
padding: 0.35rem 0.5rem; font-size: 0.8rem; cursor: pointer;
outline: none;
}
.hint-box {
text-align: center; padding: 2rem;
color: var(--text-secondary, #64748b);
font-size: 0.88rem; font-style: italic;
}
/* ── Step 3 — Zone ── */
.zone-section { display: flex; flex-direction: column; gap: 1.25rem; }
.radius-group { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.radius-btn {
padding: 0.45rem 1rem; border-radius: 8px;
background: var(--card-bg, rgba(255,255,255,0.04));
border: 2px solid var(--border, rgba(255,255,255,0.08));
color: var(--text-primary, #f1f5f9); cursor: pointer;
font-size: 0.82rem; font-weight: 600; transition: all 0.15s;
}
.radius-btn:hover { border-color: rgba(16,185,129,0.4); }
.radius-btn.selected { border-color: #10b981; background: rgba(16,185,129,0.1); }
.days-group { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.day-btn {
width: 44px; height: 44px; border-radius: 8px;
background: var(--card-bg, rgba(255,255,255,0.04));
border: 2px solid var(--border, rgba(255,255,255,0.08));
color: var(--text-primary, #f1f5f9); cursor: pointer;
font-size: 0.8rem; font-weight: 700; transition: all 0.15s;
}
.day-btn:hover { border-color: rgba(16,185,129,0.4); }
.day-btn.selected { border-color: #10b981; background: rgba(16,185,129,0.1); color: #10b981; }
.urgent-row {
display: flex; align-items: center; gap: 0.65rem;
cursor: pointer; font-size: 0.88rem; color: var(--text-secondary, #94a3b8);
}
.urgent-row input { accent-color: #10b981; width: 16px; height: 16px; cursor: pointer; }
/* ── Step 4 — Review ── */
.review-card {
background: var(--card-bg, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 12px; overflow: hidden;
}
.review-section { border-bottom: 1px solid var(--border, rgba(255,255,255,0.08)); }
.review-section:last-child { border-bottom: none; }
.review-section-title {
padding: 0.65rem 1rem;
background: var(--sidebar-bg, #161b27);
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
}
.review-row {
display: flex; justify-content: space-between; align-items: center;
padding: 0.6rem 1rem; font-size: 0.82rem;
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
}
.review-row:last-child { border-bottom: none; }
.review-row span { color: var(--text-secondary, #94a3b8); }
.review-row strong { color: var(--text-primary, #f1f5f9); }
/* ── Step 5 — Confirm ── */
.step-confirm { text-align: center; padding-top: 2rem; }
.confirm-anim {
font-size: 4rem; margin-bottom: 1rem;
animation: popIn 0.4s cubic-bezier(0.34,1.56,0.64,1);
}
@keyframes popIn {
from { transform: scale(0.4); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.confirm-ref {
display: inline-block; margin: 1.5rem auto;
background: var(--card-bg, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 8px; padding: 0.65rem 1.25rem;
font-size: 0.88rem; color: var(--text-secondary, #94a3b8);
}
.confirm-ref strong { color: #10b981; font-size: 1rem; }
.next-steps {
text-align: left; margin: 1.5rem 0 2rem;
background: var(--card-bg, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 12px; overflow: hidden;
}
.next-step-title {
padding: 0.65rem 1rem;
background: var(--sidebar-bg, #161b27);
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-secondary, #94a3b8);
border-bottom: 1px solid var(--border, rgba(255,255,255,0.08));
}
.next-step-item {
display: flex; align-items: flex-start; gap: 0.9rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
font-size: 0.85rem; color: var(--text-primary, #f1f5f9);
}
.next-step-item:last-child { border-bottom: none; }
.ns-num {
flex-shrink: 0; width: 22px; height: 22px;
background: #10b981; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; font-weight: 700; color: white;
}
.submit-error {
margin-top: 0.75rem; padding: 0.65rem 0.9rem;
background: rgba(244,63,94,0.08);
border: 1px solid rgba(244,63,94,0.25);
border-radius: 8px; font-size: 0.82rem; color: #f43f5e;
}
/* ── Footer ── */
.ct-footer {
position: fixed; bottom: 0; left: 0; right: 0;
display: flex; align-items: center; gap: 1rem;
padding: 0.9rem 1.5rem;
background: rgba(15,17,23,0.96);
border-top: 1px solid var(--border, rgba(255,255,255,0.08));
backdrop-filter: blur(12px);
}
.footer-spacer { flex: 0 0 80px; }
.footer-progress { flex: 1; }
.progress-bar {
height: 3px; background: var(--border, rgba(255,255,255,0.08));
border-radius: 2px; overflow: hidden;
}
.progress-fill {
height: 100%; background: #10b981;
border-radius: 2px; transition: width 0.35s ease;
}
.btn-prev {
flex: 0 0 auto; background: none;
border: 1px solid var(--border, rgba(255,255,255,0.08));
color: var(--text-secondary, #94a3b8);
border-radius: 8px; padding: 0.55rem 1rem;
cursor: pointer; font-size: 0.82rem; font-weight: 600;
transition: all 0.15s;
}
.btn-prev:hover { color: var(--text-primary, #f1f5f9); border-color: rgba(255,255,255,0.2); }
.btn-next {
flex: 0 0 auto;
background: #10b981; border: none; color: white;
border-radius: 8px; padding: 0.55rem 1.25rem;
cursor: pointer; font-size: 0.88rem; font-weight: 700;
transition: opacity 0.15s;
}
.btn-next:hover:not(:disabled) { opacity: 0.85; }
.btn-next:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-submit {
flex: 0 0 auto;
background: #10b981; border: none; color: white;
border-radius: 8px; padding: 0.6rem 1.5rem;
cursor: pointer; font-size: 0.88rem; font-weight: 700;
transition: opacity 0.15s;
}
.btn-submit:hover:not(:disabled) { opacity: 0.85; }
.btn-submit:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-primary-lg {
background: #10b981; border: none; color: white;
border-radius: 10px; padding: 0.75rem 2rem;
cursor: pointer; font-size: 0.95rem; font-weight: 700;
transition: opacity 0.15s;
}
.btn-primary-lg:hover { opacity: 0.85; }
/* ── Shared field styles ── */
.field { display: flex; flex-direction: column; gap: 0.3rem; }
.field label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--text-secondary, #94a3b8);
}
.field input, .field select, .field textarea {
background: var(--bg, #0f1117);
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 8px; color: var(--text-primary, #f1f5f9);
padding: 0.6rem 0.85rem; font-size: 0.88rem;
font-family: inherit; outline: none;
transition: border-color 0.15s;
}
.field input:focus, .field select:focus { border-color: #10b981; }
.field-hint { font-size: 0.7rem; color: var(--text-secondary, #64748b); }
@media (max-width: 480px) {
.form-grid { grid-template-columns: 1fr; }
.span2 { grid-column: span 1; }
.step-title { font-size: 1.3rem; }
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,700 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { useDispatchStore } from 'src/stores/dispatch'
import { fetchTechnicians } from 'src/api/dispatch'
import { createEquipmentInstall } from 'src/api/service-request'
const auth = useAuthStore()
const store = useDispatchStore()
// UI state
const phase = ref('loading') // 'loading' | 'login' | 'select-tech' | 'jobs'
const tab = ref('jobs') // 'jobs' | 'equipment' | 'map' | 'profile'
const showCompleted = ref(false)
const showToast = ref(false)
const toastMsg = ref('')
const detailJob = ref(null)
// Login form
const loginUser = ref('')
const loginPass = ref('')
const showPwd = ref(false)
// Tech selector
const techList = ref([])
const selTechId = ref('')
const selTech = computed(() => techList.value.find(t => t.name === selTechId.value) || null)
const techName = computed(() => selTech.value?.fullName || selTech.value?.name || '')
const COLORS = ['#6366f1','#10b981','#f59e0b','#8b5cf6','#06b6d4','#f43f5e','#f97316','#14b8a6']
const techColor = computed(() => {
const idx = techList.value.indexOf(selTech.value)
return COLORS[idx >= 0 ? idx % COLORS.length : 0]
})
const initials = computed(() =>
(techName.value || 'T').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
)
const today = computed(() =>
new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
)
// Job lists
const myJobs = computed(() => store.jobs)
const activeJob = computed(() => myJobs.value.find(j => j.status === 'in_progress') || null)
const upcomingJobs = computed(() =>
myJobs.value.filter(j => !j.completed && j.status !== 'in_progress' && j.status !== 'completed')
.sort((a, b) => (a.routeOrder || 99) - (b.routeOrder || 99))
)
const completedJobs = computed(() => myJobs.value.filter(j => j.status === 'completed'))
const stats = computed(() => [
{ lbl: 'Total', val: myJobs.value.length },
{ lbl: 'A faire', val: upcomingJobs.value.length + (activeJob.value ? 1 : 0) },
{ lbl: 'Faits', val: completedJobs.value.length },
])
// Auth + boot
async function loadTechs () {
const raw = await fetchTechnicians()
techList.value = raw.map((t, idx) => ({
name: t.name,
fullName: t.full_name || t.name,
techId: t.technician_id || t.name,
user: t.user || null,
colorIdx: idx,
}))
const linked = techList.value.find(t => t.user === auth.user)
selTechId.value = linked ? linked.name : (techList.value[0]?.name || '')
}
async function boot () {
await auth.checkSession()
if (auth.user) {
loginUser.value = auth.user
await loadTechs()
phase.value = 'select-tech'
} else {
phase.value = 'login'
}
}
async function doLogin () {
await auth.doLogin(loginUser.value, loginPass.value)
if (auth.user) {
await loadTechs()
phase.value = 'select-tech'
}
}
async function doLogout () {
await auth.doLogout()
store.jobs = []
selTechId.value = ''
phase.value = 'login'
}
async function loadJobs () {
if (!selTechId.value || !selTech.value) return
await store.loadJobsForTech(selTech.value.techId)
phase.value = 'jobs'
}
// Actions
async function markComplete (job) {
if (!job || job.status === 'completed') return
await store.setJobStatus(job.id, 'completed')
job.status = 'completed'
toast(job.id + ' complété !')
}
async function markEnRoute (job) {
if (!job) return
myJobs.value.forEach(j => { if (j.status === 'in_progress') j.status = 'assigned' })
await store.setJobStatus(job.id, 'in_progress')
job.status = 'in_progress'
detailJob.value = null
toast('En route vers ' + job.id)
}
function toast (msg) {
toastMsg.value = msg
showToast.value = true
setTimeout(() => { showToast.value = false }, 2800)
}
function startTime (idx) {
let m = 8 * 60
if (activeJob.value) {
m += (parseInt(activeJob.value.legDur) || 0) + (parseFloat(activeJob.value.duration) || 1) * 60
}
for (let i = 0; i < idx; i++) {
const j = upcomingJobs.value[i]
m += (parseInt(j.legDur) || 0) + (parseFloat(j.duration) || 1) * 60
}
m += parseInt(upcomingJobs.value[idx]?.legDur) || 0
return String(Math.floor(m / 60)).padStart(2, '0') + 'h' + String(m % 60).padStart(2, '0')
}
function prioLbl (p) { return { high: 'Urgent', medium: 'Moyen', low: 'Faible' }[p] || p }
function prioStyle (p) {
return {
high: 'background:#fef2f2;color:#dc2626',
medium: 'background:#fffbeb;color:#d97706',
low: 'background:#f0fdf4;color:#16a34a',
}[p] || ''
}
function mapsUrl (addr) { return 'https://maps.google.com/?q=' + encodeURIComponent(addr) }
// Equipment / Barcode
const EQUIPMENT_TYPES = ['Modem', 'Routeur', 'Décodeur TV', 'Téléphone IP', 'Câble coaxial', 'Amplificateur', 'Splitter', 'ONT/ONU', 'Autre']
const eqRequestName = ref('') // which service request we're working on
const eqItems = ref([]) // array of equipment items to submit
const eqSubmitting = ref(false)
const eqDone = ref(false)
const scannerActive = ref(false)
let _scanner = null
const eqJobs = computed(() =>
myJobs.value.filter(j => j.status !== 'completed')
)
function newEqItem (barcode = '') {
return { barcode, equipment_type: 'Modem', brand: '', model: '', notes: '', photo_base64: '', _id: Date.now() + Math.random() }
}
function addEqItem () {
eqItems.value.push(newEqItem())
}
function removeEqItem (item) {
eqItems.value = eqItems.value.filter(e => e._id !== item._id)
}
async function startScanner () {
scannerActive.value = true
await nextTick()
try {
const { Html5Qrcode } = await import('html5-qrcode')
_scanner = new Html5Qrcode('qr-reader')
await _scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 260, height: 80 } },
(decoded) => {
stopScanner()
const existing = eqItems.value.find(e => e.barcode === decoded)
if (!existing) {
eqItems.value.push(newEqItem(decoded))
toast('Scanné : ' + decoded)
} else {
toast('Déjà dans la liste')
}
},
() => {}
)
} catch (e) {
scannerActive.value = false
toast('Caméra non disponible')
}
}
async function stopScanner () {
if (_scanner) {
await _scanner.stop().catch(() => {})
_scanner = null
}
scannerActive.value = false
}
function onPhotoChange (item, event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = e => { item.photo_base64 = e.target.result }
reader.readAsDataURL(file)
}
async function submitEquipment () {
if (!eqRequestName.value || eqItems.value.length === 0) return
eqSubmitting.value = true
try {
for (const item of eqItems.value) {
await createEquipmentInstall({
request: eqRequestName.value,
barcode: item.barcode,
equipment_type: item.equipment_type,
brand: item.brand,
model: item.model,
notes: item.notes,
photo_base64: item.photo_base64,
})
}
const count = eqItems.value.length
eqItems.value = []
eqDone.value = true
toast(count + ' équipement(s) enregistré(s)')
} catch {
toast('Erreur lors de la soumission')
} finally {
eqSubmitting.value = false
}
}
onUnmounted(() => { stopScanner() })
onMounted(boot)
</script>
<template>
<div class="mobile-app">
<!-- Header -->
<div class="app-header">
<div class="app-header-bar">
<div>
<div class="app-header-sub">{{ today }}</div>
<div class="app-header-title">
<span v-if="phase === 'jobs'">{{ techName }}</span>
<span v-else-if="phase === 'select-tech'">Choisir un technicien</span>
<span v-else>Dispatch Mobile</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;">
<span :class="auth.user ? 'badge badge-online' : 'badge badge-offline'">
{{ auth.user ? 'En ligne' : 'Hors ligne' }}
</span>
<button v-if="phase === 'jobs'" class="btn-icon"
@click="phase = 'select-tech'" title="Changer de tech">&#8646;</button>
<div v-if="phase === 'jobs'" class="avatar" :style="'background:' + techColor">
{{ initials }}
</div>
</div>
</div>
<div v-if="phase === 'jobs'" class="stats-strip">
<div v-for="s in stats" :key="s.lbl" class="stat-box">
<div class="stat-val">{{ s.val }}</div>
<div class="stat-lbl">{{ s.lbl }}</div>
</div>
</div>
</div>
<!-- Content -->
<div class="app-content">
<!-- Loading -->
<div v-if="phase === 'loading'" style="display:flex;flex-direction:column;align-items:center;padding:3rem 1rem;gap:1rem;">
<div class="spinner"></div>
<div style="color:#94a3b8;font-size:0.88rem;">Chargement...</div>
</div>
<!-- Login -->
<div v-else-if="phase === 'login'" class="login-wrap">
<div class="login-hero">
<div class="login-icon">&#9889;</div>
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Connexion ERPNext</div>
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">Entrez vos identifiants pour continuer</div>
</div>
<div class="login-card">
<label class="field-label">Utilisateur (email)</label>
<input v-model="loginUser" type="email" placeholder="admin@example.com"
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
<label class="field-label">Mot de passe</label>
<input v-model="loginPass" :type="showPwd ? 'text' : 'password'" placeholder="••••••••"
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
<div v-if="auth.error" class="error-msg">{{ auth.error }}</div>
<label class="show-pwd">
<input type="checkbox" v-model="showPwd" /> Afficher le mot de passe
</label>
<button class="btn-primary"
:disabled="!loginUser || !loginPass || auth.loading"
@click="doLogin">
{{ auth.loading ? 'Connexion...' : 'Se connecter' }}
</button>
</div>
</div>
<!-- Select tech -->
<div v-else-if="phase === 'select-tech'" class="login-wrap">
<div class="login-hero">
<div class="login-icon" style="font-size:1.6rem;">&#128119;</div>
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Choisir un technicien</div>
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">
Connecté&nbsp;: <strong>{{ auth.user }}</strong>
</div>
</div>
<div class="login-card">
<label class="field-label">Technicien</label>
<select v-model="selTechId" class="field-select">
<option value="" disabled>-- Choisir --</option>
<option v-for="t in techList" :key="t.name" :value="t.name">{{ t.fullName }}</option>
</select>
<button class="btn-primary" :disabled="!selTechId || store.loading" @click="loadJobs">
{{ store.loading ? 'Chargement...' : 'Voir les jobs &rarr;' }}
</button>
<button class="btn-secondary" @click="doLogout">Changer de compte</button>
</div>
</div>
<!-- Jobs -->
<template v-else-if="phase === 'jobs'">
<!-- En cours -->
<div v-if="activeJob">
<div class="section-label">En cours</div>
<div class="job-card active-card" :style="'border-left-color:' + techColor"
@click="detailJob = activeJob">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="prio-dot" :class="'prio-' + activeJob.priority"></span>
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;">{{ activeJob.id }}</span>
</div>
<span class="badge badge-active">En cours</span>
</div>
<div style="font-size:0.97rem;font-weight:700;margin-bottom:0.3rem;">{{ activeJob.subject }}</div>
<div style="font-size:0.77rem;color:#64748b;margin-bottom:0.6rem;">&#128205; {{ activeJob.address }}</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<span class="chip">&#9202; {{ activeJob.duration }}h</span>
<span v-if="activeJob.legDur" class="chip">&#128664; {{ activeJob.legDur }}min</span>
<span style="flex:1;"></span>
<button class="btn-green" style="flex:0;padding:4px 14px;font-size:0.75rem;border-radius:8px;"
@click.stop="markComplete(activeJob)">Terminer</button>
</div>
</div>
</div>
<!-- A venir -->
<div class="section-label">A venir ({{ upcomingJobs.length }})</div>
<div v-if="upcomingJobs.length === 0"
style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
Aucun job à venir
</div>
<div v-for="(job, idx) in upcomingJobs" :key="job.id"
class="job-card" :style="'border-left-color:' + techColor" @click="detailJob = job">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.4rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<div class="num-bubble" :style="'background:' + techColor">
{{ idx + (activeJob ? 2 : 1) }}
</div>
<span style="font-size:0.72rem;font-weight:600;color:#6366f1;">{{ job.id }}</span>
<span class="prio-dot" :class="'prio-' + job.priority"></span>
</div>
<span class="chip">{{ startTime(idx) }}</span>
</div>
<div style="font-size:0.92rem;font-weight:600;margin-bottom:0.25rem;">{{ job.subject }}</div>
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.5rem;">&#128205; {{ job.address }}</div>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;">
<span class="chip">&#9202; {{ job.duration }}h</span>
<span v-if="job.legDur" class="chip">&#128664; {{ job.legDur }}m</span>
</div>
</div>
<!-- Complétés -->
<div v-if="completedJobs.length > 0">
<div class="section-label" @click="showCompleted = !showCompleted">
Complétés ({{ completedJobs.length }})
<span style="font-size:0.9rem;">{{ showCompleted ? '&#8963;' : '&#8964;' }}</span>
</div>
<div v-if="showCompleted">
<div v-for="job in completedJobs" :key="job.id"
class="job-card done-card" @click="detailJob = job">
<div style="display:flex;align-items:center;gap:0.6rem;">
<div class="check-circle">&#10003;</div>
<div>
<div style="font-size:0.85rem;font-weight:600;text-decoration:line-through;color:#64748b;">
{{ job.subject }}
</div>
<div style="font-size:0.71rem;color:#94a3b8;">{{ job.id }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Aucun job -->
<div v-if="myJobs.length === 0" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:3rem;margin-bottom:0.75rem;">&#128197;</div>
<div style="font-size:1rem;font-weight:600;color:#374151;margin-bottom:0.3rem;">Aucun job aujourd'hui</div>
<div style="color:#94a3b8;font-size:0.83rem;">Votre planning est vide.</div>
</div>
</template>
<!-- Equipment tab -->
<template v-else-if="phase === 'jobs' && tab === 'equipment'">
<!-- Confirm done banner -->
<div v-if="eqDone" class="eq-done-banner">
&#10003; Équipements enregistrés avec succès !
<button @click="eqDone = false" style="margin-left:0.75rem;background:none;border:none;color:inherit;font-size:1rem;cursor:pointer;">&times;</button>
</div>
<!-- Request picker -->
<div class="section-label">Appel de service</div>
<select v-model="eqRequestName" class="field-select" style="margin-bottom:0.5rem;">
<option value="" disabled>-- Choisir un ticket --</option>
<option v-for="j in eqJobs" :key="j.id" :value="j.id">{{ j.id }} {{ j.subject }}</option>
</select>
<!-- Scanner -->
<div class="section-label">Scanner un code-barres</div>
<div v-if="!scannerActive" style="margin-bottom:1rem;">
<button class="btn-indigo-full" @click="startScanner">
&#128247; Activer la caméra
</button>
</div>
<div v-else style="margin-bottom:1rem;">
<div id="qr-reader" class="qr-reader-box"></div>
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;" @click="stopScanner">
Arrêter le scanner
</button>
</div>
<!-- Scanned items -->
<div class="section-label">Équipements ({{ eqItems.length }})</div>
<div v-if="eqItems.length === 0" style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
Scannez un code-barres ou ajoutez manuellement.
</div>
<div v-for="item in eqItems" :key="item._id" class="eq-card">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;flex:1;">CODE-BARRES</span>
<button @click="removeEqItem(item)" style="background:none;border:none;color:#ef4444;font-size:1.1rem;cursor:pointer;">&times;</button>
</div>
<input v-model="item.barcode" placeholder="Code-barres ou numéro de série" class="eq-input" />
<label class="eq-label">Type d'équipement</label>
<select v-model="item.equipment_type" class="eq-select">
<option v-for="t in EQUIPMENT_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
<div>
<label class="eq-label">Marque</label>
<input v-model="item.brand" placeholder="ex: Cisco" class="eq-input" />
</div>
<div>
<label class="eq-label">Modèle</label>
<input v-model="item.model" placeholder="ex: DPC3829" class="eq-input" />
</div>
</div>
<label class="eq-label">Notes</label>
<input v-model="item.notes" placeholder="Observations, port, emplacement..." class="eq-input" />
<label class="eq-label">Photo</label>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label class="btn-photo">
&#128247; Prendre une photo
<input type="file" accept="image/*" capture="environment" style="display:none"
@change="onPhotoChange(item, $event)" />
</label>
<img v-if="item.photo_base64" :src="item.photo_base64" class="eq-thumb" />
</div>
</div>
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;margin-bottom:0.75rem;" @click="addEqItem">
+ Ajouter manuellement
</button>
<button class="btn-green-full"
:disabled="!eqRequestName || eqItems.length === 0 || eqSubmitting"
@click="submitEquipment">
{{ eqSubmitting ? 'Enregistrement...' : 'Enregistrer les équipements (' + eqItems.length + ')' }}
</button>
</template>
</div>
<!-- Footer tabs -->
<div class="app-footer">
<button class="tab-btn" :class="{ active: tab === 'jobs' }" @click="tab = 'jobs'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
Jobs
</button>
<button class="tab-btn" :class="{ active: tab === 'map' }" @click="tab = 'map'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
<line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>
</svg>
Carte
</button>
<button class="tab-btn" :class="{ active: tab === 'equipment' }"
@click="tab = 'equipment'; if(scannerActive) stopScanner()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 7h.01M12 7h.01M17 7h.01M7 12h.01M12 12h.01M17 12h.01M7 17h.01M12 17h.01M17 17h.01"/>
</svg>
Équip.
</button>
<button class="tab-btn" :class="{ active: tab === 'profile' }" @click="tab = 'profile'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Profil
</button>
</div>
<!-- Detail modal -->
<div v-if="detailJob" class="modal-backdrop" @click.self="detailJob = null">
<div class="modal-sheet">
<div class="modal-handle"><div class="modal-handle-bar"></div></div>
<div style="padding:1rem 1.25rem 0.25rem;display:flex;align-items:flex-start;justify-content:space-between;gap:0.5rem;">
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.35rem;">
<span style="font-size:0.75rem;font-weight:700;color:#6366f1;">{{ detailJob.id }}</span>
<span class="prio-dot" :class="'prio-' + detailJob.priority"></span>
<span style="font-size:0.68rem;font-weight:600;padding:2px 8px;border-radius:6px;"
:style="prioStyle(detailJob.priority)">{{ prioLbl(detailJob.priority) }}</span>
</div>
<div style="font-size:1.05rem;font-weight:700;color:#1e293b;">{{ detailJob.subject }}</div>
</div>
<button @click="detailJob = null"
style="background:none;border:none;font-size:1.4rem;color:#94a3b8;cursor:pointer;line-height:1;">&times;</button>
</div>
<div class="modal-row">
<div class="modal-row-icon">&#128205;</div>
<div style="flex:1;">
<div class="modal-row-label">Adresse</div>
<div class="modal-row-value">{{ detailJob.address }}</div>
</div>
<a :href="mapsUrl(detailJob.address)" target="_blank"
style="color:#6366f1;font-size:0.8rem;text-decoration:none;font-weight:600;">Carte</a>
</div>
<div class="modal-row">
<div class="modal-row-icon">&#9202;</div>
<div>
<div class="modal-row-label">Durée estimée</div>
<div class="modal-row-value">{{ detailJob.duration }}h</div>
</div>
</div>
<div v-if="detailJob.legDist" class="modal-row">
<div class="modal-row-icon">&#128664;</div>
<div>
<div class="modal-row-label">Trajet jusqu'au job</div>
<div class="modal-row-value">{{ detailJob.legDist }} km · {{ detailJob.legDur }} min</div>
</div>
</div>
<div class="modal-actions">
<button v-if="detailJob.status !== 'completed'" class="btn-indigo"
@click="markEnRoute(detailJob)">En route</button>
<button v-if="detailJob.status !== 'completed'" class="btn-green"
@click="markComplete(detailJob); detailJob = null">Terminer</button>
<button v-if="detailJob.status === 'completed'" disabled
style="flex:1;padding:0.7rem;background:#f1f5f9;color:#94a3b8;border:none;border-radius:10px;font-weight:700;font-family:inherit;">
Ticket complété
</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="showToast" class="toast">&#10003; {{ toastMsg }}</div>
</div>
</template>
<style scoped>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.mobile-app { height: 100vh; display: flex; flex-direction: column; background: #f1f5f9; color: #1e293b; font-family: 'Inter', system-ui, sans-serif; }
/* Layout */
.app-header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; flex-shrink: 0; }
.app-header-bar { display: flex; align-items: center; justify-content: space-between; padding: 0.9rem 1rem; }
.app-header-title { font-size: 1.1rem; font-weight: 700; }
.app-header-sub { font-size: 0.7rem; opacity: 0.75; margin-bottom: 2px; }
.app-content { flex: 1; overflow-y: auto; padding: 1rem; padding-bottom: 5rem; }
.app-footer { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 1px solid #e2e8f0; display: flex; z-index: 100; }
.tab-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0.55rem 0; font-size: 0.65rem; font-weight: 600; color: #94a3b8; border: none; background: none; cursor: pointer; transition: color 0.15s; }
.tab-btn.active { color: #6366f1; }
.tab-btn svg { width: 20px; height: 20px; }
/* Stats */
.stats-strip { background: #4f46e5; display: flex; padding: 0.5rem 1rem; gap: 0.5rem; }
.stat-box { flex: 1; background: rgba(255,255,255,0.12); border-radius: 8px; padding: 0.4rem 0.5rem; text-align: center; }
.stat-val { font-size: 1.1rem; font-weight: 700; color: white; }
.stat-lbl { font-size: 0.6rem; color: rgba(255,255,255,0.75); margin-top: 1px; }
/* Badges */
.badge { display: inline-flex; align-items: center; font-size: 0.65rem; font-weight: 700; padding: 2px 8px; border-radius: 20px; }
.badge-online { background: rgba(74,222,128,0.25); color: #16a34a; }
.badge-offline { background: rgba(248,113,113,0.25); color: #dc2626; }
.badge-active { background: #e0e7ff; color: #4338ca; }
/* Login */
.login-wrap { max-width: 400px; margin: 0 auto; padding-top: 0.5rem; }
.login-hero { text-align: center; padding: 1.75rem 0 1.25rem; }
.login-icon { width: 68px; height: 68px; border-radius: 20px; background: linear-gradient(135deg,#6366f1,#8b5cf6); display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
.login-card { background: white; border-radius: 16px; padding: 1.5rem; box-shadow: 0 4px 24px rgba(0,0,0,0.07); }
.field-label { font-size: 0.75rem; font-weight: 600; color: #475569; display: block; margin-bottom: 0.35rem; }
.field-input { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; transition: border-color 0.15s; margin-bottom: 0.85rem; }
.field-input:focus { border-color: #6366f1; }
.field-input.err { border-color: #ef4444; }
.show-pwd { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 1rem; font-size: 0.78rem; color: #64748b; cursor: pointer; }
.show-pwd input { accent-color: #6366f1; }
.error-msg { font-size: 0.78rem; color: #ef4444; margin: -0.6rem 0 0.75rem; padding-left: 0.1rem; }
.field-select { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; background: white; margin-bottom: 1rem; cursor: pointer; }
.field-select:focus { border-color: #6366f1; }
/* Buttons */
.btn-primary { width: 100%; padding: 0.75rem; font-size: 0.92rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; transition: background 0.15s; }
.btn-primary:hover { background: #4f46e5; }
.btn-primary:disabled { background: #c7d2fe; cursor: not-allowed; }
.btn-secondary { width: 100%; padding: 0.65rem; font-size: 0.85rem; font-weight: 600; font-family: inherit; background: transparent; color: #64748b; border: 1.5px solid #e2e8f0; border-radius: 10px; cursor: pointer; margin-top: 0.6rem; transition: all 0.15s; }
.btn-secondary:hover { border-color: #6366f1; color: #6366f1; }
.btn-green { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-indigo { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-icon { background: rgba(255,255,255,0.2); border: none; color: white; width: 34px; height: 34px; border-radius: 50%; cursor: pointer; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; }
/* Cards */
.section-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; margin: 1.25rem 0 0.5rem; padding: 0 0.1rem; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
.job-card { background: white; border-radius: 14px; padding: 0.9rem 1rem; margin-bottom: 0.7rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); border-left: 4px solid #e2e8f0; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; }
.job-card:active { transform: scale(0.985); }
.active-card { box-shadow: 0 4px 20px rgba(99,102,241,0.18); }
.done-card { opacity: 0.6; border-left-color: #22c55e !important; background: #f9fafb; }
.prio-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.prio-high { background: #ef4444; }
.prio-medium { background: #f59e0b; }
.prio-low { background: #10b981; }
.chip { background: #f1f5f9; border-radius: 6px; padding: 2px 8px; font-size: 0.7rem; font-weight: 600; color: #475569; }
.num-bubble { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; color: white; flex-shrink: 0; }
.avatar { width: 36px; height: 36px; border-radius: 50%; font-weight: 700; font-size: 0.85rem; color: white; display: flex; align-items: center; justify-content: center; }
.check-circle { width: 32px; height: 32px; border-radius: 50%; background: #22c55e; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: white; font-size: 1rem; }
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 200; display: flex; align-items: flex-end; }
.modal-sheet { background: white; border-radius: 18px 18px 0 0; width: 100%; max-width: 600px; margin: 0 auto; padding-bottom: env(safe-area-inset-bottom, 16px); animation: slideUp 0.22s ease; }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.modal-handle { display: flex; justify-content: center; padding: 0.75rem 0 0; }
.modal-handle-bar { width: 40px; height: 4px; border-radius: 2px; background: #e2e8f0; }
.modal-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 1.25rem; }
.modal-row-icon { color: #94a3b8; font-size: 1.1rem; width: 22px; text-align: center; }
.modal-row-label { font-size: 0.68rem; color: #94a3b8; }
.modal-row-value { font-size: 0.88rem; font-weight: 600; color: #1e293b; }
.modal-actions { display: flex; gap: 0.5rem; padding: 0.75rem 1.25rem 1.25rem; }
/* Toast */
.toast { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); background: #22c55e; color: white; border-radius: 12px; padding: 0.65rem 1.2rem; font-weight: 700; font-size: 0.88rem; z-index: 300; white-space: nowrap; box-shadow: 0 4px 16px rgba(0,0,0,0.15); animation: fadeIn 0.2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(-8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
/* Spinner */
.spinner { width: 40px; height: 40px; border: 3px solid #e2e8f0; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Equipment tab */
.eq-done-banner { background: #dcfce7; color: #16a34a; border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 1rem; display: flex; align-items: center; }
.eq-card { background: white; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.eq-label { display: block; font-size: 0.68rem; font-weight: 600; color: #64748b; margin: 0.55rem 0 0.2rem; }
.eq-input { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; margin-bottom: 0.1rem; }
.eq-input:focus { border-color: #6366f1; }
.eq-select { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; background: white; }
.eq-select:focus { border-color: #6366f1; }
.qr-reader-box { width: 100%; border-radius: 12px; overflow: hidden; border: 2px solid #6366f1; background: #000; min-height: 200px; }
.btn-photo { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.9rem; font-size: 0.78rem; font-weight: 600; font-family: inherit; background: #ede9fe; color: #6366f1; border: none; border-radius: 8px; cursor: pointer; }
.eq-thumb { width: 52px; height: 52px; object-fit: cover; border-radius: 8px; border: 2px solid #e2e8f0; }
.btn-indigo-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-green-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-green-full:disabled { background: #86efac; cursor: not-allowed; }
</style>

View File

@ -0,0 +1,399 @@
<script setup>
/**
* TechBidPage Vue Uber pour techniciens
* Affiche les demandes disponibles, permet d'accepter une date et soumettre un bid.
*/
import { ref, computed, onMounted } from 'vue'
import { fetchOpenRequests, createServiceBid } from 'src/api/service-request'
// Auth locale (simple, sans store)
const techName = ref(localStorage.getItem('dispatch-tech-name') || '')
const techId = ref(localStorage.getItem('dispatch-tech-id') || '')
const showLogin = ref(!techId.value)
const loginName = ref('')
async function loginAsTech () {
if (!loginName.value.trim()) return
techName.value = loginName.value.trim()
techId.value = loginName.value.trim().toLowerCase().replace(/\s+/g, '-')
localStorage.setItem('dispatch-tech-name', techName.value)
localStorage.setItem('dispatch-tech-id', techId.value)
showLogin.value = false
loadRequests()
}
// Demandes disponibles
const requests = ref([])
const loading = ref(false)
const expandedId = ref(null)
async function loadRequests () {
loading.value = true
try { requests.value = await fetchOpenRequests() }
catch (_) { requests.value = [] }
loading.value = false
}
onMounted(() => { if (!showLogin.value) loadRequests() })
// Bid state
const bidState = ref({}) // { [requestName]: { date, timeSlot, duration, notes } }
const bidding = ref({}) // { [requestName]: true } = en cours
const bidSent = ref({}) // { [requestName]: true } = confirmé
function getBid (name) {
if (!bidState.value[name]) bidState.value[name] = { date: '', timeSlot: '', duration: '2', notes: '', price: '' }
return bidState.value[name]
}
const SERVICE_ICONS = { internet: '🌐', tv: '📺', telephone: '📞', multi: '🔧' }
const SERVICE_LABELS = { internet: 'Internet', tv: 'Télévision', telephone: 'Téléphonie', multi: 'Multiple' }
const URGENCY_COLORS = { urgent: '#f43f5e', normal: '#6366f1' }
const TIME_SLOTS = [
{ id: 'morning', label: 'Matin', sub: '8h12h' },
{ id: 'afternoon', label: 'Après-midi', sub: '12h17h' },
{ id: 'evening', label: 'Soir', sub: '17h20h' },
{ id: 'flexible', label: 'Flexible', sub: 'Au choix' },
]
// Dates proposées par le client pour cette demande
// Supporte 2 formats : champs plats Frappe (preferred_date_1) et tableau localStorage (preferred_dates[])
function getClientDates (req) {
// Format tableau localStorage
if (Array.isArray(req.preferred_dates) && req.preferred_dates.length > 0) {
return req.preferred_dates
.filter(d => d.date)
.map((d, i) => ({
date: d.date,
slot: d.time_slot || (Array.isArray(d.time_slots) ? d.time_slots[0] : '') || '',
slots: Array.isArray(d.time_slots) ? d.time_slots : (d.time_slot ? [d.time_slot] : []),
priority: i + 1,
}))
}
// Format champs plats Frappe
const dates = []
for (let i = 1; i <= 3; i++) {
const d = req[`preferred_date_${i}`]
const s = req[`time_slot_${i}`]
if (d) dates.push({ date: d, slot: s, slots: s ? [s] : [], priority: i })
}
return dates
}
function formatDate (iso) {
if (!iso) return ''
const d = new Date(iso + 'T12:00:00')
return d.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
}
function timeAgo (iso) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
if (m < 60) return `il y a ${m}min`
const h = Math.floor(m / 60)
if (h < 24) return `il y a ${h}h`
return `il y a ${Math.floor(h / 24)}j`
}
const canBid = (name) => {
const b = bidState.value[name]
return b?.date && b?.timeSlot && b?.price
}
async function submitBid (req) {
const b = getBid(req.name)
if (!canBid(req.name)) return
bidding.value = { ...bidding.value, [req.name]: true }
try {
await createServiceBid({
request: req.name,
technician: techId.value,
proposed_date: b.date,
time_slot: b.timeSlot,
estimated_duration: b.duration,
notes: b.notes,
price: b.price,
})
bidSent.value = { ...bidSent.value, [req.name]: true }
expandedId.value = null
} catch (e) {
console.error(e)
} finally {
bidding.value = { ...bidding.value, [req.name]: false }
}
}
function decline (name) {
// Simply hide from list locally (no API call needed tech just ignores)
requests.value = requests.value.filter(r => r.name !== name)
}
const pendingRequests = computed(() => requests.value.filter(r => !bidSent.value[r.name]))
const sentCount = computed(() => Object.keys(bidSent.value).length)
</script>
<template>
<div class="bid-root">
<!-- Login -->
<div v-if="showLogin" class="login-screen">
<div class="login-card">
<div class="login-icon">👷</div>
<h2>Portail Technicien</h2>
<p>Entrez votre nom pour voir les mandats disponibles.</p>
<input v-model="loginName" type="text" placeholder="Votre nom" class="login-input"
@keyup.enter="loginAsTech" />
<button class="btn-login" @click="loginAsTech" :disabled="!loginName.trim()">
Accéder aux mandats
</button>
</div>
</div>
<!-- Main -->
<template v-else>
<!-- Header -->
<div class="bid-header">
<div class="bid-header-left">
<div class="tech-avatar">{{ techName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
<div>
<div class="tech-name">{{ techName }}</div>
<div class="tech-sub">Technicien</div>
</div>
</div>
<div class="header-right">
<span v-if="sentCount" class="badge-sent">{{ sentCount }} envoyé{{ sentCount > 1 ? 's' : '' }}</span>
<button class="btn-refresh" @click="loadRequests" :disabled="loading" title="Actualiser">
<span :class="{ spinning: loading }"></span>
</button>
</div>
</div>
<!-- Empty / loading -->
<div v-if="loading" class="state-center">
<div class="spinner"></div>
<p>Chargement des mandats</p>
</div>
<div v-else-if="pendingRequests.length === 0" class="state-center">
<div class="empty-icon">📭</div>
<p>Aucun mandat disponible pour le moment.</p>
<button class="btn-ghost" @click="loadRequests">Actualiser</button>
</div>
<!-- Request cards -->
<div v-else class="requests-list">
<div v-for="req in pendingRequests" :key="req.name" class="req-card"
:class="{ expanded: expandedId === req.name, urgent: req.urgency === 'urgent' }">
<!-- Card header -->
<div class="req-card-header" @click="expandedId = expandedId === req.name ? null : req.name">
<div class="req-type-badge" :style="{ background: 'rgba(99,102,241,0.15)', borderColor: 'rgba(99,102,241,0.3)' }">
{{ SERVICE_ICONS[req.service_type] || '🔧' }} {{ SERVICE_LABELS[req.service_type] || req.service_type }}
</div>
<div v-if="req.urgency === 'urgent'" class="urgent-badge">🚨 Urgent</div>
<div class="req-problem">{{ req.problem_type }}</div>
<div class="req-addr">📍 {{ req.address }}</div>
<!-- Client date preferences preview -->
<div class="req-dates-preview">
<span v-for="pd in getClientDates(req)" :key="pd.priority"
class="date-chip" :class="{ 'date-chip-1': pd.priority === 1 }">
{{ formatDate(pd.date) }}
</span>
</div>
<div class="req-meta">
<span>{{ timeAgo(req.creation) }}</span>
<span v-if="req.budget_label" class="budget-pill">💰 {{ req.budget_label }}</span>
<span>{{ expandedId === req.name ? '▲ Masquer' : '▼ Voir détails' }}</span>
</div>
</div>
<!-- Expanded bid form -->
<div v-if="expandedId === req.name" class="req-bid-form">
<div class="bid-section-title">Description du client</div>
<p class="bid-description">{{ req.description || 'Aucune description fournie.' }}</p>
<div class="bid-section-title">Dates proposées par le client</div>
<div class="client-dates">
<button v-for="pd in getClientDates(req)" :key="pd.priority"
class="client-date-btn"
:class="{ selected: getBid(req.name).date === pd.date && getBid(req.name).timeSlot === pd.slot }"
@click="getBid(req.name).date = pd.date; getBid(req.name).timeSlot = pd.slot">
<div class="cd-priority">{{ pd.priority }}e choix</div>
<div class="cd-date">{{ formatDate(pd.date) }}</div>
<div class="cd-slot">{{ (pd.slots.length > 0 ? pd.slots : [pd.slot]).map(s => TIME_SLOTS.find(t => t.id === s)?.label || s).filter(Boolean).join(' · ') || 'Flexible' }}</div>
</button>
</div>
<div class="bid-section-title">Ou proposer une autre date</div>
<div class="alt-date-row">
<input type="date" class="date-input" v-model="getBid(req.name).date"
:min="new Date().toISOString().split('T')[0]" />
<select class="slot-select" v-model="getBid(req.name).timeSlot">
<option value="">Plage horaire</option>
<option v-for="s in TIME_SLOTS" :key="s.id" :value="s.id">{{ s.label }} ({{ s.sub }})</option>
</select>
</div>
<div class="bid-section-title">Durée estimée</div>
<div class="duration-row">
<button v-for="h in ['1','2','3','4','6']" :key="h"
class="dur-btn"
:class="{ selected: getBid(req.name).duration === h }"
@click="getBid(req.name).duration = h">
{{ h }}h
</button>
</div>
<div class="bid-section-title">Mon tarif <span class="required-star">*</span></div>
<div class="price-row">
<div class="price-input-wrap">
<span class="price-currency">$</span>
<input type="number" class="price-input" v-model="getBid(req.name).price"
placeholder="0" min="0" step="5"
@click.stop />
<span class="price-unit">/ projet</span>
</div>
<div v-if="req.budget_label" class="price-hint">
Budget client : <strong>{{ req.budget_label }}</strong>
</div>
</div>
<textarea class="notes-input" v-model="getBid(req.name).notes"
placeholder="Note pour le dispatcher (optionnel)…" rows="2"></textarea>
<!-- Actions -->
<div class="bid-actions">
<button class="btn-decline" @click="decline(req.name)">
Décliner
</button>
<button class="btn-accept"
:disabled="!canBid(req.name) || bidding[req.name]"
@click="submitBid(req)">
{{ bidding[req.name] ? '…' : '✓ Soumettre ma disponibilité' }}
</button>
</div>
</div>
</div>
</div>
<!-- Sent confirmations -->
<div v-if="sentCount > 0" class="sent-banner">
{{ sentCount }} soumission{{ sentCount > 1 ? 's' : '' }} envoyée{{ sentCount > 1 ? 's' : '' }} en attente de confirmation du dispatcher
</div>
</template>
</div>
</template>
<style scoped>
.bid-root {
--accent: #6366f1;
--bg: #0f1117;
--surface: rgba(255,255,255,0.04);
--surface2: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.09);
--text: #f1f5f9;
--text2: #94a3b8;
--green: #10b981;
--red: #f43f5e;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
max-width: 600px;
margin: 0 auto;
padding-bottom: 5rem;
}
/* ── Login ── */
.login-screen { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
.login-card { text-align: center; max-width: 340px; width: 100%; }
.login-icon { font-size: 3rem; margin-bottom: 1rem; }
.login-card h2 { font-size: 1.5rem; font-weight: 800; margin-bottom: 0.5rem; }
.login-card p { color: var(--text2); margin-bottom: 1.5rem; font-size: 0.9rem; }
.login-input { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 12px; padding: 0.85rem 1rem; color: var(--text); font-size: 1rem; margin-bottom: 1rem; box-sizing: border-box; }
.login-input:focus { border-color: var(--accent); outline: none; }
.btn-login { width: 100%; background: var(--accent); border: none; color: white; border-radius: 12px; padding: 0.9rem; font-size: 1rem; font-weight: 700; cursor: pointer; }
.btn-login:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Header ── */
.bid-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; background: rgba(15,17,23,0.95); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
.bid-header-left { display: flex; align-items: center; gap: 0.75rem; }
.tech-avatar { width: 40px; height: 40px; background: rgba(99,102,241,0.2); border: 1px solid rgba(99,102,241,0.4); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 0.9rem; color: #818cf8; }
.tech-name { font-weight: 700; font-size: 0.95rem; }
.tech-sub { font-size: 0.72rem; color: var(--text2); }
.header-right { display: flex; align-items: center; gap: 0.75rem; }
.badge-sent { background: rgba(16,185,129,0.15); border: 1px solid rgba(16,185,129,0.3); color: var(--green); border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.72rem; font-weight: 700; }
.btn-refresh { background: var(--surface); border: 1px solid var(--border); color: var(--text2); border-radius: 8px; width: 34px; height: 34px; cursor: pointer; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; }
.spinning { display: inline-block; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── States ── */
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem 2rem; gap: 1rem; color: var(--text2); }
.spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
.empty-icon { font-size: 3rem; }
.btn-ghost { background: var(--surface); border: 1px solid var(--border); color: var(--text2); border-radius: 8px; padding: 0.5rem 1.25rem; cursor: pointer; font-size: 0.85rem; }
/* ── Request cards ── */
.requests-list { padding: 1rem; display: flex; flex-direction: column; gap: 0.85rem; }
.req-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 16px; overflow: hidden; transition: border-color 0.2s; }
.req-card.urgent { border-color: rgba(244,63,94,0.35); }
.req-card.expanded { border-color: var(--accent); }
.req-card-header { padding: 1rem; cursor: pointer; display: flex; flex-direction: column; gap: 0.5rem; }
.req-card-header:hover { background: var(--surface2); }
.req-type-badge { display: inline-flex; align-items: center; gap: 0.35rem; border: 1px solid; border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.72rem; font-weight: 700; width: fit-content; }
.urgent-badge { background: rgba(244,63,94,0.12); border: 1px solid rgba(244,63,94,0.3); color: var(--red); border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.72rem; font-weight: 700; width: fit-content; }
.req-problem { font-size: 0.95rem; font-weight: 700; }
.req-addr { font-size: 0.8rem; color: var(--text2); }
.req-dates-preview { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.date-chip { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); color: #818cf8; border-radius: 6px; padding: 0.15rem 0.5rem; font-size: 0.7rem; font-weight: 600; }
.date-chip-1 { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.45); }
.req-meta { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text2); margin-top: 0.25rem; }
/* ── Bid form ── */
.req-bid-form { padding: 1rem; border-top: 1px solid var(--border); background: rgba(0,0,0,0.15); display: flex; flex-direction: column; gap: 0.85rem; }
.bid-section-title { font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text2); }
.bid-description { font-size: 0.85rem; color: var(--text2); background: var(--surface); border-radius: 8px; padding: 0.65rem 0.85rem; line-height: 1.5; }
.client-dates { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.client-date-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.6rem 0.75rem; cursor: pointer; text-align: center; min-width: 100px; transition: all 0.15s; display: flex; flex-direction: column; gap: 0.15rem; }
.client-date-btn.selected { border-color: var(--accent); background: rgba(99,102,241,0.12); }
.cd-priority { font-size: 0.62rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
.cd-date { font-size: 0.8rem; font-weight: 700; color: var(--text); }
.cd-slot { font-size: 0.7rem; color: #818cf8; }
.alt-date-row { display: flex; gap: 0.5rem; }
.date-input { flex: 1; background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.6rem 0.75rem; color: var(--text); font-size: 0.85rem; }
.date-input:focus { border-color: var(--accent); outline: none; }
.slot-select { flex: 1; background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.6rem 0.75rem; color: var(--text); font-size: 0.85rem; }
.slot-select:focus { border-color: var(--accent); outline: none; }
.duration-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.dur-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.45rem 0.85rem; cursor: pointer; font-size: 0.85rem; font-weight: 700; color: var(--text2); transition: all 0.12s; }
.dur-btn.selected { border-color: var(--accent); color: white; background: rgba(99,102,241,0.2); }
.notes-input { background: var(--surface); border: 1.5px solid var(--border); border-radius: 8px; padding: 0.65rem 0.85rem; color: var(--text); font-size: 0.85rem; resize: vertical; font-family: inherit; width: 100%; box-sizing: border-box; }
.notes-input:focus { border-color: var(--accent); outline: none; }
.bid-actions { display: flex; gap: 0.75rem; }
.btn-decline { background: rgba(244,63,94,0.1); border: 1px solid rgba(244,63,94,0.25); color: var(--red); border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; font-size: 0.85rem; font-weight: 700; flex-shrink: 0; }
.btn-accept { flex: 1; background: var(--accent); border: none; color: white; border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; font-size: 0.9rem; font-weight: 700; transition: opacity 0.15s; }
.btn-accept:disabled { opacity: 0.35; cursor: not-allowed; }
/* ── Price input ── */
.price-row { display: flex; flex-direction: column; gap: 0.5rem; }
.price-input-wrap { display: flex; align-items: center; background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0 0.85rem; gap: 0.4rem; }
.price-input-wrap:focus-within { border-color: var(--accent); }
.price-currency { color: var(--text2); font-weight: 700; font-size: 1rem; }
.price-input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 1.05rem; font-weight: 700; padding: 0.65rem 0; width: 0; min-width: 60px; font-family: inherit; }
.price-unit { color: var(--text2); font-size: 0.78rem; }
.price-hint { font-size: 0.75rem; color: var(--text2); padding: 0.35rem 0; }
.price-hint strong { color: #a5b4fc; }
.required-star { color: var(--red); }
.budget-pill { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.25); color: var(--green); border-radius: 20px; padding: 0.15rem 0.5rem; font-size: 0.68rem; font-weight: 700; }
/* ── Sent banner ── */
.sent-banner { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: var(--green); border-radius: 12px; padding: 0.75rem 1.5rem; font-size: 0.82rem; font-weight: 600; text-align: center; backdrop-filter: blur(12px); }
</style>

View File

@ -0,0 +1,16 @@
import { route } from 'quasar/wrappers'
import { createRouter, createWebHashHistory } from 'vue-router'
// Routes — add pages here; no change needed in stores or API
const routes = [
{ path: '/', component: () => import('pages/DispatchV2Page.vue') },
{ path: '/mobile', component: () => import('pages/MobilePage.vue') },
{ path: '/admin', component: () => import('pages/AdminPage.vue') },
]
export default route(function () {
return createRouter({
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
routes,
})
})

View File

@ -0,0 +1,51 @@
// ── Auth store — Authentik forwardAuth ──────────────────────────────────────
// Authentik handles login at the Traefik level. If the user reaches the app,
// they are already authenticated. We fetch their identity from the /api/ proxy
// which forwards Authentik headers to ERPNext.
// ERPNext API calls use a service token (not user session).
// ─────────────────────────────────────────────────────────────────────────────
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { BASE_URL } from 'src/config/erpnext'
// Service token for ERPNext API — all dispatch API calls use this
const ERP_SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const loading = ref(true)
const error = ref('')
async function checkSession () {
loading.value = true
try {
// Fetch user identity — the /api/ proxy passes Authentik headers to ERPNext
// We use the service token to query who we are
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
headers: { Authorization: 'token ' + ERP_SERVICE_TOKEN },
})
if (res.ok) {
const data = await res.json()
// For now, use the service account identity
// The actual Authentik user email is in the response headers (X-authentik-email)
// but those are only available at the Traefik level
user.value = data.message || 'authenticated'
} else {
user.value = 'authenticated' // Authentik guarantees auth, ERPNext may not know the user
}
} catch {
user.value = 'authenticated' // If ERPNext is down, user is still authenticated via Authentik
} finally {
loading.value = false
}
}
async function doLogout () {
// Redirect to Authentik logout
window.location.href = 'https://auth.targo.ca/application/o/gigafibre-dispatch/end-session/'
}
return { user, loading, error, checkSession, doLogin: checkSession, doLogout }
})
export function getServiceToken () { return ERP_SERVICE_TOKEN }

View File

@ -0,0 +1,417 @@
// ── Dispatch store ───────────────────────────────────────────────────────────
// Shared state for both MobilePage and DispatchPage.
// All ERPNext calls go through api/dispatch.js — not here.
// ─────────────────────────────────────────────────────────────────────────────
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch'
import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar'
import { TECH_COLORS } from 'src/config/erpnext'
import { serializeAssistants } from 'src/composables/useHelpers'
// Module-level GPS guards — survive store re-creation and component remount
let __gpsStarted = false
let __gpsInterval = null
let __gpsPolling = false
export const useDispatchStore = defineStore('dispatch', () => {
const technicians = ref([])
const jobs = ref([])
const allTags = ref([]) // { name, label, color, category }
const loading = ref(false)
const erpStatus = ref('pending') // 'pending' | 'ok' | 'error' | 'session_expired'
// ── Data transformers ────────────────────────────────────────────────────
function _mapJob (j) {
return {
id: j.ticket_id || j.name,
name: j.name, // ERPNext docname (used for PUT calls)
subject: j.subject || 'Job sans titre',
address: j.address || 'Adresse inconnue',
coords: [j.longitude || 0, j.latitude || 0],
priority: j.priority || 'low',
duration: j.duration_h || 1,
status: j.status || 'open',
assignedTech: j.assigned_tech || null,
routeOrder: j.route_order || 0,
legDist: j.leg_distance || null,
legDur: j.leg_duration || null,
scheduledDate: j.scheduled_date || null,
endDate: j.end_date || null,
startTime: j.start_time || null,
assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })),
tags: (j.tags || []).map(t => t.tag),
}
}
function _mapTech (t, idx) {
return {
id: t.technician_id || t.name,
name: t.name, // ERPNext docname
fullName: t.full_name || t.name,
status: t.status || '',
user: t.user || null,
colorIdx: idx % TECH_COLORS.length,
coords: [t.longitude || -73.5673, t.latitude || 45.5017],
gpsCoords: null, // live GPS from Traccar (updated by polling)
gpsSpeed: 0,
gpsTime: null,
gpsOnline: false,
traccarDeviceId: t.traccar_device_id || null,
phone: t.phone || '',
email: t.email || '',
queue: [], // filled in loadAll()
tags: (t.tags || []).map(tg => tg.tag),
}
}
// ── Loaders ──────────────────────────────────────────────────────────────
async function loadAll () {
loading.value = true
erpStatus.value = 'pending'
try {
const [rawTechs, rawJobs, rawTags] = await Promise.all([
fetchTechnicians(),
fetchJobs(),
fetchTags(),
])
allTags.value = rawTags
technicians.value = rawTechs.map(_mapTech)
jobs.value = rawJobs.map(_mapJob)
// Build each tech's ordered queue (primary + assistant jobs)
technicians.value.forEach(tech => {
tech.queue = jobs.value
.filter(j => j.assignedTech === tech.id)
.sort((a, b) => a.routeOrder - b.routeOrder)
tech.assistJobs = jobs.value
.filter(j => j.assistants.some(a => a.techId === tech.id))
})
erpStatus.value = 'ok'
} catch (e) {
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
console.error('loadAll error:', e)
} finally {
loading.value = false
}
}
// Load jobs assigned to one tech — used by MobilePage
async function loadJobsForTech (techId) {
loading.value = true
try {
const raw = await fetchJobs([['assigned_tech', '=', techId]])
jobs.value = raw.map(_mapJob)
} finally {
loading.value = false
}
}
// ── Mutations (also syncs to ERPNext) ────────────────────────────────────
async function setJobStatus (jobId, status) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
job.status = status
await updateJob(job.id, { status })
}
async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
// Remove from old tech queue
technicians.value.forEach(t => {
t.queue = t.queue.filter(q => q.id !== jobId)
})
// Add to new tech queue
const tech = technicians.value.find(t => t.id === techId)
if (tech) {
job.assignedTech = techId
job.routeOrder = routeOrder
job.status = 'assigned'
if (scheduledDate !== undefined) job.scheduledDate = scheduledDate
tech.queue.splice(routeOrder, 0, job)
// Re-number route_order
tech.queue.forEach((q, i) => { q.routeOrder = i })
}
const payload = {
assigned_tech: techId,
route_order: routeOrder,
status: 'assigned',
}
if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || ''
await updateJob(job.id, payload)
}
async function unassignJob (jobId) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
job.assignedTech = null
job.status = 'open'
try { await updateJob(job.name || job.id, { assigned_tech: null, status: 'open' }) } catch (_) {}
}
async function createJob (fields) {
// fields: { subject, address, duration_h, priority, assigned_tech?, scheduled_date?, start_time? }
const localId = 'WO-' + Date.now().toString(36).toUpperCase()
const job = _mapJob({
ticket_id: localId, name: localId,
subject: fields.subject || 'Nouveau travail',
address: fields.address || '',
longitude: fields.longitude || 0,
latitude: fields.latitude || 0,
duration_h: parseFloat(fields.duration_h) || 1,
priority: fields.priority || 'low',
status: fields.assigned_tech ? 'assigned' : 'open',
assigned_tech: fields.assigned_tech || null,
scheduled_date: fields.scheduled_date || null,
start_time: fields.start_time || null,
route_order: 0,
})
jobs.value.push(job)
if (fields.assigned_tech) {
const tech = technicians.value.find(t => t.id === fields.assigned_tech)
if (tech) { job.routeOrder = tech.queue.length; tech.queue.push(job) }
}
try {
const created = await apiCreateJob({
subject: job.subject,
address: job.address,
longitude: job.coords?.[0] || '',
latitude: job.coords?.[1] || '',
duration_h: job.duration,
priority: job.priority,
status: job.status,
assigned_tech: job.assignedTech || '',
scheduled_date: job.scheduledDate || '',
start_time: job.startTime || '',
})
if (created?.name) { job.id = created.name; job.name = created.name }
} catch (_) {}
return job
}
async function setJobSchedule (jobId, scheduledDate, startTime) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
job.scheduledDate = scheduledDate || null
job.startTime = startTime !== undefined ? startTime : job.startTime
const payload = { scheduled_date: job.scheduledDate || '' }
if (startTime !== undefined) payload.start_time = startTime || ''
try { await updateJob(job.name || job.id, payload) } catch (_) {}
}
async function updateJobCoords (jobId, lng, lat) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
job.coords = [lng, lat]
try { await updateJob(job.name || job.id, { longitude: lng, latitude: lat }) } catch (_) {}
}
async function addAssistant (jobId, techId) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
if (job.assignedTech === techId) return // already lead
if (job.assistants.some(a => a.techId === techId)) return // already assistant
const tech = technicians.value.find(t => t.id === techId)
const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false }
job.assistants = [...job.assistants, entry]
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try {
await updateJob(job.name || job.id, {
assistants: serializeAssistants(job.assistants),
})
} catch (_) {}
}
async function removeAssistant (jobId, techId) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
job.assistants = job.assistants.filter(a => a.techId !== techId)
const tech = technicians.value.find(t => t.id === techId)
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try {
await updateJob(job.name || job.id, {
assistants: serializeAssistants(job.assistants),
})
} catch (_) {}
}
async function reorderTechQueue (techId, fromIdx, toIdx) {
const tech = technicians.value.find(t => t.id === techId)
if (!tech) return
const [moved] = tech.queue.splice(fromIdx, 1)
tech.queue.splice(toIdx, 0, moved)
tech.queue.forEach((q, i) => { q.routeOrder = i })
// Sync all reordered jobs
await Promise.all(
tech.queue.map((q, i) => updateJob(q.id, { route_order: i })),
)
}
// ── Smart assign (removes circular assistant deps) ──────────────────────
function smartAssign (jobId, newTechId, dateStr) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
if (job.assistants.some(a => a.techId === newTechId)) {
job.assistants = job.assistants.filter(a => a.techId !== newTechId)
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
}
assignJobToTech(jobId, newTechId, technicians.value.find(t => t.id === newTechId)?.queue.length || 0, dateStr)
_rebuildAssistJobs()
}
// ── Full unassign (clears assistants + unassigns) ──────────────────────
function fullUnassign (jobId) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) }
unassignJob(jobId)
_rebuildAssistJobs()
}
// Rebuild all tech.assistJobs references
function _rebuildAssistJobs () {
technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) })
}
// ── Traccar GPS — Hybrid: REST initial + WebSocket real-time ─────────────────
const traccarDevices = ref([])
const _techsByDevice = {} // deviceId (number) → tech object
function _buildTechDeviceMap () {
Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k])
technicians.value.forEach(t => {
if (!t.traccarDeviceId) return
const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId)
if (dev) _techsByDevice[dev.id] = t
})
}
function _applyPositions (positions) {
positions.forEach(p => {
const tech = _techsByDevice[p.deviceId]
if (!tech || !p.latitude || !p.longitude) return
const cur = tech.gpsCoords
if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) {
tech.gpsCoords = [p.longitude, p.latitude]
}
tech.gpsSpeed = p.speed || 0
tech.gpsTime = p.fixTime
tech.gpsOnline = true
})
}
// One-shot REST fetch (manual refresh button + initial load)
async function pollGps () {
if (__gpsPolling) return
__gpsPolling = true
try {
if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices()
_buildTechDeviceMap()
const deviceIds = Object.keys(_techsByDevice).map(Number)
if (!deviceIds.length) return
const positions = await fetchPositions(deviceIds)
_applyPositions(positions)
Object.values(_techsByDevice).forEach(t => {
if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false
})
} catch (e) { console.warn('[GPS] Poll error:', e.message) }
finally { __gpsPolling = false }
}
// WebSocket connection with auto-reconnect
let __ws = null
let __wsBackoff = 1000
function _connectWs () {
if (__ws) return
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = proto + '//' + window.location.host + '/traccar/api/socket'
try { __ws = new WebSocket(url) } catch (e) { console.warn('[GPS] WS error:', e); return }
__ws.onopen = () => {
__wsBackoff = 1000
// WS connected — stop fallback polling
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
console.log('[GPS] WebSocket connected — real-time updates active')
}
__ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
if (data.positions?.length) {
_buildTechDeviceMap() // refresh map in case techs changed
_applyPositions(data.positions)
}
} catch {}
}
__ws.onerror = () => {}
__ws.onclose = () => {
__ws = null
if (!__gpsStarted) return
// Start fallback polling while WS is down
if (!__gpsInterval) {
__gpsInterval = setInterval(pollGps, 30000)
console.log('[GPS] WS closed — fallback to 30s polling')
}
setTimeout(_connectWs, __wsBackoff)
__wsBackoff = Math.min(__wsBackoff * 2, 60000)
}
}
async function startGpsTracking () {
if (__gpsStarted) return
__gpsStarted = true
// 1. Load devices + initial REST fetch (all last-known positions)
await pollGps()
console.log('[GPS] Initial positions loaded via REST')
// 2. Create session cookie for WebSocket auth, then connect
const sessionOk = await createTraccarSession()
if (sessionOk) {
_connectWs()
} else {
// Session failed — fall back to polling
__gpsInterval = setInterval(pollGps, 30000)
console.log('[GPS] Session failed — fallback to 30s polling')
}
}
function stopGpsTracking () {
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() }
}
const startGpsPolling = startGpsTracking
const stopGpsPolling = stopGpsTracking
// ── Create / Delete technician ─────────────────────────────────────────────
async function createTechnician (fields) {
// Auto-generate technician_id: TECH-N+1
const maxNum = technicians.value.reduce((max, t) => {
const m = (t.id || '').match(/TECH-(\d+)/)
return m ? Math.max(max, parseInt(m[1])) : max
}, 0)
fields.technician_id = 'TECH-' + (maxNum + 1)
const doc = await apiCreateTech(fields)
const tech = _mapTech(doc, technicians.value.length)
technicians.value.push(tech)
return tech
}
async function deleteTechnician (techId) {
const tech = technicians.value.find(t => t.id === techId)
if (!tech) return
await apiDeleteTech(tech.name)
technicians.value = technicians.value.filter(t => t.id !== techId)
}
return {
technicians, jobs, allTags, loading, erpStatus, traccarDevices,
loadAll, loadJobsForTech,
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
smartAssign, fullUnassign,
pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling,
createTechnician, deleteTechnician,
}
})

3
apps/website/.env Normal file
View File

@ -0,0 +1,3 @@
VITE_SUPABASE_PROJECT_ID="rddrjzptzhypltuzmere"
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs"
VITE_SUPABASE_URL="https://rddrjzptzhypltuzmere.supabase.co"

25
apps/website/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

73
apps/website/README.md Normal file
View File

@ -0,0 +1,73 @@
# Welcome to your Lovable project
## Project info
**URL**: https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID
## How can I edit this code?
There are several ways of editing your application.
**Use Lovable**
Simply visit the [Lovable Project](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and start prompting.
Changes made via Lovable will be committed automatically to this repo.
**Use your preferred IDE**
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
Follow these steps:
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
```
**Edit a file directly in GitHub**
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
**Use GitHub Codespaces**
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
## What technologies are used for this project?
This project is built with:
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
## How can I deploy this project?
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
## Can I connect a custom domain to my Lovable project?
Yes, you can!
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)

BIN
apps/website/bun.lockb Executable file

Binary file not shown.

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
);

22
apps/website/index.html Normal file
View File

@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<title>Targo</title>
<meta name="description" content="TARGO Internet fibre optique, télévision HD et téléphonie résidentielle. 100% fibre locale au Québec." />
<meta name="author" content="TARGO Communications" />
<meta property="og:title" content="TARGO Internet fibre optique locale" />
<meta property="og:description" content="Internet fibre optique, télévision HD et téléphonie résidentielle. 100% fibre locale au Québec." />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7082
apps/website/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

87
apps/website/package.json Normal file
View File

@ -0,0 +1,87 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@supabase/supabase-js": "^2.95.3",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"fflate": "^0.8.2",
"framer-motion": "^12.23.26",
"html2pdf.js": "^0.14.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

42
apps/website/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

94
apps/website/src/App.tsx Normal file
View File

@ -0,0 +1,94 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop";
import Index from "./pages/Index";
import Actualites from "./pages/Actualites";
import Catalogue from "./pages/Catalogue";
import Catalogue2Pages from "./pages/Catalogue2Pages";
import CatalogueDynamique from "./pages/CatalogueDynamique";
import Internet from "./pages/Internet";
import Television from "./pages/Television";
import Telephone from "./pages/Telephone";
import Support from "./pages/Support";
import Business from "./pages/business/Business";
import BusinessTelephony from "./pages/business/BusinessTelephony";
import BusinessMultilogement from "./pages/business/BusinessMultilogement";
import BusinessServicesGeres from "./pages/business/BusinessServicesGeres";
import BusinessInternet from "./pages/business/BusinessInternet";
import BusinessSdWan from "./pages/business/BusinessSdWan";
import BusinessSip from "./pages/business/BusinessSip";
import BusinessCloud from "./pages/business/BusinessCloud";
import BusinessVpn from "./pages/business/BusinessVpn";
import BusinessSecurite from "./pages/business/BusinessSecurite";
import BusinessAmplificationCellulaire from "./pages/business/BusinessAmplificationCellulaire";
import BusinessVirtualisation from "./pages/business/BusinessVirtualisation";
import BusinessReseauInterSites from "./pages/business/BusinessReseauInterSites";
import BusinessWifi from "./pages/business/BusinessWifi";
import BusinessCablage from "./pages/business/BusinessCablage";
import BusinessMicrosoft365 from "./pages/business/BusinessMicrosoft365";
import ContratBLB from "./pages/ContratBLB";
import PubliciteLettre from "./pages/PubliciteLettre";
import PublicitePrint from "./pages/PublicitePrint";
import BrandGuidelines from "./pages/BrandGuidelines";
import Accessibilite from "./pages/Accessibilite";
import PolitiqueConfidentialite from "./pages/PolitiqueConfidentialite";
import NotFound from "./pages/NotFound";
import AdminImport from "./pages/AdminImport";
import AdminLogin from "./pages/AdminLogin";
import { ProtectedAdminRoute } from "./components/ProtectedAdminRoute";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/actualites" element={<Actualites />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/catalogue-2" element={<Catalogue2Pages />} />
<Route path="/catalogue-3" element={<CatalogueDynamique />} />
<Route path="/internet" element={<Internet />} />
<Route path="/television" element={<Television />} />
<Route path="/telephone" element={<Telephone />} />
<Route path="/support" element={<Support />} />
<Route path="/business" element={<Business />} />
<Route path="/business/telephonie" element={<BusinessTelephony />} />
<Route path="/business/multilogement" element={<BusinessMultilogement />} />
<Route path="/business/services-geres" element={<BusinessServicesGeres />} />
<Route path="/business/internet" element={<BusinessInternet />} />
<Route path="/business/sd-wan" element={<BusinessSdWan />} />
<Route path="/business/sip" element={<BusinessSip />} />
<Route path="/business/cloud" element={<BusinessCloud />} />
<Route path="/business/vpn" element={<BusinessVpn />} />
<Route path="/business/securite" element={<BusinessSecurite />} />
<Route path="/business/amplification-cellulaire" element={<BusinessAmplificationCellulaire />} />
<Route path="/business/virtualisation" element={<BusinessVirtualisation />} />
<Route path="/business/reseau-inter-sites" element={<BusinessReseauInterSites />} />
<Route path="/business/wifi" element={<BusinessWifi />} />
<Route path="/business/cablage" element={<BusinessCablage />} />
<Route path="/business/microsoft-365" element={<BusinessMicrosoft365 />} />
<Route path="/contrat-blb" element={<ContratBLB />} />
<Route path="/publicite" element={<PubliciteLettre />} />
<Route path="/publicite-print" element={<PublicitePrint />} />
<Route path="/brand-guidelines" element={<BrandGuidelines />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin/import" element={<ProtectedAdminRoute><AdminImport /></ProtectedAdminRoute>} />
<Route path="/accessibilite" element={<Accessibilite />} />
<Route path="/politique-de-confidentialite" element={<PolitiqueConfidentialite />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
);
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,114 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 280" fill="none">
<!-- Couch -->
<rect x="30" y="175" width="300" height="55" rx="10" fill="#d4c4a8"/>
<rect x="40" y="158" width="280" height="25" rx="6" fill="#e8dcc8"/>
<ellipse cx="40" cy="190" rx="15" ry="28" fill="#d4c4a8"/>
<ellipse cx="320" cy="190" rx="15" ry="28" fill="#d4c4a8"/>
<rect x="55" y="230" width="6" height="12" rx="2" fill="#242a28"/>
<rect x="299" y="230" width="6" height="12" rx="2" fill="#242a28"/>
<!-- Dad (right side) -->
<g transform="translate(230, 75)">
<!-- Body -->
<rect x="12" y="50" width="36" height="48" rx="6" fill="#242a28"/>
<!-- Head -->
<circle cx="30" cy="28" r="22" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M10 22 Q10 6 30 6 Q50 6 50 22" fill="#242a28"/>
<!-- Eyes -->
<circle cx="22" cy="26" r="2.5" fill="#242a28"/>
<circle cx="38" cy="26" r="2.5" fill="#242a28"/>
<!-- Smile -->
<path d="M23 38 Q30 44 37 38" stroke="#242a28" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="2" y="55" width="12" height="6" rx="3" fill="#242a28"/>
<rect x="46" y="55" width="12" height="6" rx="3" fill="#242a28"/>
<!-- Hands -->
<circle cx="0" cy="58" r="6" fill="#f5d0c5"/>
<circle cx="60" cy="58" r="6" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="16" y="98" width="10" height="32" rx="4" fill="#242a28"/>
<rect x="34" y="98" width="10" height="32" rx="4" fill="#242a28"/>
</g>
<!-- Mom (left side) -->
<g transform="translate(70, 80)">
<!-- Body -->
<rect x="12" y="48" width="36" height="45" rx="6" fill="#ffffff" stroke="#e0e0e0"/>
<!-- Head -->
<circle cx="30" cy="26" r="21" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M10 30 Q6 12 30 6 Q54 12 50 30 Q48 18 30 16 Q12 18 10 30" fill="#3d2314"/>
<path d="M10 30 Q8 48 15 58" stroke="#3d2314" stroke-width="5" fill="none" stroke-linecap="round"/>
<path d="M50 30 Q52 48 45 58" stroke="#3d2314" stroke-width="5" fill="none" stroke-linecap="round"/>
<!-- Eyes -->
<circle cx="22" cy="24" r="2" fill="#242a28"/>
<circle cx="38" cy="24" r="2" fill="#242a28"/>
<!-- Smile -->
<path d="M23 35 Q30 40 37 35" stroke="#242a28" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="46" y="52" width="12" height="5" rx="2.5" fill="#f5d0c5"/>
<rect x="2" y="52" width="12" height="5" rx="2.5" fill="#f5d0c5"/>
<!-- Hands -->
<circle cx="60" cy="54" r="5" fill="#f5d0c5"/>
<circle cx="0" cy="54" r="5" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="16" y="93" width="10" height="28" rx="4" fill="#242a28"/>
<rect x="34" y="93" width="10" height="28" rx="4" fill="#242a28"/>
</g>
<!-- Child 1 (boy, center-left) -->
<g transform="translate(135, 105)">
<!-- Body -->
<rect x="8" y="36" width="28" height="32" rx="5" fill="#22c55e"/>
<!-- Head -->
<circle cx="22" cy="18" r="16" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M8 14 Q8 4 22 4 Q36 4 36 14" fill="#242a28"/>
<!-- Eyes -->
<circle cx="16" cy="16" r="2" fill="#242a28"/>
<circle cx="28" cy="16" r="2" fill="#242a28"/>
<!-- Smile -->
<path d="M17 26 Q22 30 27 26" stroke="#242a28" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="34" y="40" width="10" height="4" rx="2" fill="#22c55e"/>
<rect x="0" y="40" width="10" height="4" rx="2" fill="#22c55e"/>
<!-- Hands -->
<circle cx="46" cy="42" r="4" fill="#f5d0c5"/>
<circle cx="-2" cy="42" r="4" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="11" y="68" width="8" height="22" rx="3" fill="#242a28"/>
<rect x="25" y="68" width="8" height="22" rx="3" fill="#242a28"/>
</g>
<!-- Child 2 (girl, center-right) -->
<g transform="translate(180, 108)">
<!-- Body/Dress -->
<rect x="8" y="34" width="28" height="30" rx="5" fill="#ffffff" stroke="#e0e0e0"/>
<!-- Head -->
<circle cx="22" cy="16" r="15" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M8 14 Q8 2 22 2 Q36 2 36 14" fill="#3d2314"/>
<path d="M8 14 Q6 24 10 30" stroke="#3d2314" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M36 14 Q38 24 34 30" stroke="#3d2314" stroke-width="4" fill="none" stroke-linecap="round"/>
<!-- Eyes -->
<circle cx="16" cy="14" r="2" fill="#242a28"/>
<circle cx="28" cy="14" r="2" fill="#242a28"/>
<!-- Smile -->
<path d="M17 23 Q22 27 27 23" stroke="#242a28" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="34" y="38" width="10" height="4" rx="2" fill="#f5d0c5"/>
<rect x="0" y="38" width="10" height="4" rx="2" fill="#f5d0c5"/>
<!-- Hands -->
<circle cx="46" cy="40" r="4" fill="#f5d0c5"/>
<circle cx="-2" cy="40" r="4" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="11" y="64" width="8" height="20" rx="3" fill="#242a28"/>
<rect x="25" y="64" width="8" height="20" rx="3" fill="#242a28"/>
</g>
<!-- Decorative dots -->
<circle cx="50" cy="55" r="4" fill="#22c55e" opacity="0.5"/>
<circle cx="310" cy="45" r="5" fill="#22c55e" opacity="0.4"/>
<circle cx="180" cy="35" r="3" fill="#22c55e" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

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