Compare commits
5 Commits
815146b70b
...
e66b328efb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e66b328efb | ||
|
|
1e8c434cb1 | ||
|
|
ab323f0aca | ||
|
|
b39d328af1 | ||
|
|
e51007327d |
49
.gitea/workflows/field-tech-android.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: field-tech-android
|
||||
# Usine de build Android (APK) — runner Linux auto-hébergé (act_runner). Zéro impact prod.
|
||||
# Artefact téléchargeable depuis l'exécution Gitea Actions. Voir apps/field-tech/CI-SETUP.md.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'apps/field-tech/**'
|
||||
- '.gitea/workflows/field-tech-android.yml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-apk:
|
||||
runs-on: ubuntu-latest # un act_runner Linux étiqueté ubuntu-latest (image catthehacker/ubuntu)
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/field-tech
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: '17' }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: apps/field-tech/package-lock.json
|
||||
- uses: android-actions/setup-android@v3 # installe le SDK Android (platform-tools, platforms;android-34, build-tools)
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('apps/field-tech/android/**/*.gradle', 'apps/field-tech/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
- name: Build web (Vite) + Capacitor sync
|
||||
run: |
|
||||
npm install --no-audit --no-fund
|
||||
npm run build
|
||||
[ -d android ] || npx cap add android
|
||||
npx cap sync android
|
||||
- name: Gradle assembleDebug
|
||||
working-directory: apps/field-tech/android
|
||||
run: ./gradlew assembleDebug --no-daemon --stacktrace
|
||||
# RELEASE (plus tard) : secrets TRANSISTORSOFT_LICENSE + keystore → assembleRelease + signature.
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: targo-tech-android-debug
|
||||
path: apps/field-tech/android/app/build/outputs/apk/debug/*.apk
|
||||
if-no-files-found: error
|
||||
29
.gitea/workflows/field-tech-ios.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: field-tech-ios
|
||||
# Build iOS — EXIGE un runner macOS auto-hébergé (un Mac enregistré comme act_runner, label "macos") + compte Apple
|
||||
# pour la signature/TestFlight (en attente du passeport). Déclenché manuellement tant que le runner Mac n'existe pas.
|
||||
# Voir apps/field-tech/CI-SETUP.md §iOS.
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
runs-on: macos # runner macOS auto-hébergé (Xcode requis ; iOS ne build PAS sur Linux)
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/field-tech
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- name: Build web + Capacitor iOS
|
||||
run: |
|
||||
npm install --no-audit --no-fund
|
||||
npm run build
|
||||
[ -d ios ] || npx cap add ios
|
||||
npx cap sync ios
|
||||
cd ios/App && pod install
|
||||
- name: Compile (simulateur, NON signé — vérifie que ça build)
|
||||
working-directory: apps/field-tech/ios/App
|
||||
run: xcodebuild -workspace App.xcworkspace -scheme App -configuration Debug -sdk iphonesimulator -derivedDataPath build CODE_SIGNING_ALLOWED=NO
|
||||
# APPAREIL/TestFlight (quand compte Apple prêt) : importer cert + provisioning profile (secrets) →
|
||||
# xcodebuild -archive + -exportArchive (signé) → upload TestFlight. Licence Transistorsoft iOS requise pour release.
|
||||
22
apps/field-tech/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# field-tech (Capacitor) — n'archiver que la source ; les artefacts se régénèrent.
|
||||
# node_modules/ + build/ + dist/ sont déjà ignorés par le .gitignore racine ;
|
||||
# les artefacts Android (apk/.gradle/build/local.properties/assets copiés) le sont par android/.gitignore (Capacitor).
|
||||
|
||||
# Sortie de build Vite (webDir Capacitor) — régénérée par `npm run build`
|
||||
www/
|
||||
|
||||
# Plateforme iOS (scaffoldée à la demande quand le compte Apple est prêt)
|
||||
ios/
|
||||
|
||||
# Signature (NE JAMAIS committer — secrets)
|
||||
*.keystore
|
||||
*.jks
|
||||
*.p12
|
||||
*.mobileprovision
|
||||
google-services.json
|
||||
GoogleService-Info.plist
|
||||
|
||||
# Divers
|
||||
.DS_Store
|
||||
*.apk
|
||||
*.aab
|
||||
54
apps/field-tech/BUILD-ANDROID.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Build Android — Targo Tech (app native)
|
||||
|
||||
Objectif : **app installée** (icône, pas de lien sur place) + **géorepérage natif en arrière-plan** (Transistorsoft) → arrivées/départs auto **app fermée** → checkpoints au hub. UI réutilisée depuis le hub. iOS = même base plus tard (compte Apple en attente).
|
||||
|
||||
> ℹ️ **Recette exacte + les 4 correctifs Gradle** (force work-runtime 2.9.1, minSdk 24, repos maven `xms.g`/Huawei, `googlePlayServicesLocationVersion 21.0.1`) : voir [`README.md` § Recette de build](README.md#recette-de-build-android). Ils sont **déjà appliqués et commités** dans `android/`. Ce document couvre le build Docker, Android Studio et la release signée.
|
||||
|
||||
## Architecture (ce qui est déjà fait)
|
||||
- **Appairage 1×** : au bureau, Ops affiche le lien/QR du tech (bouton 📱 dans la liste des techs → `techFieldLink`). Le tech le scanne **une fois** → `src/main.js` stocke le token (`@capacitor/preferences`). Plus jamais de lien sur place.
|
||||
- **Géorepérage** : `src/main.js` → `BackgroundGeolocation.ready({ url:HUB+'/field/ts', autoSync:true, stopOnTerminate:false, startOnBoot:true, ... })` + `addGeofences(jobs)` (identifier = token signé du job). Transistorsoft POSTe enter/exit **nativement** → hub `/field/ts` (déjà déployé) mappe l'identifier → checkpoint (actual_start/end). Survit à l'app fermée + reboot.
|
||||
- **UI** : après appairage, `location.replace(HUB+'/field?t=token)` → liste/détail/carte/Street View/photo/scan (hébergé, déjà en prod). MLKit injecté par Capacitor pour le scan on-device.
|
||||
|
||||
## Prérequis
|
||||
- Node 18+, **Android Studio** + SDK, un appareil Android (ou émulateur) en mode développeur.
|
||||
- **Licence Transistorsoft** (achat unique par app) : https://shop.transistorsoft.com → clé pour `com.transistorsoft` + l'appId `ca.targo.field`.
|
||||
|
||||
## Build via DOCKER (recommandé — sans Android Studio ni Mac)
|
||||
Une « machine de compilation » conteneurisée (SDK + Gradle + Node) produit l'APK headless. Sur n'importe quel hôte Docker (serveur, laptop). **Android seulement** (iOS = macOS).
|
||||
```bash
|
||||
cd apps/field-tech
|
||||
docker build -t targo-android-build . # ~1re fois : télécharge le SDK (qq min, ~2-3 Go d'image)
|
||||
docker run --rm -v "$PWD":/app -v targo-gradle:/root/.gradle targo-android-build
|
||||
# → APK : apps/field-tech/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
- 1re exécution = npm + Gradle téléchargent les deps (qq min) ; le volume `targo-gradle` les met en cache pour les builds suivants (rapides).
|
||||
- **DEBUG sans licence** : Transistorsoft tourne en mode dev en debug → l'APK debug suffit pour tester (sideload). Pour **release** : `-e APP_BUILD=release` + clé licence (meta-data ci-dessous) + keystore.
|
||||
- Distribuer l'APK : sideload direct, ou le copier sur le hub (`/opt/targo-hub/uploads`) pour un lien de téléchargement interne.
|
||||
|
||||
## Build local (Android Studio) — alternative
|
||||
```bash
|
||||
cd apps/field-tech
|
||||
npm install
|
||||
npm run build # Vite → www/
|
||||
npx cap add android
|
||||
npx cap sync android
|
||||
```
|
||||
1. **Licence Transistorsoft** — dans `android/app/src/main/AndroidManifest.xml`, sous `<application>` :
|
||||
```xml
|
||||
<meta-data android:name="com.transistorsoft.locationmanager.license" android:value="VOTRE_CLE" />
|
||||
```
|
||||
2. **Permissions** (le plugin les ajoute en grande partie ; vérifier `AndroidManifest.xml`) :
|
||||
`ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`, **`ACCESS_BACKGROUND_LOCATION`**, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_LOCATION`, `POST_NOTIFICATIONS`, `CAMERA` (MLKit), `INTERNET`.
|
||||
3. **Build / installer** :
|
||||
```bash
|
||||
npx cap open android # Android Studio → Run sur l'appareil
|
||||
# ou APK : cd android && ./gradlew assembleDebug → app/build/outputs/apk/debug/app-debug.apk (sideload)
|
||||
```
|
||||
4. **Appairage** : ouvrir l'app → « Scanner le QR » → scanner le QR du tech depuis Ops (bouton 📱). Le token est mémorisé.
|
||||
5. **Tester** : se déplacer vers/depuis une adresse de job (rayon 200 m) → vérifier dans Ops que `actual_start/end` se posent (geofence natif). Avec l'app **fermée**, ça doit fonctionner (c'est tout l'intérêt de Transistorsoft).
|
||||
|
||||
## Notes
|
||||
- **Distribution** : APK interne (sideload / MDM) — pas besoin du Play Store. Play « test interne » = compte 25 $ une fois si désiré.
|
||||
- **iOS** (plus tard, compte Apple) : `npx cap add ios` + licence Transistorsoft iOS + `NSLocationAlwaysAndWhenInUseUsageDescription` + mode arrière-plan `location`. Même `src/main.js`.
|
||||
- **Rafraîchir les geofences** : à l'ouverture, `main.js` recharge les jobs du jour. Pour une MAJ quotidienne app-fermée, ajouter `@transistorsoft/capacitor-background-fetch` (déjà en dépendance) → refetch + `addGeofences` périodique.
|
||||
- **Scan IA** reste dispo en repli (texte) via `/field/vision` (Gemini) ; MLKit on-device est prioritaire.
|
||||
36
apps/field-tech/CI-SETUP.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# CI build app technicien (Gitea Actions, git.targo.ca)
|
||||
|
||||
Usine de build : **push → APK Android** téléchargeable en artefact. iOS prévu (runner macOS + compte Apple). Workflows : `.gitea/workflows/field-tech-android.yml` + `field-tech-ios.yml`.
|
||||
|
||||
## 1. Activer Actions
|
||||
- Admin Gitea : `Site Administration → Actions` activé.
|
||||
- Dépôt `louis/gigafibre-fsm` : `Settings → Actions → Enable`.
|
||||
|
||||
## 2. Runner Linux (Android) — sur un hôte HORS prod
|
||||
> Le runner exécute les builds → **ne pas le mettre sur erp** (sinon on retombe sur l'impact prod qu'on évite avec la CI). Un laptop avec Docker, un mini-VM, ou tout hôte Docker convient. Pour des builds occasionnels, un laptop allumé à la demande suffit.
|
||||
|
||||
1. Gitea : `Settings → Actions → Runners → Create new Runner` → copier le **registration token**.
|
||||
2. Sur l'hôte du runner :
|
||||
```bash
|
||||
docker run -d --restart=always --name targo-ci-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e GITEA_INSTANCE_URL=https://git.targo.ca \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
-e GITEA_RUNNER_LABELS="ubuntu-latest:docker://catthehacker/ubuntu:act-22.04" \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
3. Le runner apparaît dans Gitea → **push** sur `apps/field-tech/**` (ou « Run workflow ») → l'APK debug sort dans **Artifacts** de l'exécution.
|
||||
|
||||
## 3. iOS (plus tard — compte Apple en attente)
|
||||
- iOS **ne build que sur macOS** : enregistrer un **Mac** comme runner (act_runner natif, label `macos`, Xcode installé). Pas d'image Docker possible.
|
||||
- Quand le compte Apple Developer est prêt : ajouter en **secrets dépôt** (`Settings → Actions → Secrets`) le certificat de distribution + provisioning profile (base64) + mot de passe ; le workflow iOS fera `xcodebuild -archive`/`-exportArchive` signé → TestFlight.
|
||||
|
||||
## 4. Secrets (selon besoin)
|
||||
| Secret | Pour |
|
||||
|---|---|
|
||||
| `TRANSISTORSOFT_LICENSE` | build **release** (debug = mode dev, sans licence) → injecté en meta-data AndroidManifest |
|
||||
| `ANDROID_KEYSTORE` (+ pass) | signer l'APK release |
|
||||
| `APPLE_CERT` / `APPLE_PROFILE` (+ pass) | signature iOS (device/TestFlight) |
|
||||
|
||||
## Alternative immédiate (sans runner) — build local Docker
|
||||
La même image existe en `apps/field-tech/Dockerfile` : `docker build -t targo-android-build . && docker run --rm -v "$PWD":/app -v targo-gradle:/root/.gradle targo-android-build` → APK. Voir `BUILD-ANDROID.md`.
|
||||
19
apps/field-tech/Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Machine de compilation ANDROID headless (sans Android Studio ni Mac) → APK sideload.
|
||||
# (iOS NON containerisable : exige macOS + Xcode → build Mac plus tard.)
|
||||
# Image = toolchain seulement ; le code est MONTÉ au run (itération sans rebuild d'image). Voir docker-build.sh.
|
||||
FROM eclipse-temurin:17-jdk-jammy
|
||||
ENV ANDROID_SDK_ROOT=/opt/android-sdk DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl unzip git ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Android command-line tools + SDK (platform 34, build-tools 34)
|
||||
RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools \
|
||||
&& curl -fsSL -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||
&& unzip -q /tmp/cmdtools.zip -d $ANDROID_SDK_ROOT/cmdline-tools \
|
||||
&& mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/latest \
|
||||
&& rm /tmp/cmdtools.zip
|
||||
ENV PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools
|
||||
RUN yes | sdkmanager --licenses >/dev/null \
|
||||
&& sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null
|
||||
WORKDIR /app
|
||||
CMD ["bash", "docker-build.sh"]
|
||||
106
apps/field-tech/README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Targo Tech — app technicien (Capacitor)
|
||||
|
||||
App mobile native des techniciens TARGO/Gigafibre. **Capture passive** du temps par intervention : appairage **une seule fois** (QR), puis géorepérage natif en **arrière-plan** (app fermée) → arrivées/départs détectés automatiquement → checkpoints au hub. Le tech ne tape rien sur place. UI (liste/carte/Street View/photo/scan) **réutilisée depuis le hub** (`/field`). Scan série/MAC **on-device MLKit**.
|
||||
|
||||
- **Statut : APK Android buildé ✅** (debug, sideload) — voir [recette de build](#recette-de-build-android) ci-dessous.
|
||||
- Design détaillé : [`docs/field-tech-app.md`](../../docs/field-tech-app.md) · build approfondi : [`BUILD-ANDROID.md`](BUILD-ANDROID.md) · CI : [`CI-SETUP.md`](CI-SETUP.md).
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
```
|
||||
┌─ Bureau (1×) ─────────┐ ┌─ Téléphone du tech ──────────────────────┐
|
||||
│ Ops → bouton 📱 tech │ QR │ app Targo Tech │
|
||||
│ techFieldLink (token) ├────▶│ src/main.js : token → @capacitor/ │
|
||||
└───────────────────────┘ │ preferences (persistant) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Transistorsoft BackgroundGeolocation: │
|
||||
│ addGeofences(jobs du jour, r=200m) │
|
||||
│ enter/exit ── autoSync ──▶ hub /field/ts │ ← app FERMÉE + reboot
|
||||
│ │ │
|
||||
│ ▼ (à l'ouverture) │
|
||||
│ location.replace(hub /field?t=token) │
|
||||
│ → liste/détail/carte/StreetView/photo │
|
||||
│ → scan série/MAC = MLKit on-device │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Appairage 1×** — `pairScan()` (MLKit) ou `pairPaste()` lit le lien tech (`techFieldLink`), extrait le token, le persiste. Plus jamais de lien à ouvrir sur place.
|
||||
- **Géorepérage natif** — `@transistorsoft/capacitor-background-geolocation` enregistre **un geofence par job du jour** (identifier = token signé du job). Les `enter/exit` sont POSTés **nativement** au hub `/field/ts` (déjà déployé) qui mappe l'identifier → checkpoint → dérive `actual_start`/`actual_end`. Survit à l'app fermée et au reboot — c'est tout l'intérêt par rapport à un `watchPosition` JS.
|
||||
- **UI hébergée** — après appairage, l'app charge `/field?t=token` (servi par le hub `lib/roster.js`) : liste des jobs, carte Mapbox, Google Street View, photo, scan. Aucune UI dupliquée dans l'app.
|
||||
- **Scan série/MAC** — MLKit on-device en priorité (instantané, hors-ligne) ; replis `BarcodeDetector` web puis proxy IA Gemini (`/field/vision`) pour étiquettes texte seul.
|
||||
|
||||
> Backend (tout déjà déployé sur le hub) : `GET /field/tech?t=` (jobs du jour du tech), `GET /field/job?t=`, `POST /field/checkpoint`, `POST /field/ts` (webhook geofence Transistorsoft, auto-auth via identifier signé), `POST /field/photo`, `POST /field/device`, `POST /field/vision`. Tokens = **HMAC signés** (par job / par tech), sans PII, stateless.
|
||||
|
||||
---
|
||||
|
||||
## Recette de build Android
|
||||
|
||||
> Les 4 correctifs Gradle ci-dessous sont **déjà appliqués et commités** dans `android/` (Capacitor ne réécrit pas ces fichiers lors d'un `cap sync`). Ils ne sont à ré-appliquer **que** si on régénère `android/` from scratch (`rm -rf android && npx cap add android`).
|
||||
|
||||
### Prérequis (testé sur MacBook Pro M4, sans sudo)
|
||||
```bash
|
||||
# JDK 17
|
||||
brew install openjdk@17
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
|
||||
|
||||
# Android SDK (cmdline-tools) → ~/Library/Android/sdk
|
||||
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
||||
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
|
||||
```
|
||||
|
||||
### Build (APK debug)
|
||||
```bash
|
||||
cd apps/field-tech
|
||||
npm install
|
||||
npm run build # Vite → www/
|
||||
npx cap sync android # copie www + plugins (NE touche pas aux fixes Gradle)
|
||||
cd android && ./gradlew assembleDebug
|
||||
# → android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Les 4 correctifs Gradle (pourquoi)
|
||||
| # | Symptôme | Fichier | Correctif |
|
||||
|---|----------|---------|-----------|
|
||||
| 1 | `androidx.work:work-runtime:2.10.0 requires compileSdk 35` | `android/app/build.gradle` | `configurations.all { resolutionStrategy { force 'androidx.work:work-runtime:2.9.1'; force '…-ktx:2.9.1' } }` (on reste en compileSdk 34) |
|
||||
| 2 | `Manifest merger failed: minSdkVersion 22 < 24 (tslocationmanager)` | `android/variables.gradle` | `minSdkVersion = 24` |
|
||||
| 3 | `package com.transistorsoft.xms.g.common does not exist` | `android/build.gradle` | dans `allprojects.repositories` : les 2 repos maven locaux des plugins (`…background-geolocation/libs`, `…background-fetch/libs`) **+** `maven { url 'https://developer.huawei.com/repo/' }`. Les AAR `tslocationmanager*` (classes `xms.g`) vivent dans `node_modules/.../libs` ; le plugin n'ajoute pas ce repo → étape manuelle (= INSTALL-ANDROID officiel Transistorsoft). |
|
||||
| 4 | `cannot find symbol EVENT_PROVIDERCHANGE/EVENT_AUTHORIZATION/…` (67 err) | `android/variables.gradle` | `googlePlayServicesLocationVersion = '21.0.1'` → le plugin sélectionne l'AAR `tslocationmanager-v21` (API moderne attendue par Capacitor v6.1.5 ; major <21 prendrait l'ancien 3.6.4 sans ces constantes). |
|
||||
|
||||
### Optimisation taille
|
||||
`android/app/build.gradle` filtre les ABI sur `arm64-v8a` + `armeabi-v7a` (téléphones réels ; l'émulateur Apple Silicon est arm64 → test local OK). Pour un émulateur Intel x86_64, ajouter `'x86_64'` au bloc `ndk { abiFilters … }`.
|
||||
|
||||
### Release / Play Store (plus tard)
|
||||
Préférer **compileSdk/targetSdk 35 + AGP 8.7+** (et retirer le force work-runtime) plutôt que rester en 34 ; + clé licence Transistorsoft en `meta-data` du manifest + keystore. Détails : [`BUILD-ANDROID.md`](BUILD-ANDROID.md).
|
||||
|
||||
---
|
||||
|
||||
## Installer & appairer
|
||||
- **USB** (débogage USB activé) : `~/Library/Android/sdk/platform-tools/adb install -r app-debug.apk`
|
||||
- **Sans fil** : envoyer l'APK (Drive/courriel) → ouvrir sur le tél → autoriser « Installer applis inconnues ».
|
||||
- **1er lancement** → écran d'appairage → scanner le QR (ou coller le lien) du bouton **📱** dans Ops → l'app mémorise le token, arme le geofence, et charge la liste des jobs.
|
||||
- **Tester le geofence** : se déplacer vers/depuis une adresse de job (rayon 200 m), **app fermée** → vérifier dans Ops que `actual_start/end` se posent.
|
||||
|
||||
## CI (git.targo.ca)
|
||||
Push sur `apps/field-tech/**` → `.gitea/workflows/field-tech-android.yml` build l'APK sur un runner Linux auto-hébergé (**jamais sur erp/prod**) → artefact téléchargeable. Voir [`CI-SETUP.md`](CI-SETUP.md). iOS : `field-tech-ios.yml` (runner macOS + compte Apple, `workflow_dispatch`).
|
||||
|
||||
## iOS
|
||||
Même `src/main.js`. Scaffolder quand le compte Apple Developer est validé : `npx cap add ios` + cocoapods + Xcode + licence Transistorsoft iOS + `NSLocationAlwaysAndWhenInUseUsageDescription` + mode arrière-plan `location`.
|
||||
|
||||
## Sécurité
|
||||
- **Aucun secret dans le repo** : seul figure l'URL publique `https://msg.gigafibre.ca` (`src/main.js`) et le token Mapbox **public** `pk.…` (côté hub `/field`). Les secrets serveur restent dans le hub (`ops_secret.php` / env), jamais ici.
|
||||
- Tokens d'appairage = **HMAC signés**, sans PII. Keystores / certificats : gitignorés (jamais commités).
|
||||
|
||||
## Arborescence
|
||||
```
|
||||
apps/field-tech/
|
||||
├── index.html # écran d'appairage (coquille)
|
||||
├── src/main.js # appairage + init géorepérage + redirection /field
|
||||
├── capacitor.config.json # appId ca.targo.field
|
||||
├── vite.config.js # build → www/
|
||||
├── android/ # projet natif (4 fixes Gradle commités) — artefacts gitignorés
|
||||
├── Dockerfile # build headless (alternative au build local) — voir BUILD-ANDROID.md
|
||||
├── README.md BUILD-ANDROID.md CI-SETUP.md
|
||||
```
|
||||
101
apps/field-tech/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
apps/field-tech/android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/build/*
|
||||
!/build/.npmkeep
|
||||
69
apps/field-tech/android/app/build.gradle
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "ca.targo.field"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "ca.targo.field"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
ndk {
|
||||
// APK terrain = téléphones réels (ARM) uniquement → drop x86/x86_64 (émulateurs Intel/ChromeOS),
|
||||
// APK ~2× plus léger. Sur Apple Silicon l'émulateur Android est arm64-v8a → test local OK.
|
||||
// Pour un émulateur x86_64, ajouter 'x86_64' ici (ou commenter le bloc).
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
}
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
// Transistorsoft tire androidx.work 2.10 (exige compileSdk 35) ; on le fixe à 2.9.1 (compat compileSdk 34) → APK debug sideload.
|
||||
// (Pour une soumission Play, on passera plutôt compileSdk/targetSdk 35 + AGP 8.7 — voir BUILD-ANDROID.md.)
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force 'androidx.work:work-runtime:2.9.1'
|
||||
force 'androidx.work:work-runtime-ktx:2.9.1'
|
||||
}
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
23
apps/field-tech/android/app/capacitor.build.gradle
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-mlkit-barcode-scanning')
|
||||
implementation project(':transistorsoft-capacitor-background-geolocation')
|
||||
implementation project(':transistorsoft-capacitor-background-fetch')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
apps/field-tech/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
41
apps/field-tech/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package ca.targo.field;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,34 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
apps/field-tech/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Targo Tech</string>
|
||||
<string name="title_activity_main">Targo Tech</string>
|
||||
<string name="package_name">ca.targo.field</string>
|
||||
<string name="custom_url_scheme">ca.targo.field</string>
|
||||
</resources>
|
||||
22
apps/field-tech/android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
33
apps/field-tech/android/build.gradle
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Transistorsoft : repos fournissant tslocationmanager + l'abstraction xms.g (unification GMS/HMS).
|
||||
maven { url("${project(':transistorsoft-capacitor-background-geolocation').projectDir}/libs") }
|
||||
maven { url("${project(':transistorsoft-capacitor-background-fetch').projectDir}/libs") }
|
||||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
18
apps/field-tech/android/capacitor.settings.gradle
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-mlkit-barcode-scanning'
|
||||
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
|
||||
|
||||
include ':transistorsoft-capacitor-background-geolocation'
|
||||
project(':transistorsoft-capacitor-background-geolocation').projectDir = new File('../node_modules/@transistorsoft/capacitor-background-geolocation/android')
|
||||
|
||||
include ':transistorsoft-capacitor-background-fetch'
|
||||
project(':transistorsoft-capacitor-background-fetch').projectDir = new File('../node_modules/@transistorsoft/capacitor-background-fetch/android')
|
||||
22
apps/field-tech/android/gradle.properties
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
apps/field-tech/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
apps/field-tech/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
apps/field-tech/android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,248 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
92
apps/field-tech/android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
apps/field-tech/android/settings.gradle
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
19
apps/field-tech/android/variables.gradle
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 34
|
||||
// Transistorsoft : major >= 21 → le plugin résout l'AAR moderne tslocationmanager-v21
|
||||
// (qui expose EVENT_PROVIDERCHANGE/EVENT_AUTHORIZATION/... attendus par le plugin Capacitor v6.1.5).
|
||||
googlePlayServicesLocationVersion = '21.0.1'
|
||||
androidxActivityVersion = '1.8.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.12.0'
|
||||
androidxFragmentVersion = '1.6.2'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.9.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
9
apps/field-tech/capacitor.config.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"appId": "ca.targo.field",
|
||||
"appName": "Targo Tech",
|
||||
"webDir": "www",
|
||||
"server": { "androidScheme": "https" },
|
||||
"plugins": {
|
||||
"Geolocation": { "permissions": ["location"] }
|
||||
}
|
||||
}
|
||||
14
apps/field-tech/docker-build.sh
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
# Compile l'APK Android dans le conteneur (code monté dans /app). DEBUG par défaut (sideload interne, sans licence
|
||||
# Transistorsoft = mode dev). Pour RELEASE : APP_BUILD=release + clé licence (meta-data) + keystore (voir BUILD-ANDROID.md).
|
||||
set -e
|
||||
cd /app
|
||||
echo "── npm install ──"; npm install --no-audit --no-fund
|
||||
echo "── vite build (→ www) ──"; npm run build
|
||||
[ -d android ] || { echo "── cap add android ──"; npx cap add android; }
|
||||
echo "── cap sync ──"; npx cap sync android
|
||||
cd android
|
||||
TASK="assembleDebug"; [ "${APP_BUILD:-debug}" = "release" ] && TASK="assembleRelease"
|
||||
echo "── gradle $TASK ──"; ./gradlew "$TASK" --no-daemon --stacktrace
|
||||
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
||||
echo "✅ APK : apps/field-tech/android/${APK}"
|
||||
25
apps/field-tech/index.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html><html lang=fr><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>Targo Tech</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
||||
.box{max-width:420px;padding:24px;text-align:center}
|
||||
h1{font-size:22px;margin:0 0 4px}.sub{color:#94a3b8;font-size:14px;margin-bottom:20px}
|
||||
button{background:#2563eb;color:#fff;border:0;border-radius:10px;padding:14px 18px;font-size:16px;font-weight:600;width:100%;margin-top:10px}
|
||||
input{width:100%;padding:12px;border-radius:8px;border:1px solid #475569;background:#1e293b;color:#fff;font-size:14px;margin-top:14px;box-sizing:border-box}
|
||||
#msg{color:#fca5a5;font-size:13px;margin-top:12px;min-height:18px}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class=box>
|
||||
<div id=splash class=sub>Chargement…</div>
|
||||
<div id=pair style=display:none>
|
||||
<h1>Targo <span style=color:#60a5fa>Tech</span></h1>
|
||||
<div class=sub>Appairage de l'appareil — une seule fois</div>
|
||||
<button onclick=pairScan()>📷 Scanner le QR d'appairage</button>
|
||||
<input id=lnk placeholder="…ou coller le lien d'appairage">
|
||||
<button style=background:#334155 onclick=pairPaste()>Valider le lien</button>
|
||||
<div id=msg></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type=module src="/src/main.js"></script>
|
||||
</body></html>
|
||||
2346
apps/field-tech/package-lock.json
generated
Normal file
27
apps/field-tech/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "targo-field-tech",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "App technicien TARGO/Gigafibre — appairage 1× (QR), géorepérage natif arrière-plan (Transistorsoft) → checkpoints, scan MLKit. UI réutilisée depuis le hub (/field). Voir BUILD-ANDROID.md.",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"sync": "vite build && cap sync",
|
||||
"android": "vite build && cap sync android && cap open android",
|
||||
"add:android": "cap add android",
|
||||
"add:ios": "cap add ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^6",
|
||||
"@capacitor/app": "^6",
|
||||
"@capacitor/preferences": "^6",
|
||||
"@capacitor-mlkit/barcode-scanning": "^6",
|
||||
"@transistorsoft/capacitor-background-geolocation": "^6",
|
||||
"@transistorsoft/capacitor-background-fetch": "^6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^6",
|
||||
"@capacitor/android": "^6",
|
||||
"@capacitor/ios": "^6",
|
||||
"vite": "^5"
|
||||
}
|
||||
}
|
||||
58
apps/field-tech/src/main.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Targo Tech — app native (Capacitor + Vite).
|
||||
// Appairage 1× (QR → token tech persistant) → géorepérage natif Transistorsoft (arrière-plan, app fermée)
|
||||
// → POST auto des événements geofence au hub (/field/ts) → UI réutilisée depuis le hub (/field).
|
||||
import BackgroundGeolocation from '@transistorsoft/capacitor-background-geolocation'
|
||||
import { BarcodeScanner } from '@capacitor-mlkit/barcode-scanning'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
|
||||
const HUB = 'https://msg.gigafibre.ca'
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const getToken = async () => (await Preferences.get({ key: 'tech_token' })).value
|
||||
const setToken = async (t) => Preferences.set({ key: 'tech_token', value: t })
|
||||
function tokenFromUrl (u) { try { return new URL(u).searchParams.get('t') } catch (e) { const m = /[?&]t=([^&\s]+)/.exec(u || ''); return m ? decodeURIComponent(m[1]) : (u && u.includes('.') ? u : null) } }
|
||||
|
||||
window.pairScan = async () => {
|
||||
try {
|
||||
const { barcodes } = await BarcodeScanner.scan()
|
||||
const v = barcodes && barcodes[0] && (barcodes[0].rawValue || barcodes[0].displayValue)
|
||||
const t = v && tokenFromUrl(v)
|
||||
if (t) { await setToken(t); boot() } else $('msg').textContent = 'QR non reconnu'
|
||||
} catch (e) { $('msg').textContent = 'Scan annulé' }
|
||||
}
|
||||
window.pairPaste = async () => { const t = tokenFromUrl($('lnk').value.trim()); if (t) { await setToken(t); boot() } else $('msg').textContent = 'Lien invalide' }
|
||||
window.unpair = async () => { await Preferences.remove({ key: 'tech_token' }); location.reload() }
|
||||
|
||||
// Géorepérage natif : enregistre les jobs du jour comme geofences ; Transistorsoft POSTe enter/exit au hub (autoSync),
|
||||
// même app fermée. L'identifier de chaque geofence = le token signé du job → le hub mappe + écrit le checkpoint.
|
||||
async function initGeo (token) {
|
||||
await BackgroundGeolocation.ready({
|
||||
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
|
||||
distanceFilter: 50,
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
geofenceModeHighAccuracy: true,
|
||||
locationAuthorizationRequest: 'Always',
|
||||
backgroundPermissionRationale: { title: 'Suivi des interventions', message: 'Targo Tech a besoin de la localisation « toujours » pour détecter automatiquement vos arrivées/départs.' },
|
||||
url: HUB + '/field/ts',
|
||||
autoSync: true,
|
||||
batchSync: false,
|
||||
notification: { title: 'Targo Tech', text: 'Suivi des interventions actif' },
|
||||
})
|
||||
try {
|
||||
const jobs = await fetch(HUB + '/field/tech?t=' + encodeURIComponent(token)).then(r => r.json()).then(j => j.jobs || [])
|
||||
await BackgroundGeolocation.removeGeofences()
|
||||
const fences = jobs.filter(j => j.lat && Math.abs(+j.lat) > 0.01).map(j => ({ identifier: j.token, latitude: +j.lat, longitude: +j.lon, radius: 200, notifyOnEntry: true, notifyOnExit: true }))
|
||||
if (fences.length) await BackgroundGeolocation.addGeofences(fences)
|
||||
} catch (e) { console.warn('geofences', e) }
|
||||
const st = await BackgroundGeolocation.getState()
|
||||
if (!st.enabled) await BackgroundGeolocation.start()
|
||||
}
|
||||
|
||||
async function boot () {
|
||||
const token = await getToken()
|
||||
if (!token) { $('splash').style.display = 'none'; $('pair').style.display = 'block'; return }
|
||||
try { await initGeo(token) } catch (e) { console.warn('initGeo', e) }
|
||||
// Charge l'UI complète hébergée (liste/détail/carte/photo/scan IA + MLKit injecté par Capacitor)
|
||||
location.replace(HUB + '/field?t=' + encodeURIComponent(token))
|
||||
}
|
||||
boot()
|
||||
3
apps/field-tech/vite.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { defineConfig } from 'vite'
|
||||
// Build → www/ (webDir Capacitor). Bundle les plugins (Transistorsoft, MLKit) importés dans src/main.js.
|
||||
export default defineConfig({ build: { outDir: 'www', emptyOutDir: true } })
|
||||
|
|
@ -32,6 +32,8 @@ export const listRequirements = (start, days = 7) => jget(`/roster/requirements?
|
|||
export const createRequirement = (r) => jpost('/roster/requirements', r)
|
||||
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
|
||||
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
|
||||
// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) − jobs placés, + charge due
|
||||
export const getCapacity = (start, days = 7) => jget(`/roster/capacity?start=${start}&days=${days}`)
|
||||
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
|
||||
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
|
||||
export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)
|
||||
|
|
@ -91,13 +93,37 @@ export const jobCandidates = (job, exclude) => jget('/roster/job-candidates?job=
|
|||
export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan })
|
||||
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
|
||||
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
||||
// Write-back legacy : aperçu (0 écriture) puis application (réassigne ticket.assign_to au tech dans osTicket)
|
||||
export const pushLegacyPreview = () => jget('/dispatch/legacy-sync/push-assignments')
|
||||
export const pushLegacyApply = (notify = true) => jpost('/dispatch/legacy-sync/push-assignments' + (notify ? '' : '?notify=0'), {})
|
||||
// Fil complet d'un ticket legacy (osTicket) : messages + réponses, pour l'expand au clic
|
||||
export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
||||
// Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants)
|
||||
export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {})
|
||||
// Fermeture EN LOT (1 appel pour N tickets) — efficace
|
||||
export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {})
|
||||
// Assigner un job à un tech (date = case déposée)
|
||||
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
|
||||
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
|
||||
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
||||
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
|
||||
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
|
||||
// Retirer un job d'un tech (retour au pool non assigné)
|
||||
// Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto)
|
||||
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
|
||||
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
|
||||
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
|
||||
// Réconciliation des techniciens (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) : rapport + apply manuel
|
||||
// Lien app PWA du tech (à copier / SMS) + son téléphone
|
||||
export const techAppLink = (tech) => jget('/roster/tech-link?tech=' + encodeURIComponent(tech))
|
||||
// Table additive « durées par caractéristique » (éditable inline) — seed + apprentissage futur (learned_min)
|
||||
export const getJobChars = () => jget('/roster/job-characteristics')
|
||||
export const saveJobChars = (items) => jpost('/roster/job-characteristics', { items })
|
||||
// Chrono (boucle de capture) : début/fin réels d'un job → durée réelle (apprentissage)
|
||||
export const startJob = (job) => jpost('/roster/job/start', { job })
|
||||
export const finishJob = (job) => jpost('/roster/job/finish', { job })
|
||||
export const techSyncReport = () => jget('/dispatch/legacy-sync/tech-sync')
|
||||
export const techSyncApply = (ids) => jpost('/dispatch/legacy-sync/tech-sync/apply?ids=' + encodeURIComponent((ids || []).join(',')), {})
|
||||
// Retour au POOL legacy (action EXPLICITE) : désassigne (ERPNext) + réécrit le ticket à Tech Targo (3301) dans osTicket
|
||||
export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {})
|
||||
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
|
||||
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ export function useSSE (opts = {}) {
|
|||
} catch {}
|
||||
})
|
||||
|
||||
// Événements nommés génériques : opts.listeners = { 'legacy-update': fn, ... }
|
||||
if (opts.listeners) {
|
||||
for (const [name, cb] of Object.entries(opts.listeners)) {
|
||||
es.addEventListener(name, (e) => { try { cb(JSON.parse(e.data)) } catch {} })
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = (e) => {
|
||||
connected.value = false
|
||||
if (opts.onError) opts.onError(e)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@
|
|||
<q-chip v-if="offShiftWeekCount" dense size="sm" color="orange-8" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour où la ressource n'a AUCUN quart publié. Repère le ⚠ dans la grille → publier un quart ou réassigner.</q-tooltip></q-chip>
|
||||
<q-space />
|
||||
<q-btn-group flat>
|
||||
<q-btn dense flat icon="chevron_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
|
||||
<q-btn dense flat icon="keyboard_double_arrow_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
|
||||
<q-btn dense flat icon="chevron_left" @click="navDay(-1)"><q-tooltip>Jour précédent</q-tooltip></q-btn>
|
||||
<q-btn dense flat label="Auj." @click="navToday" />
|
||||
<q-btn dense flat icon="chevron_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
|
||||
<q-btn dense flat icon="chevron_right" @click="navDay(1)"><q-tooltip>Jour suivant</q-tooltip></q-btn>
|
||||
<q-btn dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
|
||||
</q-btn-group>
|
||||
<q-input dense outlined type="date" v-model="start" style="width:150px" @update:model-value="onWeekChange" />
|
||||
<q-select dense outlined v-model="days" :options="[7, 14]" style="width:76px" emit-value map-options @update:model-value="onDaysChange" />
|
||||
|
|
@ -45,6 +47,8 @@
|
|||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTechSync"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="deep-orange-7" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="open_in_new" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch (détails & priorités)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||||
|
|
@ -55,6 +59,7 @@
|
|||
<q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
|
||||
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
|
||||
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
|
||||
<q-btn outline color="deep-orange" icon="sync_alt" label="Publier au legacy" @click="openLegacyPush"><q-tooltip class="bg-grey-9">Réassigne les tickets aux techniciens DANS le système legacy (osTicket) selon l'assignation Ops — aperçu avant d'écrire.</q-tooltip></q-btn>
|
||||
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
|
||||
</div>
|
||||
|
||||
|
|
@ -152,7 +157,14 @@
|
|||
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
|
||||
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
|
||||
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
|
||||
<q-badge v-if="gapByDay[d.iso]" color="red" floating style="top:2px;right:2px">{{ gapByDay[d.iso] }}</q-badge>
|
||||
<div v-if="capByDay[d.iso] && capByDay[d.iso].cap_h" class="cap-strip" @click.stop>
|
||||
<span v-for="s in capByDay[d.iso].segments" :key="s.key" class="cap-seg" :class="capSegClass(s)">{{ capFmt(s.free) }}
|
||||
<q-tooltip class="bg-grey-9">{{ s.label }} — <b>{{ capFmt(s.free) }} h libre</b> / {{ capFmt(s.cap) }} h · occupé {{ capFmt(s.used) }} h<br><span style="opacity:.8">{{ skillFilter.length ? ('compétence(s) : ' + skillFilter.join(', ')) : 'toutes ressources affichées' }} ({{ visibleTechs.length }} tech)</span></q-tooltip>
|
||||
</span>
|
||||
<q-icon v-if="capByDay[d.iso].overbooked" name="warning" color="negative" size="12px" class="cap-warn">
|
||||
<q-tooltip class="bg-grey-9">Surcharge : {{ capFmt(capByDay[d.iso].due_h) }} h dues > {{ capFmt(capByDay[d.iso].cap_h) }} h de capacité ({{ capByDay[d.iso].jobs_due }} job(s))</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
|
||||
</th>
|
||||
</tr>
|
||||
|
|
@ -170,6 +182,7 @@
|
|||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||||
<span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||||
<q-btn flat dense round size="9px" icon="timeline" color="indigo-5" @click.stop="openTimeline(t)"><q-tooltip>Timeline dispatch de {{ t.name }} — jobs de la semaine & priorités</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="9px" icon="phone_iphone" color="deep-purple-5" @click.stop="techAppLinkMenu(t)"><q-tooltip>Lien app terrain de {{ t.name }} (copier / envoyer par SMS)</q-tooltip></q-btn>
|
||||
<div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
|
||||
<span v-for="sk in (t.skills || [])" :key="sk" class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}<span v-if="(t.skill_levels || {})[sk] || (t.skill_eff || {})[sk]" class="chip-lvl" :style="{ background: skillEffColor(t, sk) }"><q-tooltip class="bg-grey-9">Niveau {{ (t.skill_levels || {})[sk] || '—' }} · {{ effSuffix(skillEffOf(t, sk)) }}</q-tooltip>{{ (t.skill_levels || {})[sk] || '·' }}</span></span>
|
||||
<span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span>
|
||||
|
|
@ -177,18 +190,19 @@
|
|||
<q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
</td>
|
||||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
|
||||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @contextmenu.prevent="onCellContext(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
|
||||
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
|
||||
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
|
||||
</template>
|
||||
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso)">
|
||||
<div class="tl">
|
||||
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||||
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayEditor(t, d)" @mousedown.stop><q-tooltip class="bg-grey-9" :delay="400" style="font-size:11px">Éditer la tournée du jour</q-tooltip></div>
|
||||
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift tl-shift-click" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }" @click.stop="openDayFromCell(t, d)"><q-tooltip class="bg-grey-9" :delay="500" style="font-size:11px">{{ b.oncall ? 'Garde — voir le jour' : 'Quart publié — voir les jobs / la tournée' }}</q-tooltip></div>
|
||||
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk tl-blk-click" :style="blockStyle(b, cellPct(t.id, d.iso))" @click.stop="openDayFromCell(t, d)" @mousedown.stop><q-tooltip class="bg-grey-9" :delay="400" style="font-size:11px">Clic = voir la tournée / les jobs</q-tooltip></div>
|
||||
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
|
||||
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
|
||||
<q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px">
|
||||
<div class="text-weight-medium">{{ cellTip(t.id, d.iso) }}</div>
|
||||
<div class="text-blue-3" style="font-size:10px">Clic sur le quart / les jobs = tournée · clic sur le fond = horaire · glisser = sélection</div>
|
||||
<template v-if="cellJobs(t.id, d.iso).length">
|
||||
<div class="text-amber-4 q-mt-xs" style="font-size:11px">{{ cellJobs(t.id, d.iso).length }} job(s) · par priorité</div>
|
||||
<div v-for="j in cellJobs(t.id, d.iso)" :key="j.name" class="row items-center no-wrap" style="gap:5px;font-size:11px;line-height:1.5">
|
||||
|
|
@ -218,14 +232,7 @@
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle2 text-weight-bold q-mt-lg q-mb-sm">Couverture — dispo vs requis</div>
|
||||
<div v-if="!covRows.length" class="text-grey-6 q-mb-md">Aucun besoin défini. Utilise « Demande » → « Appliquer à la semaine ».</div>
|
||||
<div v-else class="grid-wrap">
|
||||
<table class="roster-grid">
|
||||
<thead><tr><th class="tech-col">Créneau</th><th v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }"><div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div></th></tr></thead>
|
||||
<tbody><tr v-for="row in covRows" :key="row.key"><td class="tech-col">{{ row.label }}</td><td v-for="d in dayList" :key="d.iso" class="cell cov" :style="covStyle(row.key, d.iso)">{{ covText(row.key, d.iso) }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Matrice « Couverture — dispo vs requis » retirée : le besoin = toutes les spécialités chaque jour (règle simple, à automatiser via AI plus tard). -->
|
||||
|
||||
<q-dialog v-model="showShiftEditor">
|
||||
<q-card style="min-width:580px">
|
||||
|
|
@ -532,9 +539,10 @@
|
|||
</q-dialog>
|
||||
|
||||
<!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) → glisser sur une case (tech × jour) -->
|
||||
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px' }">
|
||||
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px', width: assignPanel.w + 'px', height: assignPanel.h + 'px', maxHeight: '92vh' }">
|
||||
<div class="assign-hdr" @mousedown="panelHeaderDown">
|
||||
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignPanel.jobs.length }})</span><q-space />
|
||||
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
|
||||
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
|
||||
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
|
||||
</div>
|
||||
|
|
@ -547,6 +555,17 @@
|
|||
<option value="city">Ville</option>
|
||||
<option value="priority">Priorité</option>
|
||||
</select>
|
||||
<q-btn dense flat size="sm" :icon="assignSortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="assignSortDir = assignSortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ assignSortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div v-if="assignTypes.length > 1" class="assign-chips" @mousedown.stop>
|
||||
<span v-for="t in assignTypes" :key="t.k" class="assign-chip-f" :style="assignTypeFilter.includes(t.k) ? { background: getTagColor(t.k), color: '#fff', borderColor: getTagColor(t.k) } : { borderColor: getTagColor(t.k) }" @click="toggleAssignType(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
|
||||
<span v-if="assignTypeFilter.length" class="assign-chip-f clear" @click="assignTypeFilter = []"><q-tooltip>Tout afficher</q-tooltip>✕</span>
|
||||
</div>
|
||||
<!-- Carte des jobs à assigner : 1 pin par adresse (lettre = groupe de ≥2 jobs à la même adresse) → voir les régions, clic = sélectionner le groupe local -->
|
||||
<div v-show="assignPanel.showMap" class="assign-map-wrap" @mousedown.stop>
|
||||
<div ref="assignMapEl" class="assign-map"></div>
|
||||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
<div class="assign-map-cap">📍 Pins colorés par <b>compétence</b> · <b>lettre</b> = repère (même lettre dans la liste) · clic = sélectionne le(s) job(s)<span v-if="assignNoCoord" class="text-deep-orange-7"> · ⚠ {{ assignNoCoord }} sans coords</span></div>
|
||||
</div>
|
||||
<div class="assign-body">
|
||||
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement…</div>
|
||||
|
|
@ -554,28 +573,187 @@
|
|||
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
|
||||
<div v-if="grp.label" class="assign-grp-lbl">{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></div>
|
||||
<div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||||
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||||
<div v-for="(j, idx) in grp.jobs" :key="j.name" :data-jobname="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name), 'job-flash': flashJob === j.name }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
|
||||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
||||
<span v-if="groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ groupLabel(j)[0] }}) — simple repère de proximité</q-tooltip>{{ groupLabel(j) }}</span>
|
||||
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
|
||||
<span class="ellipsis text-weight-medium">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
|
||||
<span class="ellipsis text-weight-medium" :style="assignPanel.showMap ? 'cursor:pointer' : ''" @click="assignPanel.showMap && focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
|
||||
<q-space />
|
||||
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
|
||||
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
|
||||
</div>
|
||||
<div class="assign-sub">
|
||||
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
||||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · {{ j.duration_h || 1 }}h
|
||||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-teal-9 text-weight-medium">≈ {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-deep-orange-7 text-weight-bold' : 'text-grey-7'"> · 📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
|
||||
</div>
|
||||
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
|
||||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
|
||||
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
|
||||
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
|
||||
</template>
|
||||
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
|
||||
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="closeLegacyTicket(j)"><q-tooltip>Ticket inutile → status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="assignPanel.jobs.length" class="assign-foot">
|
||||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b> — glisse la sélection sur un tech</template>
|
||||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b> — glisse sur un tech<q-btn dense unelevated size="sm" color="negative" icon="block" :label="'Fermer (' + selectedNames.length + ')'" class="q-ml-sm" @click="bulkCloseSelected"><q-tooltip>Fermer les tickets sélectionnés dans le legacy (au lieu de réassigner)</q-tooltip></q-btn></template>
|
||||
<template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template>
|
||||
</div>
|
||||
<div class="assign-resize" @mousedown.stop="panelResizeDown"><q-tooltip>Redimensionner</q-tooltip></div>
|
||||
</div>
|
||||
|
||||
<!-- Write-back legacy : aperçu des réassignations osTicket → confirmer avant d'écrire dans le legacy -->
|
||||
<q-dialog v-model="legacyPush.open">
|
||||
<q-card style="min-width:540px;max-width:660px">
|
||||
<q-card-section class="row items-center q-pb-sm">
|
||||
<q-icon name="sync_alt" color="deep-orange" size="22px" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-bold">Publier les assignations au legacy</div>
|
||||
<div class="text-caption text-grey-7">Réassigne <code>ticket.assign_to</code> au technicien dans osTicket (selon l'assignation Ops).</div>
|
||||
</div>
|
||||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<div v-if="legacyPush.loading" class="text-grey-6 q-pa-md text-center"><q-spinner size="20px" class="q-mr-sm" />Analyse des assignations…</div>
|
||||
<template v-else-if="legacyPush.preview">
|
||||
<q-banner v-if="!legacyPush.preview.pushed" dense rounded class="bg-blue-1 text-blue-9 q-mb-sm"><template #avatar><q-icon name="info" /></template>Rien à pousser. Assigne d'abord des jobs à des techs (glisser-déposer), puis reviens ici.</q-banner>
|
||||
<div v-else class="text-body2 q-mb-sm"><b class="text-deep-orange-8">{{ legacyPush.preview.pushed }}</b> ticket(s) seront réassignés dans le legacy<span v-if="legacyPush.preview.skipped" class="text-grey-6"> · {{ legacyPush.preview.skipped }} déjà à jour/fermé(s)</span><span v-if="legacyPush.preview.mismatch" class="text-deep-orange-7"> · {{ legacyPush.preview.mismatch }} ignoré(s) (nom ≠)</span>.</div>
|
||||
<q-list dense bordered class="rounded-borders" style="max-height:300px;overflow:auto">
|
||||
<q-item v-for="(s, i) in (legacyPush.preview.samples || [])" :key="i" class="q-py-xs">
|
||||
<q-item-section avatar style="min-width:28px"><q-icon :name="s.action ? 'warning' : 'arrow_forward'" :color="s.action ? 'deep-orange' : 'green'" size="16px" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label v-if="s.action" style="font-size:12px" class="text-deep-orange-8">Ticket #{{ s.ticket }} — Ops « {{ s.ops_nom }} » ≠ legacy « {{ s.legacy_staff }} » → <b>ignoré</b></q-item-label>
|
||||
<q-item-label v-else style="font-size:12px">Ticket #{{ s.ticket }} → <b>{{ s.vers_staff }}</b><span class="text-grey-6"> · {{ s.sujet }}</span></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-if="s.job"><q-btn flat dense round size="sm" icon="close" color="negative" :disable="legacyPush.applying" @click="removeFromPush(s)"><q-tooltip>Désassigner ce job (retour au pool) — ne sera pas poussé</q-tooltip></q-btn></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="text-caption text-grey-6 q-mt-xs">Sécurité : seuls les tickets <b>ouverts</b> sont touchés, le nom du staff legacy doit concorder, et c'est idempotent.</div>
|
||||
</template>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-checkbox v-if="legacyPush.preview && legacyPush.preview.pushed" v-model="legacyPush.notify" dense size="sm" color="deep-orange" class="q-mr-auto" label="Notifier les techs par courriel">
|
||||
<q-tooltip class="bg-grey-9" max-width="320px">Envoie à chaque tech réassigné le courriel d'assignation (résumé + adresse + lien <b>Répondre</b>), identique au legacy.</q-tooltip>
|
||||
</q-checkbox>
|
||||
<q-btn flat label="Annuler" v-close-popup />
|
||||
<q-btn unelevated color="deep-orange" icon="cloud_upload" label="Écrire dans le legacy" :loading="legacyPush.applying" :disable="!legacyPush.preview || !legacyPush.preview.pushed" @click="applyLegacyPush" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Sélecteur d'emplacement (jobs « hors carte ») : carte bornée au sud de Montréal + recherche d'adresse RQA -->
|
||||
<q-dialog v-model="locPicker.open" @show="initLocMap" @hide="destroyLocMap">
|
||||
<q-card style="width: 700px; max-width: 96vw">
|
||||
<q-card-section class="row items-center q-pb-xs">
|
||||
<div class="text-subtitle2">📍 Situer le job <span class="text-grey-7">— {{ locPicker.subject }}</span></div>
|
||||
<q-space />
|
||||
<q-btn dense flat round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input dense outlined v-model="locPicker.search" placeholder="Chercher une adresse (base RQA Québec)…" class="q-mb-xs" :loading="locPicker.searching" @keyup.enter="locSearch" clearable>
|
||||
<template #append><q-btn dense flat icon="search" @click="locSearch" /></template>
|
||||
</q-input>
|
||||
<q-list v-if="locPicker.results.length" bordered dense class="loc-results q-mb-xs">
|
||||
<q-item v-for="(rr, i) in locPicker.results" :key="i" clickable @click="pickLocResult(rr)">
|
||||
<q-item-section>
|
||||
<q-item-label>{{ rr.address_full || ((rr.numero || '') + ' ' + (rr.rue || '')) }}</q-item-label>
|
||||
<q-item-label caption>{{ rr.ville }} {{ rr.code_postal }}<span v-if="rr.fiber_available" class="text-positive"> · fibre</span></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side><q-icon name="place" color="deep-orange-6" /></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="loc-map-wrap">
|
||||
<div ref="locMapEl" class="loc-map"></div>
|
||||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
<div class="loc-map-hint">Clic = situer · glisser le repère · <b>clic droit = Street View</b></div>
|
||||
</div>
|
||||
<q-input dense outlined v-model="locPicker.address" label="Adresse (modifiable, écrite sur le job)" class="q-mt-sm" />
|
||||
<div class="text-caption text-grey-6 q-mt-xs" v-if="locPicker.lat != null">Coordonnées : {{ locPicker.lat }}, {{ locPicker.lon }}</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Annuler" v-close-popup />
|
||||
<q-btn unelevated color="primary" icon="place" label="Utiliser cet emplacement" :loading="locPicker.saving" :disable="locPicker.lat == null" @click="saveLocPicker" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Synchroniser les techniciens : réconciliation staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech -->
|
||||
<q-dialog v-model="techSync.open">
|
||||
<q-card style="width: 720px; max-width: 96vw">
|
||||
<q-card-section class="row items-center q-pb-xs">
|
||||
<div class="text-subtitle2">👥 Synchroniser les techniciens</div>
|
||||
<q-space />
|
||||
<q-btn dense flat round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section v-if="techSync.loading" class="text-center q-pa-lg"><q-spinner size="28px" color="primary" /></q-card-section>
|
||||
<template v-else-if="techSync.report">
|
||||
<q-card-section class="q-pt-none">
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Staff legacy actifs : <b>{{ techSync.report.counts.staff_active }}</b> · Fiches roster : <b>{{ techSync.report.counts.fiches }}</b> · Groupe Authentik <code>tech</code> : <b>{{ techSync.report.authentik ? techSync.report.counts.tech_group : '—' }}</b></div>
|
||||
<q-expansion-item default-opened dense icon="group_add" header-class="text-green-8" :label="`À ajouter au roster (${techSync.report.staff_missing_fiche.length})`">
|
||||
<div v-if="!techSync.report.staff_missing_fiche.length" class="text-grey-6 q-pa-sm">Aucun — le roster est à jour ✅</div>
|
||||
<q-list v-else dense bordered class="ts-list">
|
||||
<q-item v-for="s in techSync.report.staff_missing_fiche" :key="s.staff_id" tag="label">
|
||||
<q-item-section side><q-checkbox v-model="techSync.sel[s.staff_id]" dense color="green-7" /></q-item-section>
|
||||
<q-item-section><q-item-label>{{ s.name }} <span class="text-grey-5">#{{ s.staff_id }}</span></q-item-label><q-item-label caption>{{ s.email || '— sans courriel —' }}</q-item-label></q-item-section>
|
||||
<q-item-section side><q-chip dense size="sm" :color="s.in_tech_group ? 'green-2' : 'orange-2'" :text-color="s.in_tech_group ? 'green-9' : 'orange-9'">{{ s.in_tech_group ? 'accès tech ✓' : 'hors groupe tech' }}</q-chip></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item v-if="techSync.report.authentik && techSync.report.fiche_no_access.length" dense icon="lock" header-class="text-deep-orange-8" :label="`Fiches SANS accès Ops (${techSync.report.fiche_no_access.length})`">
|
||||
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Pas dans le groupe Authentik <code>tech</code> → à ajouter manuellement sur auth.targo.ca (lecture seule ici).</div>
|
||||
<q-list dense class="ts-list"><q-item v-for="t in techSync.report.fiche_no_access" :key="t.technician_id"><q-item-section><q-item-label>{{ t.name }}</q-item-label><q-item-label caption>{{ t.email }}</q-item-label></q-item-section></q-item></q-list>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item v-if="techSync.report.authentik && techSync.report.access_no_fiche.length" dense icon="help_outline" :label="`Dans le groupe tech, hors roster (${techSync.report.access_no_fiche.length})`">
|
||||
<div class="text-caption text-grey-6 q-px-sm q-pt-xs">Ont l'accès Ops mais aucune fiche (ex-employé ? courriel ≠ legacy ?).</div>
|
||||
<div class="q-pa-sm" style="font-size:11px;color:#78909c;line-height:1.6">{{ techSync.report.access_no_fiche.join(' · ') }}</div>
|
||||
</q-expansion-item>
|
||||
<q-banner v-if="!techSync.report.authentik" dense class="bg-orange-1 text-orange-9 q-mt-sm"><template #avatar><q-icon name="warning" /></template>Authentik non joignable — vérif d'accès indisponible, seul l'ajout au roster fonctionne.</q-banner>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Fermer" v-close-popup />
|
||||
<q-btn unelevated color="green-7" icon="group_add" :label="`Créer les fiches cochées (${techSyncSelCount})`" :loading="techSync.applying" :disable="!techSyncSelCount" @click="applyTechSync" />
|
||||
</q-card-actions>
|
||||
</template>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Tableur « durées par caractéristique » (additif) — édition inline -->
|
||||
<q-dialog v-model="jobChar.open">
|
||||
<q-card style="width: 860px; max-width: 97vw">
|
||||
<q-card-section class="row items-center q-pb-xs">
|
||||
<div class="text-subtitle2">⏱ Durées par caractéristique <span class="text-grey-6">— additif, hors transport</span></div>
|
||||
<q-space /><q-btn dense flat round icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section v-if="jobChar.loading" class="text-center q-pa-md"><q-spinner color="primary" size="26px" /></q-card-section>
|
||||
<q-card-section v-else class="q-pt-none">
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Durée d'un job = <b>1 base</b> + <b>caractéristiques</b> cochées (×qté), × vitesse du tech. Minutes éditables. Les valeurs <b>apprises</b> (geofence) viendront calibrer ces seeds par régression.</div>
|
||||
<table class="jc-table">
|
||||
<thead><tr><th>Catégorie</th><th>Libellé</th><th>Min</th><th>×Qté</th><th>Mots-clés (auto-détection)</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(it, i) in jobChar.items" :key="i" :class="'jc-' + it.cat">
|
||||
<td><q-select dense options-dense outlined v-model="it.cat" :options="['base', 'addon', 'modifier']" style="min-width:92px" /></td>
|
||||
<td><q-input dense outlined v-model="it.label" /></td>
|
||||
<td style="white-space:nowrap"><q-input dense outlined type="number" v-model.number="it.minutes" style="width:64px" /><span v-if="it.learned_min != null" class="jc-learn"> appris {{ it.learned_min }}m·{{ it.samples }}</span></td>
|
||||
<td class="text-center"><q-toggle dense size="sm" v-model="it.per_qty" /></td>
|
||||
<td><q-input dense outlined v-model="it.keywords" placeholder="ex. enfoui,enterré" /></td>
|
||||
<td><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="jobChar.items.splice(i, 1)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat dense icon="add" label="Ajouter une ligne" color="primary" @click="addJobCharRow" />
|
||||
<q-space />
|
||||
<q-btn flat label="Fermer" v-close-popup />
|
||||
<q-btn unelevated color="primary" icon="save" label="Enregistrer" :loading="jobChar.saving" @click="saveJobChar" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) -->
|
||||
<q-dialog v-model="timelineDlg.open">
|
||||
<q-card style="min-width:560px;max-width:780px">
|
||||
|
|
@ -682,7 +860,8 @@
|
|||
<!-- carte interactive : itinéraire ROUTIER réel + pins numérotés, navigable (zoom molette/boutons, déplacement) -->
|
||||
<div v-show="dayEditor.list.length" class="de-map-wrap">
|
||||
<div ref="dayMapEl" class="de-map-gl"></div>
|
||||
<div class="de-map-cap">🗺 Itinéraire routier · molette/boutons = zoom · glisser = déplacer<span v-if="dayNoCoord" class="text-deep-orange-7"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
|
||||
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
|
||||
<div class="de-map-cap">🗺 Itinéraire routier · zoom molette · <b>clic droit = Street View</b><span v-if="dayNoCoord" class="text-deep-orange-7"> · ⚠ {{ dayNoCoord }} sans coords (absent de la carte)</span></div>
|
||||
</div>
|
||||
<div v-if="!dayEditor.list.length" class="text-grey-6 q-pa-md text-center">Aucun job ce jour.</div>
|
||||
<!-- liste éditable : flèches/glisser pour réordonner · durée en minutes · ✕ pour retirer -->
|
||||
|
|
@ -701,16 +880,31 @@
|
|||
<q-icon name="drag_indicator" size="16px" class="text-grey-5" style="cursor:grab" />
|
||||
<span class="de-ord">{{ i + 1 }}</span>
|
||||
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
|
||||
<div class="col" style="min-width:0;cursor:pointer" @click="j.showDetail = !j.showDetail; focusDayJob(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
|
||||
<div class="col" style="min-width:0;cursor:pointer" @click="toggleJobDetail(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
|
||||
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
|
||||
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}–{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-deep-orange-7"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div>
|
||||
<div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j)" class="loc-pick-link text-deep-orange-7" @click.stop="openLocPicker(j)"> · ⚠ sans coords — 📍 situer</span></div>
|
||||
</div>
|
||||
<div class="de-dur"><input type="number" min="5" step="5" :value="jobMinutes(j)" @change="setJobMinutes(j, $event.target.value)" @click.stop @mousedown.stop /><span>min</span></div>
|
||||
<!-- sélecteur de priorité retiré (défaut « Moyenne » conservé en donnée) → gain de place ; priorité gérée au Dispatch -->
|
||||
<q-btn v-if="!j.actual_start" flat dense round size="sm" icon="play_arrow" color="green-6" @click="startJobChrono(j)"><q-tooltip>Démarrer (chrono réel → apprentissage)</q-tooltip></q-btn>
|
||||
<q-btn v-else-if="!j.actual_end" flat dense round size="sm" icon="stop_circle" color="deep-orange" class="chrono-run" @click="finishJobChrono(j)"><q-tooltip>Terminer (chrono réel)</q-tooltip></q-btn>
|
||||
<span v-else class="de-actual" :title="'Durée réelle mesurée'">⏱{{ j.actual_min != null ? j.actual_min : '?' }}m</span>
|
||||
<q-btn flat dense round size="sm" icon="edit_location_alt" :color="hasLL(j) ? 'blue-grey-5' : 'deep-orange'" @click="openLocPicker(j)"><q-tooltip>{{ hasLL(j) ? 'Repositionner sur la carte' : 'Situer sur la carte (sans coords)' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div v-if="j.showDetail" class="de-detail">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}</div>
|
||||
<div v-if="j.showDetail" class="de-detail">
|
||||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="13px" class="q-mr-xs" />Chargement du fil…</div>
|
||||
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
|
||||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ j._thread.messages.length }} message(s){{ j._thread.status ? ' · ' + j._thread.status : '' }}</div>
|
||||
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="de-msg">
|
||||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span></div>
|
||||
<div class="de-msg-txt">{{ m.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else style="white-space:pre-wrap;font-size:11px">{{ j.detail || 'Aucun détail importé pour ce ticket.' }}<span v-if="j._thread && j._thread.error" class="text-deep-orange-7"> · fil indisponible</span></div>
|
||||
</div>
|
||||
</template>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="dayEditor.list.length" class="row items-center q-pt-none">
|
||||
|
|
@ -747,6 +941,8 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr
|
|||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
|
||||
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
||||
import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API Mapbox Matrix), déjà utilisé par le Dispatch
|
||||
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
|
||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||
|
|
@ -761,6 +957,31 @@ const techs = ref([])
|
|||
const templates = ref([])
|
||||
const assignments = ref([])
|
||||
const coverageData = ref([])
|
||||
// Dispo restante par jour × segment (AM/PM/Soir), calculée CLIENT-SIDE sur les ressources AFFICHÉES (visibleTechs)
|
||||
// → suit le filtre par compétence : cliquer « installation » ⇒ visibleTechs = techs installation ⇒ capacité = leurs shifts.
|
||||
// Soir (17-21h) RETIRÉ des blocs offerts : c'est une capacité de secours/urgence/garde uniquement, jamais proposée au client.
|
||||
const DAY_SEGS = [{ key: 'am', label: 'AM', from: 8, to: 12 }, { key: 'pm', label: 'PM', from: 12, to: 17 }]
|
||||
const _segOv = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
|
||||
const _r1 = (x) => Math.round(x * 10) / 10
|
||||
const _toH = (t) => { if (t == null) return 0; if (typeof t === 'number') return t; const p = String(t).split(':'); return (Number(p[0]) || 0) + (Number(p[1]) || 0) / 60 }
|
||||
const capByDay = computed(() => {
|
||||
const out = {}; const tids = visibleTechs.value.map(t => t.id); const cbtd = cellsByTechDay.value; const tpl = tplByName.value
|
||||
for (const d of dayList.value) {
|
||||
const segs = DAY_SEGS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 }))
|
||||
for (const tid of tids) {
|
||||
const asgs = (cbtd[tid] && cbtd[tid][d.iso]) || [] // quarts publiés/proposés du tech ce jour
|
||||
for (const a of asgs) { const t = tpl[a.shift]; if (!t || t.on_call) continue; const s = _toH(t.start_time), e = _toH(t.end_time); if (!(e > s)) continue; for (let i = 0; i < DAY_SEGS.length; i++) segs[i].cap += _segOv(s, e, DAY_SEGS[i].from, DAY_SEGS[i].to) }
|
||||
const occ = occByTechDay.value[tid + '|' + d.iso] // jobs placés (blocs horaires)
|
||||
if (occ && occ.blocks) for (const b of occ.blocks) for (let i = 0; i < DAY_SEGS.length; i++) segs[i].used += _segOv(b.s, b.e, DAY_SEGS[i].from, DAY_SEGS[i].to)
|
||||
}
|
||||
let due_h = 0, jobs_due = 0 // charge DUE = pool non assigné ce jour (filtré par compétence si actif)
|
||||
for (const j of (assignPanel.jobs || [])) { if (j.scheduled_date !== d.iso) continue; if (skillFilter.value.length && !skillFilter.value.includes(j.required_skill)) continue; due_h += (j.est_min ? j.est_min / 60 : Number(j.duration_h || j.dur || 0)); jobs_due++ }
|
||||
segs.forEach(s => { s.cap = _r1(s.cap); s.used = _r1(s.used); s.free = _r1(s.cap - s.used) })
|
||||
const cap_h = _r1(segs.reduce((x, s) => x + s.cap, 0)); const used_h = _r1(segs.reduce((x, s) => x + s.used, 0))
|
||||
out[d.iso] = { segments: segs, cap_h, used_h, free_h: _r1(cap_h - used_h), due_h: _r1(due_h), jobs_due, overbooked: cap_h > 0 && due_h > cap_h }
|
||||
}
|
||||
return out
|
||||
})
|
||||
const dailyStats = ref([])
|
||||
const solverStats = ref(null)
|
||||
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
|
||||
|
|
@ -797,8 +1018,10 @@ const newTplRange = computed({ get: () => ({ min: hToNum(newTpl.start) || 8, max
|
|||
|
||||
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
|
||||
|
||||
function upcomingMonday () { const d = new Date(); d.setDate(d.getDate() + ((1 - d.getDay() + 7) % 7)); return d.toISOString().slice(0, 10) }
|
||||
function thisMonday () { const d = new Date(); const diff = (d.getDay() === 0 ? -6 : 1) - d.getDay(); d.setDate(d.getDate() + diff); return d.toISOString().slice(0, 10) }
|
||||
// Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC− → cause des « jours décalés »).
|
||||
function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
|
||||
function upcomingMonday () { const t = todayISO(); return addDaysISO(t, (1 - dowOf(t) + 7) % 7) } // prochain lundi (aujourd'hui si lundi), calcul UTC-cohérent
|
||||
function thisMonday () { return mondayISO(todayISO()) } // lundi de la semaine COURANTE (pour « Auj. »)
|
||||
function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) }
|
||||
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||||
const dayList = computed(() => {
|
||||
|
|
@ -938,13 +1161,30 @@ async function doRedistribute (mode) { // « Tout à recontacter » (bascule sim
|
|||
} catch (e) { err(e) } finally { redistributing.value = false }
|
||||
}
|
||||
// ── Panneau FLOTTANT « jobs à assigner » (multi-sélection + glisser-déposer + aperçu d'occupation) ──
|
||||
const assignPanel = reactive({ open: false, x: 40, y: 130, jobs: [], loading: false })
|
||||
const assignPanel = reactive({ open: false, x: 40, y: 130, w: 340, h: 560, jobs: [], loading: false, showMap: false })
|
||||
const draggingJobName = ref(null); const dropCell = ref(null); const dragHours = ref(0)
|
||||
const selectedJobs = reactive({}) // jobName → true
|
||||
const dropPreview = reactive({ key: null, addH: 0 })
|
||||
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
|
||||
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
|
||||
const assignSort = ref('group') // group (parent-enfant) | skill | date | city | priority
|
||||
|
||||
// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
|
||||
const legacyPush = reactive({ open: false, loading: false, applying: false, preview: null, notify: true })
|
||||
async function openLegacyPush () { legacyPush.open = true; legacyPush.loading = true; legacyPush.preview = null; try { legacyPush.preview = await roster.pushLegacyPreview() } catch (e) { err(e) } finally { legacyPush.loading = false } }
|
||||
async function applyLegacyPush () { legacyPush.applying = true; try { const r = await roster.pushLegacyApply(legacyPush.notify); $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Legacy : ${r.pushed} ticket(s) réassigné(s)` + (legacyPush.notify && r.notified != null ? ` · ${r.notified} avisé(s) par courriel (boîte du tech)` : '') + (r.mismatch ? ` · ${r.mismatch} ignoré(s) (nom ≠)` : '') + (r.errors ? ` · ${r.errors} erreur(s)` : '') }); if (legacyPush.notify && r.notify_errors && r.notify_errors.length) { $q.notify({ type: 'warning', icon: 'mark_email_unread', message: `⚠ ${r.notify_errors.length} avis NON envoyé(s) — ${(r.notify_errors[0] && r.notify_errors[0].error) || ''}`, timeout: 6000 }) } legacyPush.open = false } catch (e) { err(e) } finally { legacyPush.applying = false } }
|
||||
// X sur une ligne de l'aperçu → désassigne le job dans Ops (retour au pool) → l'exclut du push, puis rafraîchit l'aperçu + la grille.
|
||||
async function removeFromPush (s) {
|
||||
if (!s || !s.job) { $q.notify({ type: 'warning', message: 'Job introuvable' }); return }
|
||||
legacyPush.applying = true
|
||||
try {
|
||||
await roster.unassignJobRoster(s.job)
|
||||
await reloadOccupancy()
|
||||
legacyPush.preview = await roster.pushLegacyPreview()
|
||||
$q.notify({ type: 'info', message: 'Job désassigné (retour au pool) — exclu du legacy', timeout: 2200 })
|
||||
} catch (e) { err(e) } finally { legacyPush.applying = false }
|
||||
}
|
||||
const assignSort = ref('date') // défaut = date DUE (facilite le dispatch : aujourd'hui/en retard d'abord). Autres : group | skill | city | priority
|
||||
const assignSortDir = ref('asc') // sens du tri (asc/desc) — surtout pour la date
|
||||
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
function jobCity (j) {
|
||||
const a = String(j.location_label || j.service_location || '')
|
||||
|
|
@ -953,8 +1193,20 @@ function jobCity (j) {
|
|||
const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom »
|
||||
return parts[0] || 'Sans ville'
|
||||
}
|
||||
// Chips-filtres par compétence/type (installation, réparation, tv…) dans le panneau d'assignation.
|
||||
const assignTypeFilter = ref([]) // types sélectionnés (vide = tous)
|
||||
const assignTypes = computed(() => { const m = {}; for (const j of assignPanel.jobs) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) })
|
||||
const assignJobsFiltered = computed(() => { const f = assignTypeFilter.value; if (!f.length) return assignPanel.jobs; const set = new Set(f); return assignPanel.jobs.filter(j => set.has(j.required_skill || 'autre')) })
|
||||
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
|
||||
// Date DUE (≠ date de création) : étiquette relative pour grouper/afficher (En retard / Aujourd'hui / future).
|
||||
function isOverdue (d) { return !!d && d !== 'Sans date' && d < todayISO() }
|
||||
function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d }
|
||||
// Couleur d'un segment de capacité (heures-tech libres restantes) : rouge=plein, orange=presque plein, vert=ok, gris=aucun quart.
|
||||
function capSegClass (s) { if (!s || s.cap <= 0) return 'cap-none'; if (s.free <= 0) return 'cap-full'; if (s.free < 1.5) return 'cap-low'; return 'cap-ok' }
|
||||
const capFmt = (h) => (h === 0 ? '0' : (Number.isInteger(h) ? String(h) : h.toFixed(1)))
|
||||
const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
|
||||
const assignGroups = computed(() => {
|
||||
const jobs = assignPanel.jobs
|
||||
const jobs = assignJobsFiltered.value
|
||||
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
|
||||
const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
|
||||
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
|
||||
|
|
@ -963,9 +1215,10 @@ const assignGroups = computed(() => {
|
|||
: assignSort.value === 'city' ? jobCity(j)
|
||||
: assignSort.value === 'priority' ? (j.priority || 'low')
|
||||
: (j.scheduled_date || 'Sans date')
|
||||
const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : k
|
||||
const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : assignSort.value === 'date' ? '📅 ' + fmtDueLabel(k) : k
|
||||
const g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
|
||||
const keys = Object.keys(g).sort((a, b) => assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b))
|
||||
const dir = assignSortDir.value === 'desc' ? -1 : 1
|
||||
const keys = Object.keys(g).sort((a, b) => (assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir)
|
||||
return keys.map(k => ({ key: k, label: labelOf(k), jobs: g[k] }))
|
||||
})
|
||||
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
|
||||
|
|
@ -1007,13 +1260,218 @@ async function onCellDrop (ev, t, d) {
|
|||
let ok = 0
|
||||
for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
|
||||
assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
|
||||
$q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await loadWeek()
|
||||
$q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await reloadOccupancy() // refresh rapide du timeline (blocs) sans loadWeek complet
|
||||
}
|
||||
let _panelDrag = null // déplacement du panneau via son en-tête
|
||||
function panelHeaderDown (ev) { _panelDrag = { dx: ev.clientX - assignPanel.x, dy: ev.clientY - assignPanel.y }; document.addEventListener('mousemove', panelMove); document.addEventListener('mouseup', panelUp) }
|
||||
function panelMove (ev) { if (!_panelDrag) return; assignPanel.x = Math.max(0, ev.clientX - _panelDrag.dx); assignPanel.y = Math.max(0, ev.clientY - _panelDrag.dy) }
|
||||
function panelUp () { _panelDrag = null; document.removeEventListener('mousemove', panelMove); document.removeEventListener('mouseup', panelUp) }
|
||||
|
||||
// ── Panneau d'assignation : redimensionnement (poignée coin) ──
|
||||
let _panelResize = null
|
||||
function panelResizeDown (ev) { _panelResize = { x: ev.clientX, y: ev.clientY, w: assignPanel.w, h: assignPanel.h }; document.addEventListener('mousemove', panelResizeMove); document.addEventListener('mouseup', panelResizeUp); ev.preventDefault() }
|
||||
function panelResizeMove (ev) { if (!_panelResize) return; assignPanel.w = Math.max(300, Math.round(_panelResize.w + (ev.clientX - _panelResize.x))); assignPanel.h = Math.max(320, Math.round(_panelResize.h + (ev.clientY - _panelResize.y))); if (_assignMap) _assignMap.resize() }
|
||||
function panelResizeUp () { _panelResize = null; document.removeEventListener('mousemove', panelResizeMove); document.removeEventListener('mouseup', panelResizeUp) }
|
||||
|
||||
// ── Étiquettes de GROUPE par adresse de service : une adresse avec ≥2 jobs = groupe lettré (A, B…), rang interne (A1, A2…). Lie liste ↔ pins. ──
|
||||
function _normAddr (s) { return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ').trim() }
|
||||
function _colLetter (n) { let s = ''; n++; while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26) } return s }
|
||||
function _jobLocKey (j) { const a = _normAddr(j.address); if (a) return a; return j.service_location ? 'sl:' + j.service_location : '' }
|
||||
// Localisations des jobs géolocalisés : 1 LETTRE (A, B, C…) par ADRESSE = 1 pin (couleur = compétence). Lie la liste aux pins.
|
||||
const assignLocations = computed(() => {
|
||||
const by = {}; const order = []
|
||||
for (const j of assignPanel.jobs) {
|
||||
if (!_hasJobLL(j)) continue
|
||||
const k = (Math.round(+j.latitude * 1e5) / 1e5) + ',' + (Math.round(+j.longitude * 1e5) / 1e5)
|
||||
if (!by[k]) { by[k] = { lat: +j.latitude, lon: +j.longitude, jobs: [] }; order.push(k) }
|
||||
by[k].jobs.push(j)
|
||||
}
|
||||
const stops = []; const byName = {}
|
||||
order.forEach((k, i) => {
|
||||
const v = by[k]; const letter = _colLetter(i)
|
||||
const skJob = v.jobs.find(j => j.required_skill || j.skill) || {}; const sk = skJob.required_skill || skJob.skill || ''
|
||||
const j0 = v.jobs[0]
|
||||
stops.push({ lat: v.lat, lon: v.lon, letter, color: sk ? getTagColor(sk) : '#90a4ae', count: v.jobs.length, names: v.jobs.map(j => j.name), title: (j0.address || j0.subject || '').slice(0, 60) })
|
||||
for (const j of v.jobs) byName[j.name] = letter
|
||||
})
|
||||
return { stops, byName }
|
||||
})
|
||||
function groupLabel (j) { return assignLocations.value.byName[j.name] || '' } // lettre du pin de ce job (repère liste ↔ carte)
|
||||
|
||||
// ── Carte des jobs à assigner (Mapbox GL) : 1 pin par adresse, lettre = groupe ; clic = sélectionne les jobs co-localisés ──
|
||||
const assignMapEl = ref(null); let _assignMap = null, _assignMapRO = null
|
||||
|
||||
// ── Bascule vue SATELLITE (couche raster Mapbox superposée SOUS les pins ; pas de setStyle = pins conservés) ──
|
||||
// Préférence partagée par les 2 cartes (panneau + journée), persistée. addSatLayer() est appelé dans chaque on('load')
|
||||
// AVANT les couches de pins → le raster reste dessous. toggleSat() bascule la visibilité sur les cartes vivantes.
|
||||
const satView = ref((() => { try { return localStorage.getItem('plan_sat') === '1' } catch (e) { return false } })())
|
||||
function addSatLayer (map) {
|
||||
try {
|
||||
if (!map.getSource('sat')) map.addSource('sat', { type: 'raster', url: 'mapbox://mapbox.satellite', tileSize: 256 })
|
||||
if (!map.getLayer('sat')) map.addLayer({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: satView.value ? 'visible' : 'none' } })
|
||||
} catch (e) { /* style pas prêt — ignoré */ }
|
||||
}
|
||||
function applySat (map) { try { if (map && map.getLayer && map.getLayer('sat')) map.setLayoutProperty('sat', 'visibility', satView.value ? 'visible' : 'none') } catch (e) {} }
|
||||
function toggleSat () { satView.value = !satView.value; try { localStorage.setItem('plan_sat', satView.value ? '1' : '0') } catch (e) {} applySat(_assignMap); applySat(_dayMap); applySat(_locMap) }
|
||||
|
||||
// Clic DROIT sur une carte → Google Street View à la position cliquée (nouvel onglet). Branché sur les 3 cartes.
|
||||
function openStreetView (lng, lat) { window.open(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${lat},${lng}`, '_blank', 'noopener') }
|
||||
|
||||
// ── SÉLECTEUR D'EMPLACEMENT (jobs « hors carte ») : carte Mapbox bornée au sud de Montréal (Valleyfield ↔ Lacolle ↔
|
||||
// frontière US). Clic / glisser le repère OU recherche d'adresse RQA → coords + adresse, écrits sur le Dispatch Job. ──
|
||||
const locMapEl = ref(null); let _locMap = null, _locMarker = null
|
||||
const TERR_BOUNDS = [[-74.95, 44.95], [-73.0, 45.55]] // SW (Valleyfield/frontière) → NE
|
||||
const locPicker = reactive({ open: false, job: null, subject: '', lat: null, lon: null, address: '', search: '', results: [], searching: false, saving: false })
|
||||
function openLocPicker (j) {
|
||||
locPicker.job = j.name; locPicker.subject = j.subject || j.name
|
||||
locPicker.lat = hasLL(j) ? +j.lat : null; locPicker.lon = hasLL(j) ? +j.lon : null
|
||||
locPicker.address = j.address || ''; locPicker.search = j.address || ''; locPicker.results = []
|
||||
locPicker.open = true
|
||||
}
|
||||
async function initLocMap () {
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !locMapEl.value) return
|
||||
if (_locMap) { _locMap.resize(); return }
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_locMap = new mapboxgl.Map({ container: locMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [locPicker.lon || -73.95, locPicker.lat || 45.18], zoom: locPicker.lat != null ? 14 : 8.5 })
|
||||
_locMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
_locMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_locMap.on('load', () => {
|
||||
_locMap.resize(); addSatLayer(_locMap)
|
||||
if (locPicker.lat == null) _locMap.fitBounds(TERR_BOUNDS, { padding: 18, duration: 0 }) // zoom sur la région au sud de Montréal
|
||||
else placeLocMarker(locPicker.lon, locPicker.lat, false)
|
||||
})
|
||||
_locMap.on('click', (e) => placeLocMarker(e.lngLat.lng, e.lngLat.lat, true))
|
||||
}
|
||||
function placeLocMarker (lon, lat, doReverse) {
|
||||
locPicker.lon = +(+lon).toFixed(6); locPicker.lat = +(+lat).toFixed(6)
|
||||
if (!_locMarker) {
|
||||
_locMarker = new window.mapboxgl.Marker({ draggable: true, color: '#e8590c' }).setLngLat([lon, lat]).addTo(_locMap)
|
||||
_locMarker.on('dragend', () => { const ll = _locMarker.getLngLat(); locPicker.lon = +ll.lng.toFixed(6); locPicker.lat = +ll.lat.toFixed(6); reverseGeocode(ll.lng, ll.lat) })
|
||||
} else _locMarker.setLngLat([lon, lat])
|
||||
if (doReverse) reverseGeocode(lon, lat)
|
||||
}
|
||||
async function reverseGeocode (lon, lat) {
|
||||
if (!MAPBOX_TOKEN) return
|
||||
try {
|
||||
const r = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${lon},${lat}.json?access_token=${MAPBOX_TOKEN}&language=fr&country=ca&types=address,poi`)
|
||||
const j = await r.json(); const f = (j.features || [])[0]
|
||||
if (f && f.place_name) locPicker.address = f.place_name.replace(/, Canada$/, '')
|
||||
} catch (e) { /* non bloquant */ }
|
||||
}
|
||||
async function locSearch () {
|
||||
const q = (locPicker.search || '').trim(); if (q.length < 3) return
|
||||
locPicker.searching = true
|
||||
try { const r = await addressApi.conformityCandidates(q); locPicker.results = (r.results || []).filter(x => isFinite(+x.latitude) && isFinite(+x.longitude)).slice(0, 8) } catch (e) { err(e) } finally { locPicker.searching = false }
|
||||
}
|
||||
function pickLocResult (rr) {
|
||||
const lon = +rr.longitude, lat = +rr.latitude; if (!isFinite(lon) || !isFinite(lat)) return
|
||||
locPicker.address = rr.address_full || [rr.numero, rr.rue, rr.ville, rr.code_postal].filter(Boolean).join(' ')
|
||||
locPicker.results = []
|
||||
if (_locMap) { _locMap.easeTo({ center: [lon, lat], zoom: 16, duration: 500 }); placeLocMarker(lon, lat, false) } else { locPicker.lon = lon; locPicker.lat = lat }
|
||||
}
|
||||
async function saveLocPicker () {
|
||||
if (locPicker.lat == null || locPicker.lon == null) { $q.notify({ type: 'warning', message: 'Choisis un point sur la carte' }); return }
|
||||
locPicker.saving = true
|
||||
try {
|
||||
await roster.setJobLocation(locPicker.job, locPicker.lat, locPicker.lon, locPicker.address)
|
||||
const j = dayEditor.list.find(x => x.name === locPicker.job); if (j) { j.lat = locPicker.lat; j.lon = locPicker.lon; if (locPicker.address) j.address = locPicker.address }
|
||||
locPicker.open = false
|
||||
await reloadOccupancy(); if (_dayMap) refreshDayMap()
|
||||
$q.notify({ type: 'positive', icon: 'place', message: 'Emplacement enregistré sur le job' })
|
||||
} catch (e) { err(e) } finally { locPicker.saving = false }
|
||||
}
|
||||
function destroyLocMap () { if (_locMarker) { try { _locMarker.remove() } catch (e) {} _locMarker = null } if (_locMap) { try { _locMap.remove() } catch (e) {} _locMap = null } }
|
||||
// init/destroy via @show/@hide du q-dialog (le conteneur a sa taille finale à @show → carte fiable)
|
||||
|
||||
// ── Tableur « durées par caractéristique » (additif, hors transport) — éditable inline, seed + apprentissage ──
|
||||
const jobChar = reactive({ open: false, loading: false, saving: false, items: [] })
|
||||
async function openJobChar () { jobChar.open = true; jobChar.loading = true; try { const r = await roster.getJobChars(); jobChar.items = r.items || [] } catch (e) { err(e) } finally { jobChar.loading = false } }
|
||||
function addJobCharRow () { jobChar.items.push({ id: 'c' + Date.now().toString(36), cat: 'addon', label: '', minutes: 30, per_qty: false, keywords: '', learned_min: null, samples: 0 }) }
|
||||
async function saveJobChar () { jobChar.saving = true; try { const r = await roster.saveJobChars(jobChar.items); $q.notify({ type: r.ok ? 'positive' : 'negative', message: r.ok ? `Table enregistrée (${r.count} lignes)` : 'Échec d\'enregistrement' }); if (r.ok) jobChar.open = false } catch (e) { err(e) } finally { jobChar.saving = false } }
|
||||
|
||||
// Lien app terrain d'un tech : copie le lien (PWA /field?t=) + propose l'envoi SMS au tech (sans build natif requis).
|
||||
async function techAppLinkMenu (t) {
|
||||
try {
|
||||
const r = await roster.techAppLink(t.id); if (!r || !r.ok) { err(new Error('lien app indisponible')); return }
|
||||
try { await navigator.clipboard.writeText(r.url) } catch (e) {}
|
||||
const actions = [{ label: 'OK', color: 'white' }]
|
||||
if (r.phone) actions.unshift({ label: '📲 SMS au tech', color: 'yellow', handler: async () => { try { await sendSmsViaHub(r.phone, 'Ton app terrain Targo (interventions du jour) : ' + r.url, ''); $q.notify({ type: 'positive', message: 'Lien envoyé par SMS à ' + r.name }) } catch (e) { err(e) } } })
|
||||
$q.notify({ message: '📱 Lien app copié' + (r.phone ? '' : ' (pas de téléphone au dossier)') + ' — ' + r.url, timeout: 9000, multiLine: true, actions })
|
||||
} catch (e) { err(e) }
|
||||
}
|
||||
|
||||
// ── Synchroniser les techniciens : rapport de réconciliation (staff legacy ↔ Dispatch Technician ↔ groupe Authentik tech) ──
|
||||
const techSync = reactive({ open: false, loading: false, applying: false, report: null, sel: {} })
|
||||
const techSyncSelCount = computed(() => Object.values(techSync.sel).filter(Boolean).length)
|
||||
async function openTechSync () {
|
||||
techSync.open = true; techSync.loading = true; techSync.report = null; techSync.sel = {}
|
||||
try {
|
||||
const r = await roster.techSyncReport(); techSync.report = r
|
||||
for (const s of (r.staff_missing_fiche || [])) techSync.sel[s.staff_id] = true // candidats cochés par défaut
|
||||
} catch (e) { err(e) } finally { techSync.loading = false }
|
||||
}
|
||||
async function applyTechSync () {
|
||||
const ids = Object.entries(techSync.sel).filter(([, v]) => v).map(([k]) => k)
|
||||
if (!ids.length) return
|
||||
techSync.applying = true
|
||||
try {
|
||||
const r = await roster.techSyncApply(ids)
|
||||
$q.notify({ type: r.created ? 'positive' : 'info', icon: 'group_add', message: `${r.created} fiche(s) technicien créée(s)` + (r.results ? ` · ${r.results.filter(x => x.skipped).length} déjà présente(s)` : '') })
|
||||
techSync.open = false
|
||||
await loadBase() // recharge la liste des techniciens dans la grille
|
||||
} catch (e) { err(e) } finally { techSync.applying = false }
|
||||
}
|
||||
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
|
||||
const assignNoCoord = computed(() => assignPanel.jobs.filter(j => !_hasJobLL(j)).length)
|
||||
function assignStops () { return assignLocations.value.stops }
|
||||
function toggleAssignMap () { assignPanel.showMap = !assignPanel.showMap }
|
||||
function focusAssignJob (j) { if (_assignMap && _hasJobLL(j)) _assignMap.easeTo({ center: [+j.longitude, +j.latitude], zoom: 13, duration: 400 }) }
|
||||
// Carte → liste : déplie les détails du job + scrolle la ligne + flash visuel.
|
||||
const flashJob = ref(null)
|
||||
function focusAssignJobInList (name) {
|
||||
if (!name) return
|
||||
const job = assignPanel.jobs.find(j => j.name === name)
|
||||
if (job) { job._showThread = true; loadThread(job) }
|
||||
flashJob.value = name; setTimeout(() => { if (flashJob.value === name) flashJob.value = null }, 1800)
|
||||
nextTick(() => { const el = document.querySelector('.assign-body [data-jobname="' + name + '"]'); if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' }) })
|
||||
}
|
||||
async function initAssignMap () {
|
||||
if (!MAPBOX_TOKEN || !assignMapEl.value || _assignMap) return
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !assignMapEl.value) return
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_assignMap = new mapboxgl.Map({ container: assignMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
|
||||
_assignMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
_assignMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_assignMapRO = new ResizeObserver(() => { if (_assignMap) _assignMap.resize() }); _assignMapRO.observe(assignMapEl.value)
|
||||
_assignMap.on('load', () => {
|
||||
_assignMap.resize()
|
||||
addSatLayer(_assignMap) // couche satellite (sous les pins), masquée par défaut
|
||||
_assignMap.addSource('aj', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
_assignMap.addLayer({ id: 'aj-c', type: 'circle', source: 'aj', paint: { 'circle-radius': ['case', ['>', ['get', 'count'], 1], 12, 9], 'circle-color': ['get', 'color'], 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } })
|
||||
_assignMap.addLayer({ id: 'aj-l', type: 'symbol', source: 'aj', layout: { 'text-field': ['get', 'label'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
|
||||
_assignMap.on('mouseenter', 'aj-c', () => { _assignMap.getCanvas().style.cursor = 'pointer' })
|
||||
_assignMap.on('mouseleave', 'aj-c', () => { _assignMap.getCanvas().style.cursor = '' })
|
||||
_assignMap.on('click', 'aj-c', (e) => {
|
||||
const f = e.features[0]; const p = f.properties; const names = JSON.parse(p.names || '[]')
|
||||
names.forEach(n => { selectedJobs[n] = true }) // clic sur le pin = sélectionne tous les jobs de cette adresse
|
||||
if (names[0]) focusAssignJobInList(names[0]) // + scrolle/expand la job dans la liste
|
||||
new window.mapboxgl.Popup({ offset: 13 }).setLngLat(f.geometry.coordinates).setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.letter)}</b> · ${_esc(p.title)}<br>✓ ${p.count} job(s) sélectionné(s)<br><span style="color:#888">glisse-les sur un tech × jour</span></div>`).addTo(_assignMap)
|
||||
})
|
||||
refreshAssignMap()
|
||||
})
|
||||
}
|
||||
function refreshAssignMap () {
|
||||
if (!_assignMap || !_assignMap.isStyleLoaded()) { setTimeout(refreshAssignMap, 200); return }
|
||||
const stops = assignStops(); const src = _assignMap.getSource('aj'); if (!src) return
|
||||
src.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.letter + (s.count > 1 ? '·' + s.count : ''), color: s.color, count: s.count, names: JSON.stringify(s.names), letter: s.letter, title: s.title } })) })
|
||||
if (stops.length === 1) _assignMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 11, duration: 400 })
|
||||
else if (stops.length > 1) { const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]); stops.forEach(s => b.extend([s.lon, s.lat])); _assignMap.fitBounds(b, { padding: 40, maxZoom: 12, duration: 400 }) }
|
||||
}
|
||||
function destroyAssignMap () { if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } }
|
||||
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
|
||||
watch(() => assignPanel.jobs, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) })
|
||||
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })
|
||||
|
||||
// ── Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible) ──
|
||||
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau.
|
||||
const timelineDlg = reactive({ open: false, tech: null })
|
||||
|
|
@ -1098,7 +1556,8 @@ const packedDay = computed(() => {
|
|||
}
|
||||
return out
|
||||
})
|
||||
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon)
|
||||
// Coords valides : non nulles, finies, ET pas ~0,0 (placeholder « pas de coords » — golfe de Guinée).
|
||||
const hasLL = (j) => j && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && (Math.abs(+j.lat) > 0.01 || Math.abs(+j.lon) > 0.01)
|
||||
const dayNoCoord = computed(() => dayEditor.list.filter(j => !hasLL(j)).length)
|
||||
|
||||
// ── Carte INTERACTIVE de la journée (Mapbox GL) : itinéraire ROUTIER réel (API Directions) + pins
|
||||
|
|
@ -1119,22 +1578,61 @@ const _esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '
|
|||
function dayStops () { // arrêts géolocalisés, dans l'ordre de tournée (packedDay) + infos pour le popup
|
||||
return packedDay.value.filter(hasLL).map((j, i) => ({
|
||||
lon: +j.lon, lat: +j.lat, label: String(i + 1), color: j.skill ? getTagColor(j.skill) : '#1976d2',
|
||||
subject: j.subject || '', customer: j.customer || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin),
|
||||
subject: j.subject || '', customer: j.customer || '', address: j.address || '', time: fmtHM(j.startMin) + '–' + fmtHM(j.endMin),
|
||||
}))
|
||||
}
|
||||
// Segments de DÉPLACEMENT (pointillés) = l'espace entre 2 jobs dans la barre timeline.
|
||||
const dayTravelSegs = () => { const p = packedDay.value; const out = []; for (let i = 0; i < p.length - 1; i++) { const s = p[i].endMin, e = p[i + 1].startMin; if (e - s > 0.02) out.push({ s, e }) } return out }
|
||||
// Centre la carte sur un job (clic sur la ligne de la liste).
|
||||
function focusDayJob (j) { if (_dayMap && hasLL(j)) _dayMap.easeTo({ center: [+j.lon, +j.lat], zoom: 14, duration: 500 }) }
|
||||
// Fil legacy (messages + réponses du ticket osTicket) chargé à la demande, mis en cache sur le job (j._thread).
|
||||
async function loadThread (j) {
|
||||
const id = j && (j.legacy_id || j.legacy_ticket_id); if (!id || j._thread) return
|
||||
j._thread = { loading: true, messages: [] }
|
||||
try { const r = await roster.ticketThread(id); j._thread = { loading: false, messages: r.messages || [], count: r.count || 0, status: r.status || '' } }
|
||||
catch (e) { j._thread = { loading: false, error: true, messages: [] } }
|
||||
}
|
||||
function toggleJobDetail (j) { j.showDetail = !j.showDetail; if (j.showDetail) loadThread(j); focusDayJob(j) } // tournée : clic = déplie détails + fil + centre carte
|
||||
function toggleAssignThread (j) { j._showThread = !j._showThread; if (j._showThread) loadThread(j) } // panneau d'assignation : icône fil
|
||||
// Fermer un ticket inutile dans le legacy (depuis le panneau) → sort du dispatch (Completed). Acteur = utilisateur Authentik.
|
||||
function closeLegacyTicket (j) {
|
||||
if (!j || !j.legacy_ticket_id) return
|
||||
$q.dialog({ title: 'Fermer le ticket', message: 'Fermer le ticket legacy <b>#' + j.legacy_ticket_id + '</b> ?<br><span class="text-grey-7">' + (j.subject || '').replace(/[<>]/g, '') + '</span><br>→ il sortira du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer', color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||||
try {
|
||||
const r = await roster.closeLegacyTicket(j.legacy_ticket_id)
|
||||
if (r && r.ok) { assignPanel.jobs = assignPanel.jobs.filter(x => x.name !== j.name); $q.notify({ type: 'positive', message: 'Ticket #' + j.legacy_ticket_id + ' fermé dans le legacy' }) }
|
||||
else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') })
|
||||
} catch (e) { err(e) }
|
||||
})
|
||||
}
|
||||
// Fermeture EN LOT des jobs cochés (1 seul appel) — option « fermer » au lieu de « réassigner ».
|
||||
function bulkCloseSelected () {
|
||||
const sel = assignPanel.jobs.filter(j => selectedJobs[j.name] && j.legacy_ticket_id)
|
||||
if (!sel.length) { $q.notify({ type: 'warning', message: 'Aucun ticket legacy coché' }); return }
|
||||
$q.dialog({ title: 'Fermer en lot', message: 'Fermer <b>' + sel.length + '</b> ticket(s) dans le legacy ?<br>→ ils sortent du dispatch (Completed).', html: true, cancel: 'Annuler', ok: { label: 'Fermer ' + sel.length, color: 'negative', unelevated: true }, persistent: true }).onOk(async () => {
|
||||
try {
|
||||
const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
|
||||
if (r && r.ok) {
|
||||
const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t)))
|
||||
assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id)))
|
||||
for (const k in selectedJobs) delete selectedJobs[k]
|
||||
$q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') })
|
||||
} else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') })
|
||||
} catch (e) { err(e) }
|
||||
})
|
||||
}
|
||||
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
|
||||
async function initDayMap () {
|
||||
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
|
||||
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !dayMapEl.value) return
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN
|
||||
_dayMap = new mapboxgl.Map({ container: dayMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.6756, 45.1599], zoom: 9 })
|
||||
_dayMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right') // boutons zoom +/-
|
||||
_dayMap.on('contextmenu', (e) => openStreetView(e.lngLat.lng, e.lngLat.lat)) // clic droit → Street View
|
||||
_dayMapRO = new ResizeObserver(() => { if (_dayMap) _dayMap.resize() }); _dayMapRO.observe(dayMapEl.value)
|
||||
_dayMap.on('load', () => {
|
||||
_dayMap.resize()
|
||||
addSatLayer(_dayMap) // couche satellite (sous l'itinéraire et les pins), masquée par défaut
|
||||
_dayMap.addSource('day-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
_dayMap.addLayer({ id: 'day-route-halo', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 9, 'line-opacity': 0.2 } })
|
||||
_dayMap.addLayer({ id: 'day-route', type: 'line', source: 'day-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#3b5bdb', 'line-width': 4, 'line-opacity': 0.85 } })
|
||||
|
|
@ -1147,7 +1645,7 @@ async function initDayMap () {
|
|||
_dayMap.on('click', 'day-stops-c', (e) => {
|
||||
const f = e.features[0]; const p = f.properties
|
||||
new window.mapboxgl.Popup({ offset: 14 }).setLngLat(f.geometry.coordinates)
|
||||
.setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.label)}. ${_esc(p.subject)}</b><br>🕒 ${_esc(p.time)}${p.customer ? '<br>👤 ' + _esc(p.customer) : ''}</div>`)
|
||||
.setHTML(`<div style="font-size:12px;line-height:1.45"><b>${_esc(p.label)}. ${_esc(p.subject)}</b><br>🕒 ${_esc(p.time)}${p.customer ? '<br>👤 ' + _esc(p.customer) : ''}${p.address ? '<br>📍 ' + _esc(p.address) : ''}</div>`)
|
||||
.addTo(_dayMap)
|
||||
})
|
||||
refreshDayMap()
|
||||
|
|
@ -1157,7 +1655,7 @@ function refreshDayMap () {
|
|||
if (!_dayMap || !_dayMap.isStyleLoaded()) { setTimeout(refreshDayMap, 200); return }
|
||||
const stops = dayStops()
|
||||
const sSrc = _dayMap.getSource('day-stops')
|
||||
if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color } })) })
|
||||
if (sSrc) sSrc.setData({ type: 'FeatureCollection', features: stops.map(s => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { label: s.label, color: s.color, subject: s.subject, customer: s.customer, address: s.address, time: s.time } })) })
|
||||
if (stops.length === 1) _dayMap.easeTo({ center: [stops[0].lon, stops[0].lat], zoom: 13, duration: 400 })
|
||||
else if (stops.length > 1) {
|
||||
const b = new window.mapboxgl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat])
|
||||
|
|
@ -1187,8 +1685,16 @@ function destroyDayMap () {
|
|||
watch(() => dayEditor.open, (open) => { if (open) nextTick(() => setTimeout(initDayMap, 250)); else destroyDayMap() })
|
||||
watch(() => dayEditor.list.map(j => j.name).join(','), () => { if (_dayMap) { clearTimeout(_dirTimer); _dirTimer = setTimeout(refreshDayMap, 500) } })
|
||||
const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10
|
||||
// Chrono (boucle de capture) : début/fin réels → durée mesurée (alimente l'apprentissage des durées par type×tech)
|
||||
async function startJobChrono (j) { try { const r = await roster.startJob(j.name); j.actual_start = r.actual_start || '1'; j.actual_end = ''; $q.notify({ type: 'positive', icon: 'play_arrow', message: 'Job démarré (chrono)', timeout: 1500 }) } catch (e) { err(e) } }
|
||||
async function finishJobChrono (j) { try { const r = await roster.finishJob(j.name); j.actual_end = '1'; j.actual_min = r.actual_minutes; $q.notify({ type: 'positive', icon: 'check', message: `Terminé · ${r.actual_minutes} min réels`, timeout: 2500 }); reloadOccupancy() } catch (e) { err(e) } }
|
||||
async function removeFromDay (j) {
|
||||
try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) }
|
||||
// Action EXPLICITE → renvoie aussi le ticket au pool « Tech Targo » (3301) DANS Legacy (write-back symétrique).
|
||||
try {
|
||||
const r = await roster.returnJobToPool(j.name)
|
||||
dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek()
|
||||
$q.notify({ type: 'info', message: (r && r.returned_to_pool) ? 'Retiré du tech — ticket renvoyé au pool « Tech Targo » dans Legacy' : 'Retiré du tech (retour au pool « à assigner »)', timeout: 2600 })
|
||||
} catch (e) { err(e) }
|
||||
}
|
||||
async function saveDayOrder () {
|
||||
dayEditor.saving = true
|
||||
|
|
@ -1464,6 +1970,8 @@ const shiftName = (s) => { const t = tplByName.value[s]; return t ? (t.template_
|
|||
const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows })
|
||||
const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m })
|
||||
const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m })
|
||||
// Détail du manque de couverture d'un jour (par quart · zone) pour l'infobulle du badge rouge.
|
||||
function gapDetail (iso) { return coverageData.value.filter(c => c.date === iso && c.shortfall > 0).map(c => `${shiftName(c.shift)} · ${c.zone || '—'} : ${c.assigned}/${c.required} (−${c.shortfall})`).join('\n') }
|
||||
function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
|
||||
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
|
||||
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
|
||||
|
|
@ -1479,6 +1987,7 @@ function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn(
|
|||
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
|
||||
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
|
||||
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
|
||||
function navDay (dir) { guard(() => { start.value = addDaysISO(start.value, dir); loadWeek() }) } // décale la fenêtre d'UN jour (flèche simple)
|
||||
function navToday () { guard(() => { start.value = thisMonday(); loadWeek() }) }
|
||||
|
||||
// chargement
|
||||
|
|
@ -1529,7 +2038,6 @@ async function loadWeek () {
|
|||
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
|
||||
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
|
||||
lastWeek.start = start.value; lastWeek.days = days.value
|
||||
const c = await roster.getCoverage(start.value, days.value); coverageData.value = c.coverage || []
|
||||
await loadStats()
|
||||
} catch (e) { err(e) } finally { loading.value = false }
|
||||
}
|
||||
|
|
@ -1538,6 +2046,9 @@ async function loadStats () {
|
|||
try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
|
||||
try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
|
||||
}
|
||||
// Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules → timeline à jour) après une assignation,
|
||||
// sans le loadWeek complet (assignations + couverture + stats), bien plus lent.
|
||||
async function reloadOccupancy () { try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } }
|
||||
|
||||
async function doGenerate () {
|
||||
generating.value = true
|
||||
|
|
@ -1735,15 +2246,30 @@ function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a
|
|||
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
|
||||
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
|
||||
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
|
||||
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
|
||||
function openShiftMenu (t, d, ev, ti, di) {
|
||||
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget
|
||||
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
|
||||
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
|
||||
menu.show = true
|
||||
}
|
||||
// Y a-t-il une bande timeline cliquable sur cette case ? (shift régulier OU garde, et pas absent/pause)
|
||||
// Clic gauche sur le FOND de cellule (zone sans quart ni job) → menu d'horaire (POSER / modifier un quart).
|
||||
// Le clic sur la BANDE de quart/garde ou un BLOC de job ouvre la tournée (openDayFromCell, via @click.stop).
|
||||
function onCellClick (t, d, ev, ti, di) {
|
||||
if (justDragged.value) { justDragged.value = false; return }
|
||||
activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V
|
||||
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
|
||||
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
|
||||
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget
|
||||
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
|
||||
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
|
||||
menu.show = true
|
||||
openShiftMenu(t, d, ev, ti, di)
|
||||
}
|
||||
// Clic sur la BANDE de quart/garde ou un BLOC de job → ouvre la tournée du jour. Guardé contre les fins de drag.
|
||||
function openDayFromCell (t, d) { if (justDragged.value) { justDragged.value = false; return } activeCell.value = { id: t.id, name: t.name, iso: d.iso }; openDayEditor(t, d) }
|
||||
// Clic DROIT sur une cellule → menu d'horaire aussi (bonus / accessibilité).
|
||||
function onCellContext (t, d, ev, ti, di) {
|
||||
if (justDragged.value) { justDragged.value = false; return }
|
||||
activeCell.value = { id: t.id, name: t.name, iso: d.iso }
|
||||
openShiftMenu(t, d, ev, ti, di)
|
||||
}
|
||||
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
|
||||
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||||
|
|
@ -1835,7 +2361,31 @@ function onKey (e) {
|
|||
}
|
||||
}
|
||||
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } })
|
||||
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
|
||||
let _legacyRefreshT = null
|
||||
function scheduleLegacyRefresh () {
|
||||
clearTimeout(_legacyRefreshT)
|
||||
_legacyRefreshT = setTimeout(async () => {
|
||||
try { await reloadOccupancy(); if (assignPanel.open) { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } } catch (e) { /* non bloquant */ }
|
||||
}, 700)
|
||||
}
|
||||
function onLegacyUpdate (data) {
|
||||
const ch = (data && data.changes) || []
|
||||
if (!ch.length) return
|
||||
const closed = ch.filter(c => c.kind === 'closed').length
|
||||
const taken = ch.filter(c => c.kind === 'taken')
|
||||
const moved = ch.filter(c => c.kind === 'reassigned' && !c.pool)
|
||||
const stat = ch.filter(c => c.kind === 'status').length
|
||||
if (closed) $q.notify({ type: 'info', icon: 'task_alt', message: `Legacy : ${closed} ticket(s) fermé(s) — retiré(s) du planning`, timeout: 3500 })
|
||||
if (taken.length) $q.notify({ type: 'info', icon: 'person_remove', message: `Legacy : ${taken.length} ticket(s) pris par un tech dans Legacy — retiré(s) du pool`, timeout: 4000 })
|
||||
for (const m of moved) $q.notify({ type: 'warning', icon: 'swap_horiz', message: `Ticket #${m.ticket} réassigné HORS-Ops (staff ${m.to_staff}) — à vérifier`, timeout: 6000 })
|
||||
if (stat) $q.notify({ type: 'info', icon: 'sync', message: `Legacy : ${stat} changement(s) de statut`, timeout: 3000 })
|
||||
for (const c of ch) { if (c.job) flashJob.value = c.job }
|
||||
scheduleLegacyRefresh()
|
||||
}
|
||||
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||||
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } openAssignPanel(); /* panneau « Jobs à assigner » ouvert par défaut (fermable) */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ })
|
||||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
|
||||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
||||
</script>
|
||||
|
|
@ -1849,7 +2399,40 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
|
|||
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
|
||||
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
|
||||
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
|
||||
.assign-body { overflow: auto; padding: 5px; }
|
||||
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
|
||||
.assign-map-wrap { flex: 0 0 auto; padding: 0 5px 4px; position: relative; }
|
||||
.map-sat-btn { position: absolute; top: 7px; left: 11px; z-index: 3; background: rgba(255,255,255,.9); }
|
||||
.loc-map-wrap { position: relative; }
|
||||
.loc-map { height: 360px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||||
.loc-map-hint { position: absolute; bottom: 8px; left: 8px; background: rgba(255,255,255,.88); font-size: 11px; padding: 2px 7px; border-radius: 4px; color: #555; pointer-events: none; }
|
||||
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
|
||||
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
|
||||
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
|
||||
.cap-strip { display: flex; gap: 2px; justify-content: center; align-items: center; margin-top: 2px; }
|
||||
.cap-seg { font-size: 9px; font-weight: 700; line-height: 1; min-width: 15px; padding: 2px 2px; border-radius: 3px; text-align: center; cursor: default; }
|
||||
.cap-ok { background: #c8e6c9; color: #1b5e20; }
|
||||
.cap-low { background: #ffe0b2; color: #e65100; }
|
||||
.cap-full { background: #ffcdd2; color: #b71c1c; }
|
||||
.cap-none { background: #eceff1; color: #b0bec5; }
|
||||
.cap-warn { margin-left: 1px; }
|
||||
.de-actual { font-size: 11px; font-weight: 700; color: #00695c; background: #e0f2f1; border-radius: 4px; padding: 2px 5px; white-space: nowrap; }
|
||||
.chrono-run { animation: chronopulse 1.4s ease-in-out infinite; }
|
||||
@keyframes chronopulse { 0%, 100% { opacity: 1 } 50% { opacity: .4 } }
|
||||
.jc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.jc-table th { text-align: left; color: #607d8b; font-weight: 600; padding: 3px 4px; border-bottom: 1px solid #e0e0e0; }
|
||||
.jc-table td { padding: 2px 4px; vertical-align: middle; }
|
||||
.jc-table tr.jc-base td:first-child { border-left: 3px solid #1565c0; }
|
||||
.jc-table tr.jc-addon td:first-child { border-left: 3px solid #00897b; }
|
||||
.jc-table tr.jc-modifier td:first-child { border-left: 3px solid #8e24aa; }
|
||||
.jc-learn { font-size: 10px; color: #2e7d32; white-space: nowrap; }
|
||||
.assign-map { height: 230px; border-radius: 6px; overflow: hidden; border: 1px solid #cfd8dc; }
|
||||
.assign-map-cap { font-size: 10px; color: #607d8b; padding: 2px 2px 0; line-height: 1.3; }
|
||||
.assign-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-bottom: 1px solid #eee; flex: 0 0 auto; }
|
||||
.assign-chip-f { font-size: 10px; border: 1.5px solid #cfd8dc; border-radius: 11px; padding: 1px 8px; cursor: pointer; user-select: none; line-height: 1.6; background: #fff; color: #455a64; }
|
||||
.assign-chip-f.clear { border-color: #b0bec5; color: #607d8b; font-weight: 700; }
|
||||
.assign-chip-f:hover { filter: brightness(0.95); }
|
||||
.assign-resize { position: absolute; right: 1px; bottom: 1px; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 45%, #b0bec5 45%, #b0bec5 55%, transparent 55%, transparent 70%, #b0bec5 70%, #b0bec5 80%, transparent 80%); border-radius: 0 0 8px 0; z-index: 2; }
|
||||
.assign-grp-badge { display: inline-block; background: #cfd8dc; color: #455a64; font-weight: 700; border-radius: 4px; padding: 0 5px; font-size: 10px; line-height: 16px; }
|
||||
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
|
||||
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
|
||||
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */
|
||||
|
|
@ -1861,6 +2444,8 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
|
|||
.assign-job.sel { border-color: #00897b; background: #e0f2f1; box-shadow: inset 0 0 0 1px #00897b; } /* sélectionné = à dispatcher */
|
||||
.assign-job.child { margin-left: 14px; border-left: 3px solid #b39ddb; }
|
||||
.assign-job.blocked { opacity: .65; }
|
||||
.assign-job.job-flash { animation: jobflash 1.7s ease-out; }
|
||||
@keyframes jobflash { 0% { background: #fff59d; box-shadow: inset 0 0 0 2px #fbc02d; } 100% { background: #fafafa; box-shadow: none; } }
|
||||
.assign-sub { font-size: 10px; color: #888; margin-top: 1px; }
|
||||
.assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
|
||||
.assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }
|
||||
|
|
@ -1920,7 +2505,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
|
||||
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
|
||||
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
|
||||
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; }
|
||||
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; cursor: pointer; } /* fond cliquable → ajouter/modifier un quart */
|
||||
.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */
|
||||
.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; }
|
||||
/* Éditeur de journée (clic progressbar) — lignes draggables */
|
||||
|
|
@ -1930,20 +2515,30 @@ tr.res-hidden .hide-eye { opacity: 1; }
|
|||
.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; }
|
||||
.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* minimap du jour (territoire des arrêts) */
|
||||
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; }
|
||||
.de-map-wrap { margin: 8px 0 4px; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; position: relative; }
|
||||
.de-map-gl { width: 100%; height: 240px; }
|
||||
.de-map-cap { font-size: 10px; color: #777; padding: 3px 6px; background: #fafafa; border-top: 1px solid #eee; }
|
||||
.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; }
|
||||
.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; }
|
||||
.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; }
|
||||
.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */
|
||||
.de-detail { font-size: 11px; line-height: 1.4; white-space: pre-wrap; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 160px; overflow: auto; }
|
||||
.de-detail { font-size: 11px; line-height: 1.4; color: #444; background: #f7f5fc; border-left: 3px solid #b39ddb; border-radius: 4px; margin: 0 4px 6px 40px; padding: 6px 8px; max-height: 280px; overflow: auto; }
|
||||
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
|
||||
.de-msg:first-of-type { border-top: none; }
|
||||
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
|
||||
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
|
||||
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
|
||||
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
|
||||
.assign-msg:first-child { border-top: none; }
|
||||
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
|
||||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
|
||||
.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
|
||||
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
|
||||
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
|
||||
.tl-travel { position: absolute; top: 0; bottom: 0; background-image: repeating-linear-gradient(90deg, #78909c 0 3px, transparent 3px 7px); background-size: 100% 3px; background-repeat: no-repeat; background-position: 0 center; opacity: .85; } /* déplacement = pointillés */
|
||||
.tl-blk-click { cursor: pointer; } /* seuls les blocs de job ouvrent l'éditeur ; le reste de la cellule = édition d'horaire */
|
||||
.tl-shift-click { cursor: pointer; } /* bande de quart/garde → clic ouvre la tournée (voir les jobs) */
|
||||
.tl-shift-click:hover { filter: brightness(0.92); outline: 1px solid rgba(55,71,79,.4); outline-offset: -1px; }
|
||||
.tl-blk-click { cursor: pointer; }
|
||||
.tl-blk-click:hover { outline: 1px solid rgba(25,118,210,.7); outline-offset: -1px; filter: brightness(1.08); }
|
||||
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
|
||||
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
|
||||
|
|
|
|||
52
docs/field-tech-app.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# App technicien & capture passive du temps (durées apprises)
|
||||
|
||||
> But : mesurer **sans effort tech** le temps réel passé par job → apprendre les durées par **type × technicien** → alimenter le recommandeur « prochaine dispo » et le solveur. Principe directeur : **aucun tap dédié** — la durée se *déduit* d'événements que le tech produit déjà + géorepérage GPS automatique.
|
||||
|
||||
## 1. Modèle « fil de checkpoints »
|
||||
Chaque job accumule des **checkpoints** horodatés (et géolocalisés). La durée réelle = **1er checkpoint sur place → dernier** (ou entrée→sortie de géorepérage).
|
||||
|
||||
| Type de checkpoint | Source | Effort tech |
|
||||
|---|---|---|
|
||||
| `geo_enter` / `geo_exit` | Géorepérage GPS (zone de l'adresse) | **nul** (auto) |
|
||||
| `scan` (série / MAC / code-barre) | Caméra app (modem, ONU, mesh, STB) | déjà fait (activation) |
|
||||
| `photo` / `signature` | App | déjà fait |
|
||||
| `reply` | Réponse au ticket | déjà fait |
|
||||
| `manual_start` / `manual_finish` | Override répartiteur (Ops) ou tech | filet de sécurité |
|
||||
|
||||
**Dérivation** : `actual_start` = 1er checkpoint « sur place » (geo_enter, ou 1er scan/photo/reply à < R m de l'adresse) ; `actual_end` = dernier ; `actual_minutes = end − start`. Les checkpoints hors-zone (> R m) sont ignorés pour la durée mais conservés pour l'audit.
|
||||
|
||||
## 2. Backend (cette itération — indépendant de l'app)
|
||||
- **Endpoint public token-gated** `POST /field/checkpoint` `{ token, type, ts, lat, lon, acc, ref }` → vérifie le token (HMAC du job), enregistre le checkpoint, recalcule `actual_start/end` du Dispatch Job, ajoute une ligne d'audit dans `completion_notes`. Réponse : `{ ok, on_site, distance_m, minutes }`.
|
||||
- **Lecture** `GET /field/job?t=<token>` → infos job (sujet, client, adresse, coords, état) pour l'app.
|
||||
- **Token** = `base64url(jobName).hmac12` signé (`crypto`, secret stable du hub) → stateless, valable pour tout job, aucun champ DB ni login.
|
||||
- `/field` + `/field/*` câblés **publics** dans `server.js` (calqués sur `/book`, hors forwardAuth Authentik).
|
||||
|
||||
## 3. App Capacitor (projet natif dédié — `apps/field-tech/`)
|
||||
Véhicule unique pour les techs (remplace le mobile legacy) :
|
||||
- **Géorepérage arrière-plan économe** : `@transistorsoft/capacitor-background-geolocation` (référence batterie + geofencing natif iOS/Android) — ou `@capacitor-community/background-geolocation` pour démarrer. On enregistre un geofence par job du jour ; `enter`/`exit` → POST `/field/checkpoint`.
|
||||
- **Scan caméra** : `@capacitor-mlkit/barcode-scanning` → série/MAC → checkpoint `scan` **et** push vers GenieACS (par MAC) — réunit l'item roadmap « scan série/MAC des devices ».
|
||||
- **Offline-first** : file d'attente locale (Preferences/SQLite) → rejoue les checkpoints à la reconnexion.
|
||||
- **Auth** : login Authentik (groupe `tech`) → l'app reçoit ses jobs du jour + un token par job ; ou device-token. (À finaliser.)
|
||||
- **Push** : web-push/FCM (déjà côté legacy) pour les nouvelles assignations.
|
||||
- Builds : iOS (TestFlight) + Android (APK interne) ; permissions localisation « toujours » + caméra.
|
||||
|
||||
## 4. Amorçage SANS app (mineur legacy — cette itération)
|
||||
Estimer une 1re durée par ticket depuis les événements legacy **déjà horodatés** : `ticket_msg.date_orig` du staff (réponses), création `device` (scan série), activation Ministra. Fenêtre sur-site ≈ (dernière activité − première) le même jour, filtrée < 8 h (exclut le multi-jour). **Bruité à l'unité, mais l'agrégat (médiane par type) donne une baseline d'apprentissage dès aujourd'hui.** Rapport `GET /dispatch/legacy-sync/mine-durations` (lecture seule).
|
||||
|
||||
## 5. Boucle d'apprentissage (consomme les durées)
|
||||
1. Capture (checkpoints app + override Ops + baseline minée) → `actual_minutes` par job.
|
||||
2. Agrégation : **médiane / p75 par sous-type × tech** (sous-types fins par mots-clés du sujet : « modem » vs « fil/fibre/drop » vs « ONT » vs « install »).
|
||||
3. Remplace la table statique `DUR` (util/legacy-parse) + affine `skill_eff` (facteur vitesse par tech×compétence).
|
||||
4. Le recommandeur « prochaine dispo » (`bookingSlots/fitBooking`) utilise la durée apprise ajustée au tech.
|
||||
|
||||
## 6. Phasage
|
||||
- **P1 (maintenant)** : backend checkpoints (§2) + mineur legacy (§4).
|
||||
- **P2** : recommandeur « prochaine dispo » (UI Ops) + sous-types fins + durées seed.
|
||||
- **P3** : app Capacitor (§3) — scaffold → geofence → scan → offline → stores.
|
||||
- **P4** : agrégation apprise (§5) qui remplace les seeds, une fois les données accumulées.
|
||||
|
||||
## 7. Sécurité / contraintes
|
||||
- Endpoint checkpoint **public mais token-gaté** (HMAC par job) ; pas de PII dans le token.
|
||||
- N'écrit QUE sur le Dispatch Job (ERPNext) ; ne touche pas le legacy en écriture.
|
||||
- GPS = **preuve + contrôle de distance**, pas du tracking continu serveur (le geofencing reste sur l'appareil).
|
||||
- Le tap répartiteur ▶/⏹ (Ops) reste comme override/fallback.
|
||||
51
scripts/migration/backfill_dispatch_address.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- backfill_dispatch_address.sql — remplit le champ `address` (adresse de service) des Dispatch Jobs
|
||||
-- issus du pont legacy, qui ne l'écrivait jamais (175 jobs, address vide).
|
||||
-- A) jobs AVEC service_location → adresse depuis la SL (address_line + ville, en ignorant 'N/A')
|
||||
-- B) jobs SANS service_location → adresse extraite du sujet (partie après ' · ', l'ancien append du pont)
|
||||
-- Le pont écrit désormais `address` directement (création + backfill update) ; ce script rattrape l'historique.
|
||||
-- Idempotent (ne touche que address = '' / NULL). À lancer AVANT toute re-sync (qui nettoie le sujet).
|
||||
\timing on
|
||||
SELECT character_maximum_length AS address_maxlen FROM information_schema.columns
|
||||
WHERE table_name = 'tabDispatch Job' AND column_name = 'address';
|
||||
|
||||
BEGIN;
|
||||
-- A) depuis la Service Location liée
|
||||
WITH a AS (
|
||||
UPDATE "tabDispatch Job" dj
|
||||
SET address = left(NULLIF(trim(both ', ' FROM concat_ws(', ', NULLIF(sl.address_line,''), NULLIF(sl.city,'N/A'))), ''), 140),
|
||||
modified = NOW()
|
||||
FROM "tabService Location" sl
|
||||
WHERE dj.service_location = sl.name
|
||||
AND coalesce(dj.legacy_ticket_id,'') <> ''
|
||||
AND coalesce(dj.address,'') = ''
|
||||
AND NULLIF(trim(both ', ' FROM concat_ws(', ', NULLIF(sl.address_line,''), NULLIF(sl.city,'N/A'))), '') IS NOT NULL
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) AS rempli_via_service_location FROM a;
|
||||
|
||||
-- B) depuis le sujet (jobs sans SL : le pont y avait collé l'adresse après ' · ')
|
||||
WITH b AS (
|
||||
UPDATE "tabDispatch Job" dj
|
||||
SET address = left(trim(split_part(subject, ' · ', 2)), 140),
|
||||
modified = NOW()
|
||||
WHERE coalesce(dj.legacy_ticket_id,'') <> ''
|
||||
AND coalesce(dj.address,'') = ''
|
||||
AND position(' · ' IN subject) > 0
|
||||
AND length(trim(split_part(subject, ' · ', 2))) > 0
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) AS rempli_via_sujet FROM b;
|
||||
COMMIT;
|
||||
|
||||
-- État final
|
||||
SELECT count(*) FILTER (WHERE coalesce(address,'') <> '') AS addr_ok,
|
||||
count(*) FILTER (WHERE coalesce(address,'') = '') AS addr_vide,
|
||||
count(*) AS total
|
||||
FROM "tabDispatch Job" WHERE coalesce(legacy_ticket_id,'') <> '';
|
||||
|
||||
-- Reste-t-il des jobs ACTIFS (open/assigned) sans adresse ? (à diagnostiquer le cas échéant)
|
||||
SELECT name, status, left(subject,70) AS subject, service_location
|
||||
FROM "tabDispatch Job"
|
||||
WHERE coalesce(legacy_ticket_id,'') <> '' AND coalesce(address,'') = ''
|
||||
AND status IN ('open','assigned','in_progress')
|
||||
ORDER BY modified DESC LIMIT 10;
|
||||
25
scripts/migration/diag_dispatch_addr.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-- diag_dispatch_addr.sql — état des adresses/coords des Dispatch Jobs issus du pont legacy.
|
||||
\timing on
|
||||
-- 1) Champs « adresse » présents sur le doctype Dispatch Job ?
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'tabDispatch Job'
|
||||
AND (column_name ILIKE '%address%' OR column_name ILIKE '%location%'
|
||||
OR column_name ILIKE '%civic%' OR column_name ILIKE '%street%'
|
||||
OR column_name ILIKE '%zip%' OR column_name ILIKE '%postal%' OR column_name ILIKE '%city%')
|
||||
ORDER BY 1;
|
||||
|
||||
-- 2) État des jobs issus du pont (legacy_ticket_id non vide)
|
||||
SELECT
|
||||
count(*) AS total_bridge,
|
||||
count(*) FILTER (WHERE latitude IS NULL OR longitude IS NULL OR abs(coalesce(latitude,0)) < 1) AS sans_coords,
|
||||
count(*) FILTER (WHERE coalesce(service_location,'') = '') AS sans_service_location,
|
||||
count(*) FILTER (WHERE coalesce(service_location,'') = '' AND (latitude IS NULL OR abs(coalesce(latitude,0)) < 1)) AS ni_sl_ni_coords
|
||||
FROM "tabDispatch Job"
|
||||
WHERE coalesce(legacy_ticket_id,'') <> '';
|
||||
|
||||
-- 3) Échantillon de jobs sans coords (que voit-on dans le sujet ?)
|
||||
SELECT name, legacy_ticket_id, left(subject, 80) AS subject, service_location,
|
||||
round(latitude::numeric,4) AS lat, round(longitude::numeric,4) AS lon, status
|
||||
FROM "tabDispatch Job"
|
||||
WHERE coalesce(legacy_ticket_id,'') <> '' AND (latitude IS NULL OR abs(coalesce(latitude,0)) < 1)
|
||||
ORDER BY modified DESC LIMIT 12;
|
||||
22
scripts/migration/diag_dispatch_addr2.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- diag_dispatch_addr2.sql — état du champ address + coords SL pour le job actif sans coords.
|
||||
\timing on
|
||||
-- 1) Remplissage du champ address sur les jobs du pont
|
||||
SELECT count(*) FILTER (WHERE coalesce(address,'') = '') AS addr_vide,
|
||||
count(*) FILTER (WHERE coalesce(address,'') <> '') AS addr_ok,
|
||||
count(*) AS total
|
||||
FROM "tabDispatch Job" WHERE coalesce(legacy_ticket_id,'') <> '';
|
||||
|
||||
-- 2) Le job actif sans coords (LEG-187195) : la SL liée a-t-elle des coords ?
|
||||
SELECT sl.name, sl.address_line, sl.city,
|
||||
round(sl.latitude::numeric,5) AS lat, round(sl.longitude::numeric,5) AS lon
|
||||
FROM "tabService Location" sl WHERE sl.name = 'LOC-0000002656';
|
||||
|
||||
-- 3) Parmi les jobs sans coords : combien sont actifs (open/assigned) vs terminés ?
|
||||
SELECT status, count(*) FROM "tabDispatch Job"
|
||||
WHERE coalesce(legacy_ticket_id,'') <> '' AND (latitude IS NULL OR abs(coalesce(latitude,0)) < 1)
|
||||
GROUP BY status ORDER BY 2 DESC;
|
||||
|
||||
-- 4) Parmi les jobs SANS service_location : combien actifs ? (ce sont eux qui perdent l'adresse)
|
||||
SELECT status, count(*) FROM "tabDispatch Job"
|
||||
WHERE coalesce(legacy_ticket_id,'') <> '' AND coalesce(service_location,'') = ''
|
||||
GROUP BY status ORDER BY 2 DESC;
|
||||
180
services/legacy-bridge/ops_reassign.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
/**
|
||||
* ops_reassign.php — Pont d'écriture Ops → legacy osTicket (gestionclient).
|
||||
* Déployé SUR facturation.targo.ca (hôte de l'app) → la connexion DB hérite des droits d'ÉCRITURE
|
||||
* de l'app (aucun GRANT à modifier ; le user du hub @10.100.5.61 reste SELECT-only).
|
||||
*
|
||||
* Actions (POST, x-www-form-urlencoded ou JSON), header `X-Ops-Token: <token>` OBLIGATOIRE :
|
||||
* action=reassign ticket_id, staff_id, [assist_ids=CSV] [notify=1] [address] → assign_to + participant(assistants) + followed_by + ticket_msg
|
||||
* notify=1 → courriel d'assignation au tech (résumé + lien Répondre + Adresse), via PHPMailer6 OAuth2 (ops_secret.php)
|
||||
* action=close ticket_id → status=closed + date_closed + closed_by + réouverture enfants + ticket_msg
|
||||
*
|
||||
* FIDÈLE à ticket_view.php : format participant `-id-;-id-`, followers JSON, log CHANGE LOG, respect du lock_name,
|
||||
* dates en epoch (time()), réouverture des tickets enfants `waiting_for` à la fermeture.
|
||||
*
|
||||
* ⚠️ Secrets (TOKEN + creds DB) : remplis UNIQUEMENT dans la copie serveur, jamais committés. Ici = placeholders.
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// ─── secrets : fichier serveur HORS REPO `ops_secret.php` (à côté de ce fichier) qui définit :
|
||||
// $OPS_TOKEN, $DB_USER, $DB_PASS + creds OAuth Gmail $oauthEmail,$clientId,$clientSecret,$refreshToken
|
||||
// (réutilise les mêmes creds que ticket_view.php). JAMAIS committé.
|
||||
require __DIR__ . '/ops_secret.php';
|
||||
$DB_HOST = '10.100.80.100';
|
||||
$DB_NAME = 'gestionclient';
|
||||
$OPS_STAFF = 3301; // auteur des entrées ticket_msg (Tech Targo)
|
||||
|
||||
function out($a, $code = 200) { http_response_code($code); echo json_encode($a, JSON_UNESCAPED_UNICODE); exit; }
|
||||
|
||||
// ─── auth ───
|
||||
$tok = $_SERVER['HTTP_X_OPS_TOKEN'] ?? ($_POST['token'] ?? '');
|
||||
if (!is_string($tok) || !hash_equals($OPS_TOKEN, $tok)) out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
|
||||
$action = $_POST['action'] ?? 'reassign';
|
||||
// Auteur réel = staff legacy du répartiteur Ops (mappé depuis son email Authentik côté hub) ; repli = Tech Targo.
|
||||
$actor = (int)($_POST['actor_staff_id'] ?? 0);
|
||||
|
||||
// ─── connexion (droits write hérités de l'hôte app) ───
|
||||
$db = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||
if ($db->connect_errno) out(['ok' => false, 'error' => 'db_connect: ' . $db->connect_error], 500);
|
||||
$db->set_charset('utf8');
|
||||
$now = time();
|
||||
$logStaff = $actor > 0 ? $actor : $OPS_STAFF;
|
||||
|
||||
function ops_log($db, $ticket, $staff, $msg) {
|
||||
$m = $db->real_escape_string('<hr><p>CHANGE LOG (Ops):</p><p>' . $msg . '</p>');
|
||||
$db->query("INSERT INTO ticket_msg (ticket_id, staff_id, msg, date_orig, public) VALUES ($ticket, $staff, '$m', " . time() . ", 0)");
|
||||
}
|
||||
|
||||
// ─── FERMETURE EN LOT : 1 connexion, N tickets (efficace — évite N allers-retours HTTPS + mysqli) ───
|
||||
if ($action === 'batch_close') {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string)($_POST['ticket_ids'] ?? ''))))));
|
||||
$closed = 0; $skipped = 0; $lockedN = 0; $results = [];
|
||||
foreach ($ids as $tid) {
|
||||
$r = $db->query("SELECT status, lock_name FROM ticket WHERE id = $tid LIMIT 1"); $row = $r ? $r->fetch_assoc() : null;
|
||||
if (!$row || $row['status'] === 'closed') { $skipped++; $results[] = ['t' => $tid, 'skip' => true]; continue; }
|
||||
if (trim((string)$row['lock_name']) !== '') { $lockedN++; $results[] = ['t' => $tid, 'locked' => $row['lock_name']]; continue; }
|
||||
$db->query("UPDATE ticket SET status='closed', date_closed=$now, closed_by=$logStaff, last_update=$now WHERE id=$tid AND status<>'closed'");
|
||||
$db->query("UPDATE ticket SET status='open', last_update=$now WHERE waiting_for=$tid AND status<>'closed'");
|
||||
ops_log($db, $tid, $logStaff, 'Ticket fermé via Ops (lot).');
|
||||
$closed++; $results[] = ['t' => $tid, 'ok' => true];
|
||||
}
|
||||
out(['ok' => true, 'action' => 'batch_close', 'closed' => $closed, 'skipped' => $skipped, 'locked' => $lockedN, 'results' => $results]);
|
||||
}
|
||||
|
||||
// ─── actions sur 1 ticket ───
|
||||
$ticket = (int)($_POST['ticket_id'] ?? 0);
|
||||
if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400);
|
||||
$res = $db->query("SELECT id, status, assign_to, lock_name, followed_by FROM ticket WHERE id = $ticket LIMIT 1");
|
||||
$t = $res ? $res->fetch_assoc() : null;
|
||||
if (!$t) out(['ok' => false, 'error' => 'ticket introuvable'], 404);
|
||||
if ($t['status'] === 'closed') out(['ok' => false, 'error' => 'ticket déjà fermé', 'status' => 'closed']);
|
||||
// LOCK : un staff édite le ticket dans le legacy → on NE clobbe PAS (le répartiteur réessaie)
|
||||
if (trim((string)$t['lock_name']) !== '') out(['ok' => false, 'error' => 'verrouillé', 'locked_by' => $t['lock_name']]);
|
||||
|
||||
if ($action === 'close') {
|
||||
$db->query("UPDATE ticket SET status='closed', date_closed=$now, closed_by=$logStaff, last_update=$now WHERE id=$ticket AND status<>'closed'");
|
||||
$aff = $db->affected_rows;
|
||||
// réouvre les tickets enfants en attente de celui-ci (fidèle au legacy)
|
||||
$db->query("UPDATE ticket SET status='open', last_update=$now WHERE waiting_for=$ticket AND status<>'closed'");
|
||||
ops_log($db, $ticket, $logStaff, 'Ticket fermé via Ops.');
|
||||
out(['ok' => true, 'action' => 'close', 'affected' => $aff]);
|
||||
}
|
||||
|
||||
// ─── reassign (+ assistants) ───
|
||||
$staff = (int)($_POST['staff_id'] ?? 0);
|
||||
if ($staff <= 0) out(['ok' => false, 'error' => 'staff_id requis'], 400);
|
||||
$chk = $db->query("SELECT id, first_name, last_name, email FROM staff WHERE id=$staff AND status=1");
|
||||
$s = $chk ? $chk->fetch_assoc() : null;
|
||||
if (!$s) out(['ok' => false, 'error' => 'staff inactif ou introuvable', 'staff_id' => $staff]);
|
||||
|
||||
// assistants : CSV d'ids → participant `-id-;-id-` + ajout aux followers (followed_by JSON) — FIDÈLE à ticket_view.php
|
||||
$assistCsv = trim((string)($_POST['assist_ids'] ?? ''));
|
||||
$assistIds = $assistCsv === '' ? [] : array_values(array_unique(array_filter(array_map('intval', explode(',', $assistCsv)))));
|
||||
$participant = '';
|
||||
foreach ($assistIds as $a) { $participant .= ($participant === '' ? '' : ';') . "-$a-"; }
|
||||
$aFollow = json_decode($t['followed_by'] !== '' && $t['followed_by'] !== null ? $t['followed_by'] : '{}', true);
|
||||
if (!is_array($aFollow)) $aFollow = [];
|
||||
foreach ($assistIds as $a) { if (!array_key_exists((string)$a, $aFollow)) $aFollow[(string)$a] = ['child' => 0]; }
|
||||
$jsonFollow = $db->real_escape_string(json_encode((object)$aFollow));
|
||||
$participantEsc = $db->real_escape_string($participant);
|
||||
|
||||
$db->query("UPDATE ticket SET assign_to=$staff, participant='$participantEsc', followed_by='$jsonFollow', last_update=$now WHERE id=$ticket AND status<>'closed'");
|
||||
$aff = $db->affected_rows;
|
||||
$who = trim(($s['first_name'] ?? '') . ' ' . ($s['last_name'] ?? ''));
|
||||
$log = "Réassigné à #$staff ($who) via Ops." . ($assistIds ? ' Assistants : ' . implode(', ', $assistIds) . '.' : '');
|
||||
ops_log($db, $ticket, $logStaff, $log);
|
||||
|
||||
// ─── avis courriel au nouveau tech (notify=1) — MÊME contenu que ticket_view.php (résumé + lien Répondre),
|
||||
// + ligne « Adresse » fournie par Ops. Envoi via PHPMailer6 OAuth2 (creds dans ops_oauth_boot.php, hors repo). ───
|
||||
$notified = false; $notifyErr = null; $notifyTo = '';
|
||||
if (!empty($_POST['notify'])) {
|
||||
$staffEmail = trim((string)($s['email'] ?? ''));
|
||||
$notifyTo = $staffEmail;
|
||||
if ($staffEmail === '') { $notifyErr = 'staff sans courriel'; }
|
||||
else {
|
||||
$opsAddress = trim((string)($_POST['address'] ?? '')); // adresse de service propre (Ops)
|
||||
$tq = $db->query("SELECT subject, dept_id, account_id, status, due_date, due_time, bon_id, wizard_fibre FROM ticket WHERE id=$ticket LIMIT 1");
|
||||
$tr = $tq ? $tq->fetch_assoc() : [];
|
||||
$subject = (string)($tr['subject'] ?? '');
|
||||
$deptId = (int)($tr['dept_id'] ?? 0);
|
||||
$accId = (int)($tr['account_id'] ?? 0);
|
||||
$tstatus = (string)($tr['status'] ?? '');
|
||||
$dueEpoch = (int)($tr['due_date'] ?? 0);
|
||||
$dueStr = $dueEpoch > 0 ? date('d-m-Y', $dueEpoch) : 'sans date';
|
||||
$dueTime = trim((string)($tr['due_time'] ?? ''));
|
||||
$dueTimeStr = ($dueTime !== '' && $dueEpoch > 0) ? " - $dueTime" : '';
|
||||
$bonId = trim((string)($tr['bon_id'] ?? ''));
|
||||
$wizFibre = trim((string)($tr['wizard_fibre'] ?? ''));
|
||||
$dn = $db->query("SELECT name FROM ticket_dept WHERE id=$deptId LIMIT 1"); $dnr = $dn ? $dn->fetch_assoc() : null;
|
||||
$deptName = (string)($dnr['name'] ?? '');
|
||||
$cn = $db->query("SELECT first_name, last_name, company, customer_id FROM account WHERE id=$accId LIMIT 1"); $cnr = $cn ? $cn->fetch_assoc() : null;
|
||||
$clientName = $cnr ? trim(($cnr['first_name'] ?? '') . ' ' . ($cnr['last_name'] ?? '') . ' ' . ($cnr['company'] ?? '') . ' - ' . ($cnr['customer_id'] ?? '')) : '';
|
||||
// liens ONU (fibre) / bon de travail — fidèle à ticket_view.php
|
||||
$onu = '';
|
||||
if ($wizFibre !== '') { $wi = explode('|', $wizFibre); $onu = "<a href='https://store.targo.ca/targo/rep_ticket/connect_fibre.php?tt=$ticket&td=" . ($wi[0] ?? '') . "&ts=$staff&tf=" . ($wi[1] ?? '') . "&tp=" . ($wi[2] ?? '') . "'>Connecter ONU</a><br><br>"; }
|
||||
if ($deptId === 26) $onu = "<a href='https://store.targo.ca/targo/rep_ticket/change_fibre.php?tt=$ticket'>Remplacer ONU</a><br><br>";
|
||||
$bonLink = $bonId !== '' ? "Ouvrir <b>Bon de Travail</b>: https://facturation.targo.ca/facturation/accueil.php?menu=bon_travail&bon_id=$bonId <br><br>" : '';
|
||||
$addrLine = $opsAddress !== '' ? "<b>Adresse</b>: " . htmlspecialchars($opsAddress, ENT_QUOTES) . "<br/>" : '';
|
||||
$replyLink = "https://store.targo.ca/targo/reply_ticket.php?ticket=$ticket&staff=$staff";
|
||||
$subjectLine = html_entity_decode("[Ticket #$ticket] $subject [$deptName]", ENT_QUOTES);
|
||||
$body = "Ce ticket vous a été assigné.<br/><br/>"
|
||||
. "Répondre: $replyLink<br/><br/>"
|
||||
. $onu . $bonLink
|
||||
. "<b>Ticket ID</b>: <a href='https://facturation.targo.ca/facturation/accueil.php?menu=ticket_view&id=$ticket'>$ticket</a><br/>"
|
||||
. "<b>Client</b>: $clientName<br/><br/>"
|
||||
. "<b>Sujet</b>: $subject<br/>"
|
||||
. "<b>Status</b>: $tstatus<br/>"
|
||||
. "<b>Due date</b>: $dueStr $dueTimeStr<br/>"
|
||||
. $addrLine
|
||||
. "<b>Département</b>: $deptName<br/><br/>";
|
||||
try {
|
||||
// L'app legacy (vendor + PHPMailer6) est dans ./facturation quand ce script est au docroot /var/www ; sinon ici.
|
||||
$APP_DIR = is_dir(__DIR__ . '/facturation/PHPMailer6') ? __DIR__ . '/facturation' : __DIR__;
|
||||
require_once $APP_DIR . '/vendor/autoload.php'; // League\OAuth2 (Google provider) — déjà dans l'app
|
||||
require_once $APP_DIR . '/PHPMailer6/Exception.php';
|
||||
require_once $APP_DIR . '/PHPMailer6/PHPMailer.php';
|
||||
require_once $APP_DIR . '/PHPMailer6/SMTP.php';
|
||||
require_once $APP_DIR . '/PHPMailer6/OAuth.php';
|
||||
$provider = new \League\OAuth2\Client\Provider\Google(['clientId' => $clientId, 'clientSecret' => $clientSecret]); // creds depuis ops_secret.php
|
||||
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = gethostbyname('smtp.gmail.com');
|
||||
$mail->SMTPOptions = ['ssl' => ['verify_peer_name' => false]];
|
||||
$mail->Port = 587;
|
||||
$mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->AuthType = 'XOAUTH2';
|
||||
$mail->setOAuth(new \PHPMailer\PHPMailer\OAuth(['provider' => $provider, 'clientId' => $clientId, 'clientSecret' => $clientSecret, 'refreshToken' => $refreshToken, 'userName' => $oauthEmail]));
|
||||
$mail->setFrom('ticket@targointernet.com', 'Ticket');
|
||||
$mail->addAddress($staffEmail);
|
||||
$mail->Subject = $subjectLine;
|
||||
$mail->CharSet = \PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
|
||||
$mail->isHTML(true);
|
||||
$mail->Body = $body;
|
||||
$mail->send();
|
||||
$notified = true;
|
||||
} catch (\Throwable $e) { $notifyErr = $e->getMessage(); }
|
||||
}
|
||||
}
|
||||
out(['ok' => true, 'action' => 'reassign', 'affected' => $aff, 'staff' => $staff, 'assistants' => $assistIds, 'notified' => $notified, 'notify_to' => $notifyTo, 'notify_error' => $notifyErr]);
|
||||
|
|
@ -26,6 +26,53 @@ const cfg = require('./config')
|
|||
const { log, json, httpRequest } = require('./helpers')
|
||||
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo
|
||||
const addrdb = require('./address-db') // pool PG local (camping_registry)
|
||||
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
|
||||
const fs = require('fs')
|
||||
// Pont d'ÉCRITURE legacy = endpoint PHP sur facturation.targo.ca (hérite des droits write de l'app ; aucun grant DB).
|
||||
// Token lu d'un FICHIER (/app/data/ops_legacy.token) → pas de var d'env → pas besoin de recréer le conteneur.
|
||||
const OPS_LEGACY_URL = process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php'
|
||||
function opsLegacyToken () { try { return (process.env.OPS_LEGACY_TOKEN || fs.readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch (e) { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }
|
||||
async function legacyWrite (fields) { // POST form-urlencoded + X-Ops-Token → ops_reassign.php
|
||||
const body = Object.entries(fields).map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v == null ? '' : v)).join('&')
|
||||
return httpRequest(OPS_LEGACY_URL, '', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Ops-Token': opsLegacyToken() }, timeout: 15000 })
|
||||
}
|
||||
// Ferme un ticket dans le legacy (via PHP : status=closed + date_closed + closed_by=acteur + réouverture enfants + log).
|
||||
async function closeTicketLegacy (ticketId, actorEmail) {
|
||||
let actorStaff = 0
|
||||
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } } catch (e) {}
|
||||
const w = await legacyWrite({ action: 'close', ticket_id: ticketId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
return (w && w.data) || { ok: false, error: 'no response' }
|
||||
}
|
||||
// Fermeture EN LOT : 1 seul appel PHP (1 connexion) pour N tickets → bien plus rapide que N appels.
|
||||
async function batchCloseLegacy (ticketIds, actorEmail) {
|
||||
let actorStaff = 0
|
||||
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } } catch (e) {}
|
||||
const w = await legacyWrite({ action: 'batch_close', ticket_ids: ticketIds.join(','), actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
return (w && w.data) || { ok: false, error: 'no response' }
|
||||
}
|
||||
// Résout le staff legacy de l'acteur Ops depuis son email Authentik (0 si introuvable).
|
||||
async function resolveActorStaff (actorEmail) {
|
||||
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) return ar[0].id } } catch (e) {}
|
||||
return 0
|
||||
}
|
||||
// RETOUR AU POOL : désassigne le Dispatch Job dans ERPNext PUIS — si le job était poussé à un tech —
|
||||
// réécrit le ticket legacy à assign_to=3301 (Tech Targo, l'inbox de dispatch). Action EXPLICITE seulement
|
||||
// (les redistributions auto vers un autre tech poussent le nouveau tech au prochain Publier, pas 3301).
|
||||
async function returnToPool (job, actorEmail) {
|
||||
if (!job) return { ok: false, error: 'job requis' }
|
||||
const djs = await erp.list('Dispatch Job', { filters: [['name', '=', job]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status'], limit: 1 })
|
||||
const j = djs && djs[0]
|
||||
if (!j) return { ok: false, error: 'job introuvable' }
|
||||
const hadTech = !!j.assigned_tech
|
||||
const r = await erp.update('Dispatch Job', job, { assigned_tech: null, status: 'open', start_time: null }).catch(e => ({ ok: false, error: e.message }))
|
||||
let legacy = null
|
||||
if (hadTech && j.legacy_ticket_id) { // n'écrit au legacy que si le ticket avait réellement été attribué
|
||||
const actorStaff = await resolveActorStaff(actorEmail)
|
||||
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: TARGO_TECH_STAFF_ID, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
legacy = (w && w.data) || { ok: false, error: 'no response' }
|
||||
}
|
||||
return { ok: !(r && r.ok === false), job, erp: r, legacy, returned_to_pool: !!(legacy && legacy.ok) }
|
||||
}
|
||||
|
||||
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc
|
||||
// FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
|
||||
|
|
@ -66,6 +113,37 @@ const { norm } = require('./util/text')
|
|||
// GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette
|
||||
// les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet
|
||||
// absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés).
|
||||
// TERRITOIRE de service TARGO (Montérégie sud-ouest : Haut-St-Laurent, Beauharnois-Salaberry, Jardins-de-Napierville,
|
||||
// Roussillon). Rejette les géocodes hors-zone — Mapbox/trigram tombent sinon sur des HOMONYMES (Gaspésie, Outaouais,
|
||||
// Estrie : « Saint-Louis », « Franklin », « Lac des Pins »…). bbox volontairement large mais bien en deçà du reste du QC.
|
||||
const TERR = { latMin: 44.85, latMax: 45.6, lonMin: -74.85, lonMax: -73.2 }
|
||||
function inTerritory (lat, lon) { const a = parseFloat(lat), o = parseFloat(lon); return isFinite(a) && isFinite(o) && a >= TERR.latMin && a <= TERR.latMax && o >= TERR.lonMin && o <= TERR.lonMax }
|
||||
|
||||
// Repli CENTROÏDE (code postal complet, sinon ville) depuis rqa_addresses LOCAL — « au pire, le centre du CP/ville ».
|
||||
// Toujours EN territoire (filtré par bbox). Cache module.
|
||||
const _centroidCache = new Map()
|
||||
async function geocodeCentroid (postalCode, city) {
|
||||
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase()
|
||||
const key = 'ctr|' + fsa + '|' + norm(city)
|
||||
if (_centroidCache.has(key)) return _centroidCache.get(key)
|
||||
let res = null
|
||||
try {
|
||||
const pg = addrdb.pool()
|
||||
if (fsa.length >= 6) { // centroïde du code postal complet (zone fine, ex. J0S1T0)
|
||||
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE replace(upper(code_postal), ' ', '') = $1", [fsa])
|
||||
const row = r.rows[0]
|
||||
if (row && Number(row.n) > 0 && inTerritory(row.lat, row.lon)) res = { lat: +row.lat, lon: +row.lon }
|
||||
}
|
||||
if (!res && city) { // sinon centroïde de la VILLE, contraint au territoire
|
||||
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE lower(unaccent(ville)) = lower(unaccent($1)) AND latitude BETWEEN $2 AND $3 AND longitude BETWEEN $4 AND $5", [city, TERR.latMin, TERR.latMax, TERR.lonMin, TERR.lonMax])
|
||||
const row = r.rows[0]
|
||||
if (row && Number(row.n) > 0) res = { lat: +row.lat, lon: +row.lon }
|
||||
}
|
||||
} catch (e) { log('geocodeCentroid error:', e.message) }
|
||||
_centroidCache.set(key, res)
|
||||
return res
|
||||
}
|
||||
|
||||
const _geoCache = new Map()
|
||||
async function geocodeRQA (addressLine, postalCode, city) {
|
||||
const key = norm([addressLine, postalCode, city].filter(Boolean).join('|'))
|
||||
|
|
@ -82,7 +160,7 @@ async function geocodeRQA (addressLine, postalCode, city) {
|
|||
const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue
|
||||
const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) }
|
||||
const pick = rows.find(r => {
|
||||
if (!coord(r.latitude, r.longitude)) return false
|
||||
if (!coord(r.latitude, r.longitude) || !inTerritory(r.latitude, r.longitude)) return false // hors territoire = homonyme → rejet
|
||||
if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet
|
||||
if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet
|
||||
const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
|
||||
|
|
@ -122,7 +200,7 @@ async function geocodeMapbox (addressLine, city, postalCode) {
|
|||
const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0]
|
||||
if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) {
|
||||
const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat]
|
||||
if (c) res = c
|
||||
if (c && inTerritory(c.lat, c.lon)) res = c // hors territoire (proximité insuffisante → homonyme lointain) → rejet
|
||||
}
|
||||
} catch (e) { log('geocodeMapbox error:', e.message) }
|
||||
_mbCache.set(key, res)
|
||||
|
|
@ -153,6 +231,9 @@ function stripHtml (html, max = 1500) {
|
|||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||||
.replace(/�*39;|'|'/gi, "'").replace(/"/gi, '"')
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)) } catch (e) { return '' } })
|
||||
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(parseInt(n, 10)) } catch (e) { return '' } })
|
||||
.replace(/&(eacute|egrave|ecirc|euml|agrave|acirc|aacute|ccedil|ocirc|ouml|ugrave|ucirc|icirc|iuml|ntilde|deg|laquo|raquo|hellip|rsquo|lsquo|ldquo|rdquo|ndash|mdash|nbsp|euro);/gi, (m, e) => ({ eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë', agrave: 'à', acirc: 'â', aacute: 'á', ccedil: 'ç', ocirc: 'ô', ouml: 'ö', ugrave: 'ù', ucirc: 'û', icirc: 'î', iuml: 'ï', ntilde: 'ñ', deg: '°', laquo: '«', raquo: '»', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', ndash: '–', mdash: '—', nbsp: ' ', euro: '€' }[e.toLowerCase()] || m))
|
||||
.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
||||
if (s.length > max) s = s.slice(0, max) + '…'
|
||||
return s
|
||||
|
|
@ -225,8 +306,8 @@ async function buildJob (t) {
|
|||
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
|
||||
const addr = svcAddr || billAddr
|
||||
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
|
||||
if (!sl && addr) subject = (subject + ' · ' + addr) // pas de Service Location → on garde l'adresse visible dans le sujet
|
||||
subject = subject.slice(0, 140) // Subject = champ Data Frappe (max 140 car.) ; le détail complet est dans legacy_detail/coords
|
||||
const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
|
||||
subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
|
||||
|
||||
const payload = {
|
||||
ticket_id: 'LEG-' + t.id,
|
||||
|
|
@ -239,9 +320,10 @@ async function buildJob (t) {
|
|||
legacy_ticket_id: String(t.id),
|
||||
legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…)
|
||||
}
|
||||
if (addr) payload.address = addr.slice(0, 140) // adresse de SERVICE (delivery, sinon facturation) EN CLAIR — toujours importée, indépendamment du lien Service Location / des coords (champ Frappe Data = 140 car.)
|
||||
const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil)
|
||||
// En-tête de dates (ouverture + dernière MàJ) pour juger l'ancienneté → décider de fermer ; puis la description.
|
||||
const dateHdr = '🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '')
|
||||
const dateHdr = '🎫 Ticket #' + t.id + ' · 🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '')
|
||||
const detail = [dateHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n')
|
||||
if (detail) payload.legacy_detail = detail // description + dates → visible dans Ops (mouseover panneau + détail Dispatch)
|
||||
const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd
|
||||
|
|
@ -270,7 +352,7 @@ async function buildJob (t) {
|
|||
}
|
||||
|
||||
async function findExisting (legacyId) {
|
||||
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location'], limit: 1 })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address'], limit: 1 })
|
||||
return (r && r[0]) || null
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +398,8 @@ async function syncImpl ({ dryRun = false } = {}) {
|
|||
(Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5)
|
||||
if (b.payload.latitude != null && (!exHas || isUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ }
|
||||
if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location
|
||||
// Adresse de SERVICE : backfill si jamais écrite (cas des 175 jobs historiques) ; et rafraîchit pour les jobs encore au pool (open + non assigné).
|
||||
if (b.payload.address && (!ex.address || (ex.status === 'open' && !ex.assigned_tech && ex.address !== b.payload.address))) patch.address = b.payload.address
|
||||
if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
|
||||
// Rafraîchit le sujet (qui inclut l'adresse de SERVICE) pour les jobs encore au pool (open + non assigné),
|
||||
// sans surprendre un tech sur un job déjà dispatché. Corrige les sujets anciens basés sur la facturation.
|
||||
|
|
@ -358,6 +442,239 @@ async function reconcile () {
|
|||
return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun }
|
||||
}
|
||||
|
||||
// RÉIMPORT autoritaire des adresses de SERVICE depuis la table legacy `delivery` (point de service réel),
|
||||
// pour TOUS les Dispatch Jobs issus du pont — y compris assignés/fermés que la sync (open + 3301) ne retouche pas.
|
||||
// Écrit `address` (svcAddr delivery > billAddr) et remplit les coords MANQUANTES (0,0/NULL) depuis delivery
|
||||
// (ne clobbe JAMAIS un job déjà géolocalisé → sécurité dispatch). Les jobs de CAMPING sont exclus : la `delivery`
|
||||
// legacy y pointe la RÉSIDENCE du client (ce qu'on corrige justement par la géoloc fixe du camping).
|
||||
// Passe par le verrou de sync (frappe_pg = zéro concurrence). dryRun => 0 écriture + échantillon des changements.
|
||||
function reimportAddresses (opts = {}) {
|
||||
const run = _syncLock.then(() => reimportAddressesImpl(opts), () => reimportAddressesImpl(opts))
|
||||
_syncLock = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
async function reimportAddressesImpl ({ dryRun = false, overwrite = true } = {}) {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'subject', 'address', 'latitude', 'longitude', 'status', 'assigned_tech'], limit: 5000 })
|
||||
const ids = [...new Set((jobs || []).map(j => parseInt(j.legacy_ticket_id, 10)).filter(Number.isFinite))]
|
||||
if (!ids.length) return { ok: true, dryRun, jobs: 0, legacy_found: 0, address_updated: 0, coords_filled: 0, camping_skipped: 0, skipped: 0, errors: 0, samples: [] }
|
||||
// Même jointure `delivery` que la sync (delivery_id du ticket → repli delivery avec coords → n'importe quel delivery du compte).
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.id,
|
||||
dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip, dv.latitude AS dv_lat, dv.longitude AS dv_lon,
|
||||
a.address1, a.address2, a.city AS a_city, a.state, a.zip AS a_zip
|
||||
FROM ticket t
|
||||
LEFT JOIN account a ON a.id = t.account_id
|
||||
LEFT JOIN delivery dv ON dv.id = COALESCE(
|
||||
NULLIF(t.delivery_id, 0),
|
||||
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||||
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
|
||||
)
|
||||
WHERE t.id IN (?)`,
|
||||
[ids],
|
||||
)
|
||||
const byId = new Map((rows || []).map(r => [String(r.id), r]))
|
||||
const campings = await getCampings()
|
||||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||||
let addrUpd = 0, coordFill = 0, skipped = 0, campSkip = 0, errors = 0
|
||||
const samples = []
|
||||
for (const j of jobs) {
|
||||
const r = byId.get(String(j.legacy_ticket_id)); if (!r) { skipped++; continue }
|
||||
if (campingFor(campings, [j.subject, r.dv_city, r.dv_addr])) { campSkip++; continue } // camping → résidence non pertinente, on garde la géoloc camping
|
||||
const svcAddr = [r.dv_addr, r.dv_city, r.dv_zip].filter(Boolean).join(', ')
|
||||
const billAddr = [r.address1, r.address2, r.a_city, r.state, r.a_zip].filter(Boolean).join(', ')
|
||||
const addr = (svcAddr || billAddr).slice(0, 140)
|
||||
const patch = {}
|
||||
if (addr && (overwrite ? norm(addr) !== norm(j.address || '') : !j.address)) patch.address = addr
|
||||
const dc = coord(r.dv_lat, r.dv_lon) // coords delivery valides (bornes QC) ?
|
||||
if (dc && !(hasCoord(j.latitude) && hasCoord(j.longitude))) { patch.latitude = dc.lat; patch.longitude = dc.lon } // remplit SEULEMENT si manquantes
|
||||
if (!Object.keys(patch).length) { skipped++; continue }
|
||||
if (patch.address) addrUpd++
|
||||
if (patch.latitude != null) coordFill++
|
||||
if (samples.length < 15) samples.push({ job: j.name, ticket: j.legacy_ticket_id, status: j.status, src: svcAddr ? 'delivery' : 'billing', from: (j.address || '').slice(0, 45), to: patch.address || '(adresse inchangée)', coords_filled: patch.latitude != null })
|
||||
if (!dryRun) { const res = await erp.update('Dispatch Job', j.name, patch); if (!(res && res.ok)) errors++ }
|
||||
}
|
||||
return { ok: true, dryRun, jobs: jobs.length, legacy_found: rows.length, address_updated: addrUpd, coords_filled: coordFill, camping_skipped: campSkip, skipped, errors, samples }
|
||||
}
|
||||
|
||||
// REMPLIT les coords MANQUANTES (0,0/NULL) des Dispatch Jobs qui ont une adresse mais pas de GPS → « hors carte ».
|
||||
// Priorité : coords de la Service Location liée (si valides) → géocodage RQA (trigram) → Mapbox. Ne clobbe JAMAIS
|
||||
// un job déjà géolocalisé. Couvre TOUTE source (legacy ET jobs de test/seed). Verrou de sync. dryRun => 0 écriture.
|
||||
function fillMissingCoords (opts = {}) {
|
||||
const run = _syncLock.then(() => fillMissingCoordsImpl(opts), () => fillMissingCoordsImpl(opts))
|
||||
_syncLock = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
const PC_RE = /[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d/ // code postal canadien
|
||||
function splitAddr (address) { // "ligne, ville, code postal" → { line, city, postal } (tolérant)
|
||||
const parts = String(address || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
const line = parts[0] || ''
|
||||
const last = parts[parts.length - 1] || ''
|
||||
const postal = PC_RE.test(last) ? last : ''
|
||||
const city = postal ? (parts[parts.length - 2] || '') : (parts.length > 1 ? parts[parts.length - 1] : '')
|
||||
return { line, city, postal }
|
||||
}
|
||||
async function fillMissingCoordsImpl ({ dryRun = false } = {}) {
|
||||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
|
||||
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude)))
|
||||
if (!targets.length) return { ok: true, dryRun, candidates: 0, filled: 0, via_service_location: 0, via_rqa: 0, via_mapbox: 0, failed: 0, samples: [] }
|
||||
// Précharge les coords des Service Locations liées (1 requête).
|
||||
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
|
||||
const slCoord = new Map()
|
||||
if (slCodes.length) {
|
||||
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
|
||||
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
|
||||
}
|
||||
let filled = 0, viaSL = 0, viaRQA = 0, viaMB = 0, failed = 0
|
||||
const samples = []
|
||||
for (const j of targets) {
|
||||
let c = null, src = null
|
||||
if (j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
|
||||
if (!c) { const { line, city, postal } = splitAddr(j.address); const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } }
|
||||
if (!c) { const { line, city, postal } = splitAddr(j.address); const mb = await geocodeMapbox(line, city, postal); if (mb) { c = mb; src = 'mapbox' } }
|
||||
if (!c) { failed++; if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: 'ÉCHEC géocodage' }); continue }
|
||||
if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: `${c.lat.toFixed(5)},${c.lon.toFixed(5)} via ${src}` })
|
||||
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
|
||||
filled++; if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else viaMB++
|
||||
}
|
||||
return { ok: true, dryRun, candidates: targets.length, filled, via_service_location: viaSL, via_rqa: viaRQA, via_mapbox: viaMB, failed, samples }
|
||||
}
|
||||
|
||||
// CORRIGE les coords MANQUANTES *ou HORS-TERRITOIRE* (homonymes mal géocodés : Gaspésie/Outaouais/Estrie).
|
||||
// Chaîne TOUT-EN-TERRITOIRE : camping → coords Service Location → RQA rue (gardé territoire) → centroïde code
|
||||
// postal → centroïde ville. ÉCRASE une coord hors-zone (c'est le bug) ; remplit une coord manquante. Verrou de sync.
|
||||
function fixGeocoding (opts = {}) {
|
||||
const run = _syncLock.then(() => fixGeocodingImpl(opts), () => fixGeocodingImpl(opts))
|
||||
_syncLock = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
async function fixGeocodingImpl ({ dryRun = false } = {}) {
|
||||
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'subject', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
|
||||
// cibles = pas de coords valides EN territoire (manquantes OU hors-zone)
|
||||
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude) && inTerritory(j.latitude, j.longitude)))
|
||||
if (!targets.length) return { ok: true, dryRun, candidates: 0, fixed: 0, via_camping: 0, via_service_location: 0, via_rqa: 0, via_postal: 0, via_city: 0, failed: 0, samples: [] }
|
||||
const campings = await getCampings()
|
||||
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
|
||||
const slCoord = new Map()
|
||||
if (slCodes.length) {
|
||||
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
|
||||
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude) && inTerritory(s.latitude, s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
|
||||
}
|
||||
let fixed = 0, viaCamp = 0, viaSL = 0, viaRQA = 0, viaPostal = 0, viaCity = 0, failed = 0
|
||||
const samples = []
|
||||
for (const j of targets) {
|
||||
const { line, city, postal } = splitAddr(j.address)
|
||||
let c = null, src = null
|
||||
const camp = campingFor(campings, [j.subject, j.address])
|
||||
if (camp && inTerritory(camp.latitude, camp.longitude)) { c = { lat: Number(camp.latitude), lon: Number(camp.longitude) }; src = 'camping' }
|
||||
if (!c && j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
|
||||
if (!c) { const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } } // déjà gardé territoire
|
||||
if (!c) { const ce = await geocodeCentroid(postal, city); if (ce) { c = ce; src = (String(postal || '').replace(/\s+/g, '').length >= 6) ? 'postal' : 'city' } }
|
||||
if (!c) { failed++; if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), result: 'ÉCHEC (absent RQA)' }); continue }
|
||||
if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), from: `${(+j.latitude || 0).toFixed(3)},${(+j.longitude || 0).toFixed(3)}`, to: `${c.lat.toFixed(4)},${c.lon.toFixed(4)} via ${src}` })
|
||||
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
|
||||
fixed++; if (src === 'camping') viaCamp++; else if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else if (src === 'postal') viaPostal++; else viaCity++
|
||||
}
|
||||
return { ok: true, dryRun, candidates: targets.length, fixed, via_camping: viaCamp, via_service_location: viaSL, via_rqa: viaRQA, via_postal: viaPostal, via_city: viaCity, failed, samples }
|
||||
}
|
||||
|
||||
// PURGE des orphelins d'un ANCIEN import : jobs nommés `TT-<id>` SANS legacy_ticket_id dont le ticket legacy est
|
||||
// `closed` (doublons/périmés — adresse réduite à la ville ; closeResolved ne peut PAS les voir faute de
|
||||
// legacy_ticket_id). Supprime via l'API Frappe (nettoie les liens). NE touche JAMAIS un ticket encore `open`.
|
||||
// Le record canonique reste côté legacy + les jobs `LEG-`. Verrou de sync. dryRun => 0 écriture.
|
||||
function purgeStaleOrphans (opts = {}) {
|
||||
const run = _syncLock.then(() => purgeStaleOrphansImpl(opts), () => purgeStaleOrphansImpl(opts))
|
||||
_syncLock = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
async function purgeStaleOrphansImpl ({ dryRun = false } = {}) {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['name', 'like', 'TT-%']], fields: ['name', 'status', 'subject', 'legacy_ticket_id'], limit: 500 })
|
||||
const withId = (jobs || [])
|
||||
.filter(j => !j.legacy_ticket_id) // seulement les orphelins (les jobs du pont actuel ont un legacy_ticket_id → intouchables)
|
||||
.map(j => ({ ...j, tid: (String(j.name).match(/^TT-(\d+)$/) || [])[1] }))
|
||||
.filter(j => j.tid)
|
||||
if (!withId.length) return { ok: true, dryRun, candidates: 0, closed: 0, deleted: 0, kept_open: 0, kept_open_detail: [], errors: 0, samples: [] }
|
||||
const ids = [...new Set(withId.map(j => parseInt(j.tid, 10)))]
|
||||
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
|
||||
const st = {}; for (const r of rows) st[String(r.id)] = r.status
|
||||
const stale = withId.filter(j => st[j.tid] === 'closed') // périmés = ticket legacy fermé
|
||||
const keptOpen = withId.filter(j => st[j.tid] !== 'closed').map(j => ({ name: j.name, legacy_status: st[j.tid] || 'introuvable' }))
|
||||
let deleted = 0, errors = 0
|
||||
const samples = []
|
||||
for (const j of stale) {
|
||||
if (samples.length < 25) samples.push({ name: j.name, subject: (j.subject || '').slice(0, 40) })
|
||||
if (!dryRun) { const r = await erp.remove('Dispatch Job', j.name); if (r && r.ok) deleted++; else errors++ }
|
||||
}
|
||||
return { ok: true, dryRun, candidates: withId.length, closed: stale.length, deleted, kept_open: keptOpen.length, kept_open_detail: keptOpen, errors, samples }
|
||||
}
|
||||
|
||||
// WRITE-BACK vers le legacy osTicket : pousse l'assignation tech décidée dans Ops → `ticket.assign_to`.
|
||||
// Mapping TECH-<id> → staff legacy <id>, VALIDÉ par concordance de NOM (le staff legacy doit porter le même nom
|
||||
// que le tech Ops → évite de pousser un tech de test/système mal mappé). Ne touche QUE les tickets encore `open`.
|
||||
// Idempotent (saute si assign_to est déjà le bon). C'est un WRITE EXPLICITE vers le legacy — jamais automatique
|
||||
// (le scheduler ne l'appelle pas) ; uniquement via la route POST. Verrou de sync. dryRun => 0 écriture + aperçu.
|
||||
function pushAssignments (opts = {}) {
|
||||
const run = _syncLock.then(() => pushAssignmentsImpl(opts), () => pushAssignmentsImpl(opts))
|
||||
_syncLock = run.then(() => {}, () => {})
|
||||
return run
|
||||
}
|
||||
async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true } = {}) {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
let actorStaff = 0 // staff legacy du répartiteur Ops (email Authentik → staff) = auteur du log/closed_by côté legacy
|
||||
if (actorEmail) { try { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } catch (e) {} }
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['assigned_tech', '!=', '']], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'subject', 'status', 'address'], limit: 2000 })
|
||||
if (!jobs.length) return { ok: true, dryRun, candidates: 0, pushed: 0, skipped: 0, mismatch: 0, errors: 0, notified: 0, samples: [] }
|
||||
const techIds = [...new Set(jobs.map(j => j.assigned_tech))]
|
||||
const techs = await erp.list('Dispatch Technician', { filters: [['name', 'in', techIds]], fields: ['name', 'full_name', 'email'], limit: techIds.length + 5 })
|
||||
const techByName = {}; for (const t of (techs || [])) techByName[t.name] = t
|
||||
// Mapping PRINCIPAL par EMAIL : staff.email (legacy) = Dispatch Technician.email (= identité Authentik via ldap_id). Fiable, sans parsing.
|
||||
const emails = [...new Set((techs || []).map(t => String(t.email || '').toLowerCase()).filter(Boolean))]
|
||||
const [emRows] = emails.length ? await p.query('SELECT id, first_name, last_name, status, lower(email) AS email FROM staff WHERE status = 1 AND lower(email) IN (?)', [emails]) : [[]]
|
||||
const staffByEmail = new Map((emRows || []).map(s => [s.email, s]))
|
||||
// Repli : staff déduit de TECH-<id>, VALIDÉ par concordance de nom (pour les techs sans email).
|
||||
const sufIds = [...new Set(jobs.map(j => (String(j.assigned_tech).match(/(\d{2,})$/) || [])[1]).filter(Boolean))]
|
||||
const [sufRows] = sufIds.length ? await p.query('SELECT id, first_name, last_name, status FROM staff WHERE id IN (?)', [sufIds]) : [[]]
|
||||
const staffById = new Map((sufRows || []).map(s => [String(s.id), s]))
|
||||
const ids = [...new Set(jobs.map(j => parseInt(j.legacy_ticket_id, 10)).filter(Boolean))]
|
||||
const [tk] = ids.length ? await p.query('SELECT id, status, assign_to FROM ticket WHERE id IN (?)', [ids]) : [[]]
|
||||
const tkById = new Map((tk || []).map(r => [String(r.id), r]))
|
||||
// Résout le staff legacy : email d'abord (autoritaire), sinon TECH-<id> validé par nom.
|
||||
const resolveStaff = (techRow, assignedTech) => {
|
||||
const email = String((techRow && techRow.email) || '').toLowerCase()
|
||||
if (email && staffByEmail.has(email)) return { staff: staffByEmail.get(email), via: 'email' }
|
||||
const sid = (String(assignedTech).match(/(\d{2,})$/) || [])[1]
|
||||
const cand = sid ? staffById.get(sid) : null
|
||||
if (cand) {
|
||||
const a = norm((techRow && techRow.full_name) || ''); const b = norm((cand.first_name || '') + ' ' + (cand.last_name || ''))
|
||||
if (a && b && (a === b || a.includes(b) || b.includes(a) || (a.split(' ')[0] === b.split(' ')[0] && a.split(' ').slice(-1)[0] === b.split(' ').slice(-1)[0]))) return { staff: cand, via: 'nom' }
|
||||
}
|
||||
return { staff: null, via: null }
|
||||
}
|
||||
let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0; const errSamples = []; const notifyErrors = []
|
||||
const samples = []
|
||||
for (const j of jobs) {
|
||||
const techRow = techByName[j.assigned_tech]
|
||||
const { staff: st, via } = resolveStaff(techRow, j.assigned_tech)
|
||||
const tkrow = tkById.get(String(j.legacy_ticket_id))
|
||||
if (!st || Number(st.status) !== 1) { mismatch++; if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, tech: j.assigned_tech, ops_nom: (techRow && techRow.full_name) || '(?)', action: '⚠ staff legacy introuvable (ni email ni nom) — ignoré' }); continue }
|
||||
const staffId = String(st.id)
|
||||
if (!tkrow || tkrow.status === 'closed') { skipped++; continue } // ticket fermé/introuvable → on ne réassigne pas
|
||||
if (String(tkrow.assign_to) === staffId) { skipped++; continue } // déjà assigné au bon tech → idempotent
|
||||
if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', via, sujet: (j.subject || '').slice(0, 34) })
|
||||
if (!dryRun) {
|
||||
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: j.address || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
const d = (w && w.data) || {}
|
||||
if (d.ok) { pushed++; if (d.notified) notified++; else if (notify && d.notify_error && notifyErrors.length < 6) notifyErrors.push({ ticket: j.legacy_ticket_id, to: d.notify_to || '', error: d.notify_error }) }
|
||||
else if (d.error === 'verrouillé') { locked++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, locked_by: d.locked_by }) }
|
||||
else { errors++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, error: d.error || ('http ' + (w && w.status)) }) }
|
||||
} else pushed++
|
||||
}
|
||||
return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples }
|
||||
}
|
||||
|
||||
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
|
||||
// (sort du pool / des listes ouvertes). NE touche PAS « In Progress » (tech en action). SÉQUENTIEL.
|
||||
async function closeResolved () {
|
||||
|
|
@ -393,6 +710,136 @@ async function ticketThread (legacyId) {
|
|||
return { ok: true, ticket: id, subject: (trows && trows[0] && trows[0].subject) || '', status: (trows && trows[0] && trows[0].status) || '', count: messages.length, messages }
|
||||
}
|
||||
|
||||
// ── VEILLE Legacy → Ops (poll léger → SSE) ──
|
||||
// Lecture seule (sauf auto-close immédiat, idempotent) : compare l'état legacy (status/assign_to/last_update) de TOUS
|
||||
// les tickets suivis à un snapshot mémoire, et DIFFUSE les deltas sur le topic SSE 'dispatch' (l'Ops rafraîchit en direct).
|
||||
// Détecte : fermeture (→ marque le Dispatch Job Completed tout de suite), réassignation hors-Ops, changement de statut,
|
||||
// nouvelle activité (last_update). Cadence courte (≈3 min) ≠ la sync d'import (15 min). Pas de verrou (read-only legacy+ERP).
|
||||
const _watchSnap = new Map() // ticket_id → { status, assign_to, last_update }
|
||||
let _watchTimer = null
|
||||
let _lastWatch = null
|
||||
async function watchLegacy () {
|
||||
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
|
||||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'subject'], limit: 5000 })
|
||||
const jobByTk = new Map(); const ids = []
|
||||
for (const j of (jobs || [])) { const id = String(j.legacy_ticket_id); jobByTk.set(id, j); const n = parseInt(id, 10); if (n) ids.push(n) }
|
||||
if (!ids.length) { _lastWatch = { at: new Date().toISOString(), tracked: 0, changes: 0 }; return { ok: true, tracked: 0, changes: [] } }
|
||||
const [rows] = await p.query('SELECT id, status, assign_to, last_update FROM ticket WHERE id IN (?)', [ids])
|
||||
const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS
|
||||
const changes = []; let closedNow = 0; let pulled = 0
|
||||
for (const r of (rows || [])) {
|
||||
const id = String(r.id)
|
||||
const prev = _watchSnap.get(id)
|
||||
const cur = { status: r.status, assign_to: String(r.assign_to), last_update: Number(r.last_update) || 0 }
|
||||
_watchSnap.set(id, cur)
|
||||
const j = jobByTk.get(id); if (!j) continue
|
||||
// (1) RÉCONCILIATION D'ÉTAT (à CHAQUE passage, même amorçage) : un job ENCORE dans le pool (open/On Hold, non
|
||||
// assigné côté Ops) dont le ticket legacy est PRIS par un VRAI employé (≠3301, ≠0) et pas fermé → on le RETIRE
|
||||
// du pool (status Cancelled). Couvre les tickets assignés dans Legacy AVANT le démarrage de la veille (ex. 250095).
|
||||
const at = parseInt(cur.assign_to, 10) || 0
|
||||
if (!j.assigned_tech && (j.status === 'open' || j.status === 'On Hold') && cur.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID) {
|
||||
try { await erp.update('Dispatch Job', j.name, { status: 'Cancelled' }); pulled++; changes.push({ ticket: id, job: j.name, kind: 'taken', to_staff: cur.assign_to, subject: j.subject || '' }) } catch (e) {}
|
||||
continue // sorti du pool → pas d'autre delta à émettre
|
||||
}
|
||||
// (2) DELTAS diffusés — seulement une fois le snapshot amorcé
|
||||
if (!seeded || !prev) continue
|
||||
if (prev.status !== cur.status) {
|
||||
if (cur.status === 'closed') {
|
||||
try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closedNow++ } catch (e) {} // reflète tout de suite (idempotent)
|
||||
changes.push({ ticket: id, job: j.name, kind: 'closed', subject: j.subject || '' })
|
||||
} else changes.push({ ticket: id, job: j.name, kind: 'status', from: prev.status, to: cur.status, subject: j.subject || '' })
|
||||
} else if (prev.assign_to !== cur.assign_to) {
|
||||
changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: j.assigned_tech || '', subject: j.subject || '' })
|
||||
} else if (prev.last_update !== cur.last_update) {
|
||||
changes.push({ ticket: id, job: j.name, kind: 'activity', subject: j.subject || '' })
|
||||
}
|
||||
}
|
||||
if (changes.length) sse.broadcast('dispatch', 'legacy-update', { at: new Date().toISOString(), changes })
|
||||
_lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, changes: changes.length }
|
||||
return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, changes }
|
||||
}
|
||||
|
||||
// ── RÉCONCILIATION DES TECHNICIENS (union des 3 systèmes) — RAPPORT + apply manuel ──────────────────────────
|
||||
// 3 sources : staff legacy actif ↔ Dispatch Technician (ERPNext, TECH-<id>) ↔ groupe Authentik 'tech' (accès Ops).
|
||||
// Le rapport liste les ÉCARTS ; aucune écriture Authentik. L'apply CRÉE des fiches Dispatch Technician (ERPNext) pour
|
||||
// les staff choisis (le répartiteur coche). Matching par EMAIL pour l'accès, par id (TECH-<id>) pour la fiche.
|
||||
async function akGet (path) {
|
||||
if (!cfg.AUTHENTIK_TOKEN) return null
|
||||
const r = await httpRequest(cfg.AUTHENTIK_URL || 'https://auth.targo.ca', '/api/v3' + path, { method: 'GET', headers: { Authorization: 'Bearer ' + cfg.AUTHENTIK_TOKEN }, timeout: 12000 }).catch(() => null)
|
||||
return r && r.data
|
||||
}
|
||||
async function techGroupEmailSet () {
|
||||
const g = await akGet('/core/groups/?search=tech')
|
||||
const set = new Set()
|
||||
if (g && Array.isArray(g.results)) {
|
||||
const grp = g.results.find(x => x.name === 'tech') || g.results[0]
|
||||
for (const u of (grp && grp.users_obj) || []) { const e = (u.email || '').toLowerCase().trim(); if (e) set.add(e) }
|
||||
return { ok: true, emails: set }
|
||||
}
|
||||
return { ok: false, emails: set }
|
||||
}
|
||||
async function techSyncReport () {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [staff] = await p.query("SELECT id, first_name, last_name, lower(trim(email)) email FROM staff WHERE status=1")
|
||||
const techs = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'status', 'email'], limit: 500 })
|
||||
const ficheStaffIds = new Set(); const ficheEmails = new Set()
|
||||
for (const t of techs) { const sid = (String(t.technician_id || t.name).match(/(\d+)$/) || [])[1]; if (sid) ficheStaffIds.add(sid); if (t.email) ficheEmails.add(String(t.email).toLowerCase().trim()) }
|
||||
const ak = await techGroupEmailSet()
|
||||
const staff_missing_fiche = []
|
||||
for (const s of (staff || [])) {
|
||||
if (ficheStaffIds.has(String(s.id))) continue
|
||||
if (s.email && ficheEmails.has(s.email)) continue // déjà une fiche (matchée par courriel)
|
||||
staff_missing_fiche.push({ staff_id: s.id, name: [s.first_name, s.last_name].filter(Boolean).join(' ') || ('staff ' + s.id), email: s.email || '', in_tech_group: !!(s.email && ak.emails.has(s.email)) })
|
||||
}
|
||||
const fiche_no_access = ak.ok ? techs.filter(t => { const e = (t.email || '').toLowerCase().trim(); return e && !ak.emails.has(e) }).map(t => ({ technician_id: t.technician_id, name: t.full_name, email: t.email })) : []
|
||||
const fiche_no_email = techs.filter(t => !t.email).map(t => ({ technician_id: t.technician_id, name: t.full_name }))
|
||||
const access_no_fiche = ak.ok ? [...ak.emails].filter(e => !ficheEmails.has(e)).sort() : []
|
||||
return {
|
||||
ok: true, authentik: ak.ok,
|
||||
counts: { staff_active: (staff || []).length, fiches: techs.length, tech_group: ak.emails.size, missing_fiche: staff_missing_fiche.length, no_access: fiche_no_access.length, access_no_fiche: access_no_fiche.length },
|
||||
staff_missing_fiche: staff_missing_fiche.sort((a, b) => b.staff_id - a.staff_id),
|
||||
fiche_no_access, fiche_no_email, access_no_fiche,
|
||||
}
|
||||
}
|
||||
async function techSyncApply (staffIds) {
|
||||
const ids = [...new Set((staffIds || []).map(x => parseInt(x, 10)).filter(Boolean))]
|
||||
if (!ids.length) return { ok: true, created: 0, results: [] }
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [rows] = await p.query('SELECT id, first_name, last_name, email FROM staff WHERE id IN (?) AND status=1', [ids])
|
||||
const byId = new Map((rows || []).map(r => [String(r.id), r]))
|
||||
let created = 0; const results = []
|
||||
for (const id of ids) { // SÉQUENTIEL (frappe_pg)
|
||||
const s = byId.get(String(id)); if (!s) { results.push({ staff_id: id, ok: false, error: 'staff inactif/introuvable' }); continue }
|
||||
const tid = 'TECH-' + id
|
||||
const ex = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tid]], fields: ['name'], limit: 1 })
|
||||
if (ex && ex.length) { results.push({ staff_id: id, ok: true, skipped: 'existe déjà', technician_id: tid }); continue }
|
||||
const body = { technician_id: tid, full_name: [s.first_name, s.last_name].filter(Boolean).join(' ') || tid, resource_type: 'human', status: 'Disponible', efficiency: 1 }
|
||||
if (s.email) body.email = s.email
|
||||
try { await erp.create('Dispatch Technician', body); created++; results.push({ staff_id: id, ok: true, technician_id: tid }) } catch (e) { results.push({ staff_id: id, ok: false, error: String(e.message || e) }) }
|
||||
}
|
||||
return { ok: true, created, results }
|
||||
}
|
||||
|
||||
// MINEUR de durées (baseline d'apprentissage SANS app) : approxime le temps sur-site par la fenêtre des réponses STAFF
|
||||
// d'un même ticket le même jour (< 8 h). Bruité à l'unité mais l'agrégat (médiane par dept/type) donne un point de départ.
|
||||
async function mineDurations () {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.dept_id, (MAX(m.date_orig) - MIN(m.date_orig)) AS span
|
||||
FROM ticket t JOIN ticket_msg m ON m.ticket_id = t.id
|
||||
WHERE t.status='closed' AND t.date_closed > UNIX_TIMESTAMP() - 180*86400 AND m.staff_id > 0
|
||||
GROUP BY t.id, t.dept_id HAVING span > 120 AND span < 8*3600`)
|
||||
const byDept = {}
|
||||
for (const r of (rows || [])) { (byDept[r.dept_id] || (byDept[r.dept_id] = [])).push(Number(r.span) / 60) }
|
||||
const out = []
|
||||
for (const dept in byDept) {
|
||||
const arr = byDept[dept].sort((a, b) => a - b); if (arr.length < 3) continue
|
||||
out.push({ dept_id: Number(dept), job_type: jobType(Number(dept)), n: arr.length, median_min: Math.round(arr[Math.floor(arr.length / 2)]), p75_min: Math.round(arr[Math.floor(arr.length * 0.75)]) })
|
||||
}
|
||||
out.sort((a, b) => b.n - a.n)
|
||||
return { ok: true, note: 'Proxy bruité (fenêtre réponses staff même jour <8h, 180j). Baseline en attendant la capture GPS/app.', by_dept: out.slice(0, 25) }
|
||||
}
|
||||
|
||||
// ── Récurrence (setInterval) ──
|
||||
let _timer = null
|
||||
let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma)
|
||||
|
|
@ -407,13 +854,56 @@ function startSync () {
|
|||
setTimeout(tick, 90 * 1000)
|
||||
_timer = setInterval(tick, minutes * 60 * 1000)
|
||||
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
|
||||
// VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import.
|
||||
const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3
|
||||
const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message))
|
||||
setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
|
||||
_watchTimer = setInterval(wtick, watchMin * 60 * 1000)
|
||||
log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)
|
||||
}
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } }
|
||||
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
try {
|
||||
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
|
||||
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
|
||||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'GET') return json(res, 200, await reimportAddresses({ dryRun: true })) // aperçu (0 écriture)
|
||||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'POST') return json(res, 200, await reimportAddresses({ dryRun: false })) // applique
|
||||
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'GET') return json(res, 200, await fillMissingCoords({ dryRun: true })) // aperçu géocodage des « hors carte »
|
||||
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique
|
||||
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone
|
||||
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'POST') return json(res, 200, await fixGeocoding({ dryRun: false })) // applique
|
||||
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') return json(res, 200, await pushAssignments({ dryRun: true })) // APERÇU write-back tech → legacy (0 écriture)
|
||||
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel
|
||||
const notify = new URL(req.url, 'http://localhost').searchParams.get('notify') !== '0'
|
||||
return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/close-ticket' && method === 'POST') { // ferme un ticket legacy + marque le Dispatch Job Completed
|
||||
const id = new URL(req.url, 'http://localhost').searchParams.get('ticket'); if (!id) return json(res, 400, { ok: false, error: 'ticket requis' })
|
||||
const r = await closeTicketLegacy(id, req.headers['x-authentik-email'] || '')
|
||||
if (r && r.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(id)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} }
|
||||
return json(res, 200, r)
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/batch-close' && method === 'POST') { // ferme N tickets legacy en 1 appel + marque les Dispatch Jobs Completed
|
||||
const ids = (new URL(req.url, 'http://localhost').searchParams.get('tickets') || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (!ids.length) return json(res, 400, { ok: false, error: 'tickets requis' })
|
||||
const r = await batchCloseLegacy(ids, req.headers['x-authentik-email'] || '')
|
||||
if (r && r.ok && Array.isArray(r.results)) { for (const it of r.results) { if (it.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(it.t)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} } } }
|
||||
return json(res, 200, r)
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/return-to-pool' && method === 'POST') { // désassigne (ERPNext) + retour pool 3301 (legacy) — action EXPLICITE
|
||||
const job = new URL(req.url, 'http://localhost').searchParams.get('job'); if (!job) return json(res, 400, { ok: false, error: 'job requis' })
|
||||
return json(res, 200, await returnToPool(job, req.headers['x-authentik-email'] || ''))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'GET') return json(res, 200, await purgeStaleOrphans({ dryRun: true })) // aperçu purge orphelins TT- périmés
|
||||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'POST') return json(res, 200, await purgeStaleOrphans({ dryRun: false })) // supprime
|
||||
if (path === '/dispatch/legacy-sync/watch' && method === 'GET') return json(res, 200, await watchLegacy()) // poll manuel + deltas (diffuse aussi sur SSE 'dispatch')
|
||||
if (path === '/dispatch/legacy-sync/tech-sync' && method === 'GET') return json(res, 200, await techSyncReport()) // rapport réconciliation techs (3 systèmes)
|
||||
if (path === '/dispatch/legacy-sync/mine-durations' && method === 'GET') return json(res, 200, await mineDurations()) // baseline durées apprises (proxy réponses staff)
|
||||
if (path === '/dispatch/legacy-sync/tech-sync/apply' && method === 'POST') { // crée les fiches Dispatch Technician choisies (ERPNext only)
|
||||
const ids = (new URL(req.url, 'http://localhost').searchParams.get('ids') || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
return json(res, 200, await techSyncApply(ids))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
|
||||
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
|
||||
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
|
||||
|
|
@ -428,4 +918,4 @@ async function handle (req, res, method, path) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType } // parseurs purs exposés pour les tests
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests
|
||||
|
|
|
|||
|
|
@ -258,8 +258,66 @@ async function coverage (start, days) {
|
|||
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
|
||||
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
|
||||
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
|
||||
function nowFrappe () { return new Date().toLocaleString('sv-SE', { timeZone: 'America/Toronto' }).replace('T', ' ') } // "YYYY-MM-DD HH:MM:SS"
|
||||
function minutesBetween (a, b) { if (!a || !b) return null; const ms = Date.parse(String(b).replace(' ', 'T')) - Date.parse(String(a).replace(' ', 'T')); return isFinite(ms) ? Math.max(0, Math.round(ms / 60000)) : null }
|
||||
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
|
||||
|
||||
// ── DURÉE D'UNE JOB = modèle ADDITIF par caractéristiques (hors transport) ───────────────────────────────
|
||||
// Base (1) + Σ caractéristiques cochées (×qté), × vitesse tech. Table ÉDITABLE (tableur inline Ops), persistée en JSON.
|
||||
// `minutes` = seed (réglé à la main, inspiré du tableur dispatch) ; `learned_min`+`samples` = calibration future par
|
||||
// régression sur les durées RÉELLES (geofence/checkpoints) — « triangulation » des temps par caractéristique.
|
||||
const JOBCHAR_PATH = '/app/data/job-characteristics.json'
|
||||
const JOBCHAR_SEED = { items: [
|
||||
{ id: 'install_resid', cat: 'base', label: 'Installation fibre résidentielle', minutes: 120, per_qty: false, keywords: 'installation,raccordement' },
|
||||
{ id: 'install_comm', cat: 'base', label: 'Installation commerciale', minutes: 240, per_qty: false, keywords: 'commercial' },
|
||||
{ id: 'predrop', cat: 'base', label: 'Pré-drop (pose)', minutes: 120, per_qty: false, keywords: 'pre-drop,predrop,pré-drop' },
|
||||
{ id: 'repar_fibre', cat: 'base', label: 'Réparation / bris de fibre', minutes: 105, per_qty: false, keywords: 'réparation,reparation,bris,fibre' },
|
||||
{ id: 'depan_wifi', cat: 'base', label: 'Dépannage Wifi / réseau', minutes: 90, per_qty: false, keywords: 'wifi,sans-fil,internet' },
|
||||
{ id: 'changer_secteur', cat: 'base', label: 'Changer de secteur', minutes: 75, per_qty: false, keywords: 'secteur' },
|
||||
{ id: 'retrait', cat: 'base', label: 'Retrait / désinstallation', minutes: 60, per_qty: false, keywords: 'retrait,désinstall,desinstall' },
|
||||
{ id: 'chgmt_equip', cat: 'base', label: "Changement d'équipement (ONU/modem/Hx)", minutes: 45, per_qty: false, keywords: 'changement,onu,modem,équipement,equipement,hx' },
|
||||
{ id: 'migration_tv', cat: 'base', label: 'Migration TV / boîtier', minutes: 45, per_qty: false, keywords: 'migration,tv,boîtier,boitier' },
|
||||
{ id: 'ata', cat: 'base', label: 'Activation téléphonie (ATA)', minutes: 45, per_qty: false, keywords: 'ata,téléphonie,telephonie' },
|
||||
{ id: 'enfoui', cat: 'addon', label: 'Drop enfoui (au lieu d’aérien)', minutes: 75, per_qty: false, keywords: 'enfoui,enterré,enterre,souterrain' },
|
||||
{ id: 'echelle', cat: 'addon', label: 'Aérien avec échelle / poteau', minutes: 30, per_qty: false, keywords: 'aérien,aerien,poteau,échelle,echelle' },
|
||||
{ id: 'dist_poteau', cat: 'addon', label: 'Distance poteau → maison > 60 m', minutes: 30, per_qty: false, keywords: '' },
|
||||
{ id: 'boitier_tv', cat: 'addon', label: 'Boîtier TV / STB supplémentaire', minutes: 30, per_qty: true, keywords: 'stb,boîtier tv,boitier tv' },
|
||||
{ id: 'mesh', cat: 'addon', label: "Point d'accès mesh supplémentaire", minutes: 20, per_qty: true, keywords: 'mesh,borne' },
|
||||
{ id: 'tel_ata', cat: 'addon', label: 'Ligne téléphonie / ATA ajoutée', minutes: 30, per_qty: true, keywords: '' },
|
||||
{ id: 'epissure', cat: 'addon', label: 'Épissure / fusion (par connecteur)', minutes: 20, per_qty: true, keywords: 'épissure,epissure,fusion' },
|
||||
{ id: 'wifi_complexe', cat: 'addon', label: 'Config Wifi complexe (multi-SSID/VLAN)', minutes: 30, per_qty: false, keywords: '' },
|
||||
{ id: 'tel_complexe', cat: 'addon', label: 'Téléphonie complexe (PBX/multi-lignes)', minutes: 30, per_qty: false, keywords: 'pbx' },
|
||||
{ id: 'test_otdr', cat: 'addon', label: 'Test / certification (OTDR, photos)', minutes: 20, per_qty: false, keywords: 'otdr,certification' },
|
||||
{ id: 'acces_difficile', cat: 'addon', label: 'Accès difficile (hauteur, vide sanitaire)', minutes: 30, per_qty: false, keywords: '' },
|
||||
{ id: 'tampon_client', cat: 'modifier', label: 'Tampon coordination client', minutes: 15, per_qty: false, keywords: '' },
|
||||
{ id: 'junior_install', cat: 'modifier', label: 'Tech junior sur install (pénalité)', minutes: 90, per_qty: false, keywords: '' },
|
||||
] }
|
||||
function readJobChar () { try { return JSON.parse(require('fs').readFileSync(JOBCHAR_PATH, 'utf8')) } catch (e) { return JSON.parse(JSON.stringify(JOBCHAR_SEED)) } }
|
||||
function writeJobChar (items) { try { require('fs').writeFileSync(JOBCHAR_PATH, JSON.stringify({ items, updated: nowFrappe() }, null, 2)); return true } catch (e) { return false } }
|
||||
// Durée estimée (minutes, hors transport) à partir des caractéristiques cochées : picks = [{id, qty}].
|
||||
function estimateMinutes (picks, items) {
|
||||
const by = {}; for (const i of (items || readJobChar().items)) by[i.id] = i
|
||||
let m = 0; for (const p of (picks || [])) { const it = by[p.id]; if (!it) continue; const mn = (it.learned_min != null ? it.learned_min : it.minutes) || 0; m += mn * (it.per_qty ? (Number(p.qty) || 1) : 1) }
|
||||
return Math.round(m)
|
||||
}
|
||||
// Auto-détection DÉTERMINISTE (mots-clés / regex, AUCUNE IA) des caractéristiques d'un job depuis son texte.
|
||||
function detectCharacteristics (job, items) {
|
||||
const strip = s => String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '')
|
||||
const text = strip([job.subject, job.legacy_detail, job.legacy_dept, job.job_type, job.service_type].join(' '))
|
||||
const hit = kw => String(kw || '').split(',').map(x => strip(x).trim()).filter(Boolean).some(k => new RegExp('\\b' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(text)) // limites de mots → pas de faux positif « ap » dans « après »
|
||||
const bases = items.filter(i => i.cat === 'base')
|
||||
let base = bases.find(b => hit(b.keywords))
|
||||
if (!base) { const jt = strip(job.job_type); base = bases.find(b => (jt.includes('install') && b.id === 'install_resid') || (jt.includes('repar') && b.id === 'repar_fibre') || (jt.includes('retrait') && b.id === 'retrait')) }
|
||||
const picks = base ? [{ id: base.id, qty: 1 }] : []
|
||||
for (const a of items.filter(i => i.cat === 'addon')) if (hit(a.keywords)) picks.push({ id: a.id, qty: 1 })
|
||||
return picks
|
||||
}
|
||||
function estimateForJob (job, items) {
|
||||
items = items || readJobChar().items
|
||||
const picks = detectCharacteristics(job, items); const by = {}; for (const i of items) by[i.id] = i
|
||||
return { minutes: estimateMinutes(picks, items), picks, labels: picks.map(p => (by[p.id] || {}).label).filter(Boolean) }
|
||||
}
|
||||
|
||||
// ── #56 Politique de créneaux offerts + holds temporaires ────────────────────
|
||||
// Persistée dans le même fichier que la politique de reprise (sous-objet `booking`),
|
||||
// éditée via /roster/policy (lib/roster-assistant.js). Appliquée ici à TOUTE source
|
||||
|
|
@ -498,6 +556,109 @@ async function handlePublicBooking (req, res, method, path, url) {
|
|||
return json(res, 404, { error: 'not found' })
|
||||
}
|
||||
|
||||
// ── APP TECHNICIEN : capture passive du temps via « checkpoints » (GPS geofence / scan / etc.) ──────────────
|
||||
// Token signé par job (HMAC, stateless, sans login ni champ DB). Voir docs/field-tech-app.md.
|
||||
const FIELD_SECRET = cfg.INTERNAL_TOKEN || cfg.ERP_SERVICE_TOKEN || 'targo-field-v1'
|
||||
const ON_SITE_M = Number(process.env.FIELD_ONSITE_RADIUS_M) || 250 // rayon « sur place » autour de l'adresse
|
||||
function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).update(String(name)).digest('hex').slice(0, 12); return Buffer.from(String(name)).toString('base64url') + '.' + h }
|
||||
function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
|
||||
function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
|
||||
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
|
||||
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
|
||||
function techFieldSign (name) { return fieldSign('T:' + name) }
|
||||
function techFieldVerify (t) { const v = fieldVerify(t); return (v && v.startsWith('T:')) ? v.slice(2) : null }
|
||||
function serveFieldApp (res) {
|
||||
try { let html = require('fs').readFileSync('/app/public/field-app.html', 'utf8'); html = html.split('__MAPBOX_TOKEN__').join(FIELD_MAPBOX).split('__HUB__').join(cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca'); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html) } catch (e) { res.writeHead(500); return res.end('app terrain indisponible') }
|
||||
}
|
||||
function savePhoto (jobName, dataUrl) {
|
||||
const m = /^data:image\/(\w+);base64,(.+)$/.exec(String(dataUrl || '')); if (!m) return null
|
||||
const buf = Buffer.from(m[2], 'base64'); if (!buf.length || buf.length > 6 * 1024 * 1024) return null
|
||||
const fs = require('fs'); const dir = '/app/uploads/field'; try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {}
|
||||
const fn = 'f_' + String(jobName).replace(/[^a-zA-Z0-9_-]/g, '') + '_' + Date.now() + '.' + (m[1] === 'png' ? 'png' : 'jpg')
|
||||
try { fs.writeFileSync(dir + '/' + fn, buf); return fn } catch (e) { return null }
|
||||
}
|
||||
async function handleFieldTech (req, res, method, path, url) {
|
||||
const token = url.searchParams.get('t') || url.searchParams.get('token') || ''
|
||||
if (path === '/field/job' && method === 'GET') {
|
||||
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'actual_start', 'actual_end', 'status'], limit: 1 })
|
||||
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
|
||||
return json(res, 200, { ok: true, job: { ...j, actual_minutes: minutesBetween(j.actual_start, j.actual_end) } })
|
||||
}
|
||||
if (path === '/field/checkpoint' && method === 'POST') {
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'latitude', 'longitude', 'actual_start', 'actual_end', 'completion_notes', 'status'], limit: 1 })
|
||||
const j = r && r[0]; if (!j) return json(res, 404, { ok: false, error: 'job introuvable' })
|
||||
const ts = nowFrappe()
|
||||
const lat = Number(b.lat), lon = Number(b.lon); const hasGps = isFinite(lat) && isFinite(lon)
|
||||
let dist = null
|
||||
if (hasGps && j.latitude != null && j.longitude != null && (Math.abs(+j.latitude) > 0.01)) dist = haversineM(+j.latitude, +j.longitude, lat, lon)
|
||||
const onSite = (b.type === 'geo_exit') || dist == null || dist <= ON_SITE_M // hors-zone ignoré pour la durée, gardé en note
|
||||
const patch = {}
|
||||
if (onSite) { if (!j.actual_start) patch.actual_start = ts; patch.actual_end = ts; if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress' }
|
||||
const note = `• ${ts} ${b.type || 'checkpoint'}${b.ref ? ' ' + String(b.ref).slice(0, 40) : ''}${hasGps ? ` · GPS ${lat.toFixed(5)},${lon.toFixed(5)}${b.acc ? ' ±' + Math.round(b.acc) + 'm' : ''}${dist != null ? ` · à ${dist}m` : ''}` : ''}${onSite ? '' : ' · HORS ZONE (ignoré durée)'}`
|
||||
patch.completion_notes = ((j.completion_notes || '') + '\n' + note).slice(-4000)
|
||||
const up = await retryWrite(() => erp.update('Dispatch Job', name, patch))
|
||||
const startTs = j.actual_start || (onSite ? ts : null)
|
||||
return json(res, up.ok ? 200 : 500, { ok: !!up.ok, on_site: onSite, distance_m: dist, minutes: minutesBetween(startTs, onSite ? ts : j.actual_end), actual_start: patch.actual_start || j.actual_start || '', actual_end: patch.actual_end || j.actual_end || '' })
|
||||
}
|
||||
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
|
||||
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
|
||||
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const dates = rangeDates(todayET(), 2) // aujourd'hui + demain
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', tech], ['scheduled_date', 'in', dates]], fields: ['name', 'subject', 'customer_name', 'address', 'latitude', 'longitude', 'scheduled_date', 'start_time', 'status', 'actual_start', 'actual_end', 'legacy_ticket_id', 'legacy_activation_url'], orderBy: 'scheduled_date asc, start_time asc', limit: 200 })
|
||||
const staffId = (String(tech).match(/(\d+)$/) || [])[1] || ''
|
||||
const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '' }))
|
||||
return json(res, 200, { ok: true, tech, jobs })
|
||||
}
|
||||
if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes'], limit: 1 }); const j = r && r[0]
|
||||
const note = `\n• ${nowFrappe()} 📷 photo ${fn}${b.note ? ' — ' + String(b.note).slice(0, 60) : ''}`
|
||||
await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) }))
|
||||
return json(res, 200, { ok: true, file: fn })
|
||||
}
|
||||
if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher)
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
const serial = String(b.serial || '').slice(0, 60), mac = String(b.mac || '').slice(0, 40), kind = String(b.kind || 'appareil').slice(0, 30)
|
||||
if (!serial && !mac) return json(res, 400, { ok: false, error: 'série ou MAC requis' })
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes', 'actual_start', 'status'], limit: 1 }); const j = r && r[0]
|
||||
const ts = nowFrappe(); const patch = { completion_notes: (((j && j.completion_notes) || '') + `\n• ${ts} 📦 ${kind}${serial ? ' SN ' + serial : ''}${mac ? ' MAC ' + mac : ''}`).slice(-4000), actual_end: ts }
|
||||
if (j && !j.actual_start) patch.actual_start = ts // un scan sur place = preuve de présence (démarre le chrono)
|
||||
if (j && (j.status === 'open' || j.status === 'assigned')) patch.status = 'In Progress'
|
||||
await retryWrite(() => erp.update('Dispatch Job', name, patch))
|
||||
return json(res, 200, { ok: true, serial, mac, kind, genieacs: 'à brancher par MAC' })
|
||||
}
|
||||
if (path === '/field/ts' && method === 'POST') { // webhook geofence natif Transistorsoft (autoSync) → checkpoints. Auto-authentifié : identifier = token signé du job.
|
||||
const b = await parseBody(req)
|
||||
const locs = Array.isArray(b.location) ? b.location : (b.location ? [b.location] : (Array.isArray(b) ? b : []))
|
||||
let applied = 0
|
||||
for (const L of locs) {
|
||||
const g = L && L.geofence; if (!g || !g.identifier) continue
|
||||
const name = fieldVerify(g.identifier); if (!name) continue
|
||||
const type = String(g.action).toUpperCase() === 'EXIT' ? 'geo_exit' : 'geo_enter'
|
||||
const c = (L.coords) || {}; const ts = nowFrappe()
|
||||
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['name', 'actual_start', 'completion_notes', 'status'], limit: 1 }); const j = r && r[0]; if (!j) continue
|
||||
const patch = { actual_end: ts, completion_notes: (((j.completion_notes) || '') + `\n• ${ts} ${type} (geofence natif)${c.latitude ? ` · GPS ${(+c.latitude).toFixed(5)},${(+c.longitude).toFixed(5)}` : ''}`).slice(-4000) }
|
||||
if (type === 'geo_enter' && !j.actual_start) patch.actual_start = ts
|
||||
if (j.status === 'open' || j.status === 'assigned') patch.status = 'In Progress'
|
||||
await retryWrite(() => erp.update('Dispatch Job', name, patch)).catch(() => {}); applied++
|
||||
}
|
||||
return json(res, 200, { ok: true, applied })
|
||||
}
|
||||
if (path === '/field/vision' && method === 'POST') { // photo d'étiquette → proxy IA Gemini → {brand,model,serial_number,mac_address}
|
||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||
if (!b.image) return json(res, 400, { ok: false, error: 'image requise' })
|
||||
try { const eq = await require('./vision').extractEquipment(String(b.image).replace(/^data:image\/[^;]+;base64,/, '')); return json(res, 200, { ok: true, ...eq }) }
|
||||
catch (e) { return json(res, 500, { ok: false, error: String(e.message || e) }) }
|
||||
}
|
||||
return json(res, 404, { ok: false, error: 'not found' })
|
||||
}
|
||||
// Génère le lien terrain d'un job (pour SMS/app/QR) — exposé via une route interne.
|
||||
function fieldLink (name) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + fieldSign(name) }
|
||||
function techFieldLink (techName) { return (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca') + '/field?t=' + techFieldSign(techName) }
|
||||
|
||||
// Stats par jour : effectif (techs distincts), heures TRAVAILLÉES, tickets dispatch.
|
||||
// La garde (on_call) = mise en disponibilité → exclue des heures travaillées.
|
||||
async function statsByDay (start, days) {
|
||||
|
|
@ -525,20 +686,23 @@ async function occupancyByTechDay (start, days) {
|
|||
const dates = rangeDates(start, days)
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]],
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id'], limit: 5000,
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end'], limit: 5000,
|
||||
})
|
||||
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
|
||||
const occChars = readJobChar().items // table additive (1 lecture) → durée EFFECTIVE estimée par job
|
||||
const m = {}
|
||||
for (const j of jobs) {
|
||||
if (!j.assigned_tech || !j.scheduled_date) continue
|
||||
const k = j.assigned_tech + '|' + j.scheduled_date
|
||||
const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] })
|
||||
const dur = Number(j.duration_h) || 0
|
||||
const estMin = estimateForJob(j, occChars).minutes
|
||||
const dur = estMin ? estMin / 60 : (Number(j.duration_h) || 0) // estimation additive (sinon duration_h)
|
||||
o.h += dur
|
||||
const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills)
|
||||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400) })
|
||||
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
|
||||
}
|
||||
// ordre = route_order manuel s'il existe, sinon priorité puis heure
|
||||
for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99)))
|
||||
|
|
@ -567,6 +731,56 @@ async function resolveTechName (techId) {
|
|||
return f.length ? f[0].name : null
|
||||
}
|
||||
|
||||
// ── CAPACITÉ par jour, découpée en segments AM / PM / Soir ──────────────────
|
||||
// Capacité d'un segment = Σ (recouvrement du quart de chaque tech assigné ce jour-là avec la plage du segment).
|
||||
// Utilisé = Σ (recouvrement des jobs PLACÉS (start_time) ce jour-là avec le segment).
|
||||
// Libre = capacité − utilisé (heures-tech restantes). due_h = Σ durée estimée des jobs DUS ce jour (placés ou non) ;
|
||||
// unplaced_h = part des dus non encore placés. overbooked = due_h > capacité totale du jour.
|
||||
const DAY_SEGMENTS = [
|
||||
{ key: 'am', label: 'AM', from: 8, to: 12 },
|
||||
{ key: 'pm', label: 'PM', from: 12, to: 17 },
|
||||
{ key: 'soir', label: 'Soir', from: 17, to: 21 },
|
||||
]
|
||||
const segOverlap = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
|
||||
const r1 = (x) => Math.round(x * 10) / 10
|
||||
async function capacityByDay (start, days) {
|
||||
const dates = rangeDates(start, days)
|
||||
const templates = await fetchTemplates()
|
||||
const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) }
|
||||
const asgs = await fetchAssignments(start, days) // Proposé + Publié = présence planifiée du tech
|
||||
const jobs = await erp.list('Dispatch Job', {
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]],
|
||||
fields: ['name', 'scheduled_date', 'start_time', 'duration_h', 'assigned_tech'], limit: 5000,
|
||||
})
|
||||
const out = {}
|
||||
for (const d of dates) out[d] = { date: d, segments: DAY_SEGMENTS.map(s => ({ key: s.key, label: s.label, cap: 0, used: 0, free: 0 })), cap_h: 0, used_h: 0, free_h: 0, due_h: 0, jobs_due: 0, unplaced_h: 0, techs: 0 }
|
||||
const techSeen = {}
|
||||
for (const a of asgs) {
|
||||
const o = out[a.date]; if (!o) continue
|
||||
const w = tpl[a.shift]; if (!w || !(w.e > w.s)) continue
|
||||
DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].cap += segOverlap(w.s, w.e, seg.from, seg.to) })
|
||||
const tk = a.date + '|' + a.tech; if (!techSeen[tk]) { techSeen[tk] = 1; o.techs++ }
|
||||
}
|
||||
for (const j of jobs) {
|
||||
const o = out[j.scheduled_date]; if (!o) continue
|
||||
const dur = Number(j.duration_h) || 0
|
||||
o.due_h += dur; o.jobs_due++
|
||||
const sh = j.start_time ? timeToH(j.start_time) : null
|
||||
if (sh != null && dur > 0) DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].used += segOverlap(sh, sh + dur, seg.from, seg.to) })
|
||||
else if (!j.assigned_tech) o.unplaced_h += dur // dû mais pas encore placé sur l'horaire
|
||||
}
|
||||
for (const d of dates) {
|
||||
const o = out[d]
|
||||
o.segments.forEach(s => { s.cap = r1(s.cap); s.used = r1(s.used); s.free = r1(s.cap - s.used) })
|
||||
o.cap_h = r1(o.segments.reduce((x, s) => x + s.cap, 0))
|
||||
o.used_h = r1(o.segments.reduce((x, s) => x + s.used, 0))
|
||||
o.free_h = r1(o.cap_h - o.used_h)
|
||||
o.due_h = r1(o.due_h); o.unplaced_h = r1(o.unplaced_h)
|
||||
o.overbooked = o.cap_h > 0 && o.due_h > o.cap_h
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function handle (req, res, method, path, url) {
|
||||
const qs = url.searchParams
|
||||
const start = qs.get('start')
|
||||
|
|
@ -609,6 +823,22 @@ async function handle (req, res, method, path, url) {
|
|||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { coverage: await coverage(start, days) })
|
||||
}
|
||||
if (path === '/roster/capacity' && method === 'GET') { // dispo restante par jour × segment (AM/PM/Soir)
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS })
|
||||
}
|
||||
if (path === '/roster/job-characteristics' && method === 'GET') return json(res, 200, { ok: true, ...readJobChar() }) // table additive de durées (éditable)
|
||||
if (path === '/roster/job-characteristics' && method === 'POST') { // sauvegarde du tableur inline
|
||||
const b = await parseBody(req); if (!Array.isArray(b.items)) return json(res, 400, { ok: false, error: 'items requis' })
|
||||
const clean = b.items.filter(i => i && i.id && i.label).map(i => ({ id: String(i.id), cat: ['base', 'addon', 'modifier'].includes(i.cat) ? i.cat : 'addon', label: String(i.label).slice(0, 80), minutes: Math.max(0, Math.round(Number(i.minutes) || 0)), per_qty: !!i.per_qty, keywords: String(i.keywords || '').slice(0, 160), learned_min: i.learned_min != null ? Math.round(Number(i.learned_min)) : null, samples: Number(i.samples) || 0 }))
|
||||
const ok = writeJobChar(clean); return json(res, ok ? 200 : 500, { ok, count: clean.length })
|
||||
}
|
||||
if (path === '/roster/tech-link' && method === 'GET') { // lien app PWA par tech (à copier / SMS) + son téléphone
|
||||
const tech = qs.get('tech'); if (!tech) return json(res, 400, { ok: false, error: 'tech requis' })
|
||||
const rows = await erp.list('Dispatch Technician', { filters: [['name', '=', tech]], fields: ['name', 'full_name', 'phone'], limit: 1 })
|
||||
const t = rows && rows[0]
|
||||
return json(res, 200, { ok: true, tech, name: (t && t.full_name) || tech, phone: (t && t.phone) || '', url: techFieldLink(tech) })
|
||||
}
|
||||
if (path === '/roster/stats' && method === 'GET') {
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { stats: await statsByDay(start, days) })
|
||||
|
|
@ -750,9 +980,14 @@ async function handle (req, res, method, path, url) {
|
|||
}
|
||||
// Jobs À ASSIGNER (non assignés) avec leur groupe/dépendances (parent_job, depends_on, step_order, chaîne On Hold).
|
||||
if (path === '/roster/unassigned-jobs' && method === 'GET') {
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech'], orderBy: 'modified desc', limit: 400 })
|
||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
|
||||
const jobs = (rows || []).filter(j => !j.assigned_tech) // non assignés
|
||||
for (const j of jobs) j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
|
||||
const chars = readJobChar().items // table additive (1 lecture)
|
||||
for (const j of jobs) {
|
||||
j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // skill explicite, sinon déduit du type → couleur
|
||||
const est = estimateForJob(j, chars) // auto-détection DÉTERMINISTE (mots-clés) → durée additive estimée
|
||||
j.est_min = est.minutes; j.est_labels = est.labels
|
||||
}
|
||||
await attachLocations(jobs)
|
||||
return json(res, 200, { jobs })
|
||||
}
|
||||
|
|
@ -797,6 +1032,32 @@ async function handle (req, res, method, path, url) {
|
|||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job })
|
||||
}
|
||||
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis via le sélecteur Mapbox Ops.
|
||||
if (path === '/roster/job/set-location' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const lat = Number(b.lat), lon = Number(b.lon)
|
||||
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return json(res, 400, { error: 'lat/lon invalides' })
|
||||
const patch = { latitude: lat, longitude: lon }
|
||||
if (b.address != null && String(b.address).trim() !== '') patch.address = String(b.address).slice(0, 140)
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, latitude: lat, longitude: lon })
|
||||
}
|
||||
// CHRONO (boucle de capture) : début/fin réels → durée réelle (carburant de l'apprentissage des durées par type×tech).
|
||||
if (path === '/roster/job/start' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const now = nowFrappe()
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { actual_start: now, actual_end: '', status: 'In Progress' }))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_start: now })
|
||||
}
|
||||
if (path === '/roster/job/finish' && method === 'POST') {
|
||||
const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
|
||||
const cur = await erp.list('Dispatch Job', { filters: [['name', '=', b.job]], fields: ['actual_start'], limit: 1 })
|
||||
const startTs = (cur && cur[0] && cur[0].actual_start) || null
|
||||
const now = nowFrappe()
|
||||
const patch = { actual_end: now, status: 'Completed' }; if (!startTs) patch.actual_start = now // borne si jamais démarré
|
||||
const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, actual_minutes: minutesBetween(startTs || now, now) })
|
||||
}
|
||||
// Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure
|
||||
// → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide).
|
||||
if (path === '/roster/backfill-start-times' && method === 'POST') {
|
||||
|
|
@ -1037,4 +1298,4 @@ async function handle (req, res, method, path, url) {
|
|||
return json(res, 404, { error: 'roster: route inconnue ' + path })
|
||||
}
|
||||
|
||||
module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
|
||||
module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
|
||||
|
|
|
|||
|
|
@ -79,21 +79,21 @@ const EQUIP_SCHEMA = {
|
|||
required: ['serial_number'],
|
||||
}
|
||||
|
||||
// Extraction équipement réutilisable (étiquette modem/ONU → marque/modèle/série/MAC). base64 SANS préfixe data:.
|
||||
async function extractEquipment (base64) {
|
||||
const parsed = await geminiVision(base64, EQUIP_PROMPT, EQUIP_SCHEMA)
|
||||
if (!parsed) return { serial_number: null, barcodes: [] }
|
||||
if (parsed.mac_address) parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
|
||||
if (parsed.serial_number) parsed.serial_number = parsed.serial_number.replace(/\s+/g, '').trim()
|
||||
log(`Vision equipment: brand=${parsed.brand} model=${parsed.model} sn=${parsed.serial_number} mac=${parsed.mac_address}`)
|
||||
return parsed
|
||||
}
|
||||
async function handleEquipment (req, res) {
|
||||
const body = await parseBody(req)
|
||||
const check = extractBase64(req, body, 'equipment')
|
||||
if (check.error) return json(res, check.status, { error: check.error })
|
||||
try {
|
||||
const parsed = await geminiVision(check.base64, EQUIP_PROMPT, EQUIP_SCHEMA)
|
||||
if (!parsed) return json(res, 200, { serial_number: null, barcodes: [] })
|
||||
if (parsed.mac_address) parsed.mac_address = parsed.mac_address.replace(/[:\-.\s]/g, '').toUpperCase()
|
||||
if (parsed.serial_number) parsed.serial_number = parsed.serial_number.replace(/\s+/g, '').trim()
|
||||
log(`Vision equipment: brand=${parsed.brand} model=${parsed.model} sn=${parsed.serial_number} mac=${parsed.mac_address}`)
|
||||
return json(res, 200, parsed)
|
||||
} catch (e) {
|
||||
log('Vision equipment error:', e.message)
|
||||
return json(res, 500, { error: 'Vision extraction failed: ' + e.message })
|
||||
}
|
||||
try { return json(res, 200, await extractEquipment(check.base64)) }
|
||||
catch (e) { log('Vision equipment error:', e.message); return json(res, 500, { error: 'Vision extraction failed: ' + e.message }) }
|
||||
}
|
||||
|
||||
// ─── Invoice / bill OCR ────────────────────────────────────────────────
|
||||
|
|
@ -249,4 +249,4 @@ async function handleFieldScan (req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, handleInvoice, extractField, handleFieldScan }
|
||||
module.exports = { handleBarcodes, extractBarcodes, handleEquipment, extractEquipment, handleInvoice, extractField, handleFieldScan }
|
||||
|
|
|
|||
148
services/targo-hub/public/field-app.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<!doctype html><html lang=fr><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover,maximum-scale=1">
|
||||
<title>Targo Tech</title>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.css" rel=stylesheet>
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.6.0/mapbox-gl.js"></script>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#0f172a;color:#e2e8f0}
|
||||
.wrap{max-width:640px;margin:0 auto;padding:12px;padding-bottom:80px}
|
||||
.card{background:#1e293b;border-radius:14px;padding:14px;margin-bottom:10px}
|
||||
h1{font-size:17px;margin:0 0 2px}.sub{color:#94a3b8;font-size:13px}
|
||||
.badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px}
|
||||
.b-todo{background:#334155;color:#cbd5e1}.b-run{background:#064e3b;color:#6ee7b7}.b-done{background:#1e3a8a;color:#bfdbfe}
|
||||
.job{display:flex;gap:10px;align-items:center;cursor:pointer}
|
||||
.map{height:230px;border-radius:10px;overflow:hidden;margin:8px 0}
|
||||
.big{font-size:16px;font-weight:700;padding:15px;border-radius:12px;text-align:center;border:0;width:100%;color:#fff}
|
||||
.start{background:#16a34a}.stop{background:#ea580c}.doneb{background:#1e40af}
|
||||
.acts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:10px}
|
||||
.acts a,.acts button{background:#334155;color:#e2e8f0;border:0;border-radius:10px;padding:12px 6px;font-size:13px;text-align:center;text-decoration:none;font-weight:600}
|
||||
.top{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||
.lnk{color:#60a5fa;text-decoration:none;font-size:13px}
|
||||
.geo{font-size:12px;color:#94a3b8;margin-top:6px}.warn{color:#fca5a5}
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.85);display:none;flex-direction:column;padding:14px;z-index:50}
|
||||
.modal video{width:100%;border-radius:10px;background:#000}
|
||||
.modal input{width:100%;padding:12px;border-radius:8px;border:1px solid #475569;background:#0f172a;color:#fff;font-size:15px;margin-top:8px}
|
||||
.toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:#0b3d2e;color:#6ee7b7;padding:10px 16px;border-radius:10px;font-size:13px;display:none;z-index:60}
|
||||
.hbtn{background:#334155;border:0;color:#e2e8f0;border-radius:8px;padding:8px 10px;font-size:14px}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class=wrap>
|
||||
<!-- LISTE -->
|
||||
<div id=list>
|
||||
<h1>Mes interventions <span class=sub id=techname></span></h1>
|
||||
<div id=jobs class=sub>Chargement…</div>
|
||||
</div>
|
||||
<!-- DÉTAIL -->
|
||||
<div id=detail style=display:none>
|
||||
<div class=top><button class=hbtn onclick=showList()>‹ Retour</button><span class=badge id=dbadge></span></div>
|
||||
<div class=card>
|
||||
<h1 id=dsub></h1><div class=sub id=dcust></div>
|
||||
<div class=sub id=daddr style=margin-top:4px></div>
|
||||
<div style=margin-top:6px>
|
||||
<a class=lnk id=ditin target=_blank>🧭 Itinéraire</a> <a class=lnk id=dsv target=_blank>👁 Street View</a>
|
||||
</div>
|
||||
<div class=map id=map></div>
|
||||
<div class=geo id=dgeo></div>
|
||||
<button id=chrono class="big start" onclick=toggleChrono()>📍 Je suis arrivé</button>
|
||||
<div class=acts>
|
||||
<a id=areply class=lnk style="background:#334155;border-radius:10px;padding:12px 6px;text-align:center" target=_blank>💬 Répondre</a>
|
||||
<button onclick=document.getElementById('photo').click()>📷 Photo</button>
|
||||
<button onclick=openScan()>📦 Appareil</button>
|
||||
</div>
|
||||
<input type=file id=photo accept=image/* capture=environment style=display:none onchange=sendPhoto(this)>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SCAN -->
|
||||
<div class=modal id=scan>
|
||||
<div class=top><button class=hbtn onclick=closeScan()>✕ Fermer</button><span class=sub>Scanner code-barre / saisir</span></div>
|
||||
<video id=cam playsinline></video>
|
||||
<button class=big style="background:#7c3aed;margin-top:8px" onclick=visionScan()>📸 Lire l'étiquette (IA)</button>
|
||||
<div class=sub id=vismsg style=margin-top:4px></div>
|
||||
<input type=file id=scanphoto accept=image/* capture=environment style=display:none onchange=visionFile(this)>
|
||||
<input id=serial placeholder="N° de série">
|
||||
<input id=mac placeholder="MAC (optionnel)">
|
||||
<button class="big doneb" style=margin-top:10px onclick=sendDevice()>Enregistrer l'appareil</button>
|
||||
</div>
|
||||
<div class=toast id=toast></div>
|
||||
<script>
|
||||
const HUB='__HUB__', MAPBOX='__MAPBOX_TOKEN__', T=new URLSearchParams(location.search).get('t')||'';
|
||||
const CAP=window.Capacitor, NATIVE=!!(CAP&&CAP.isNativePlatform&&CAP.isNativePlatform()), MLKIT=CAP&&CAP.Plugins&&CAP.Plugins.BarcodeScanning;
|
||||
const $=id=>document.getElementById(id); let JOBS=[],CUR=null,map=null,jobMk=null,meMk=null,watch=null,enteredZone=false;
|
||||
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2600)}
|
||||
function badge(j){return j.ended?'<span class="badge b-done">terminé'+(j.minutes!=null?' · '+j.minutes+'m':'')+'</span>':j.started?'<span class="badge b-run">en cours</span>':'<span class="badge b-todo">à faire</span>'}
|
||||
async function api(p,o){const r=await fetch(HUB+p,o);return r.json()}
|
||||
function dist(a1,o1,a2,o2){const R=6371000,t=Math.PI/180,dA=(a2-a1)*t,dO=(o2-o1)*t;const x=Math.sin(dA/2)**2+Math.cos(a1*t)*Math.cos(a2*t)*Math.sin(dO/2)**2;return Math.round(R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x)))}
|
||||
async function load(){
|
||||
const r=await api('/field/tech?t='+encodeURIComponent(T)).catch(()=>({}));
|
||||
if(r&&r.ok){JOBS=r.jobs||[];$('techname').textContent='· '+(r.tech||'');renderList();return}
|
||||
// sinon : token de job unique
|
||||
const j=await api('/field/job?t='+encodeURIComponent(T)).catch(()=>({}));
|
||||
if(j&&j.ok){JOBS=[{...j.job,token:T,start:'',started:!!j.job.actual_start,ended:!!j.job.actual_end,minutes:j.job.actual_minutes,lat:j.job.latitude,lon:j.job.longitude}];openDetail(0);return}
|
||||
$('jobs').innerHTML='<span class=warn>Lien invalide ou expiré.</span>';
|
||||
}
|
||||
function renderList(){
|
||||
if(!JOBS.length){$('jobs').innerHTML='Aucune intervention.';return}
|
||||
$('jobs').innerHTML='';
|
||||
JOBS.forEach((j,i)=>{const d=document.createElement('div');d.className='card job';d.onclick=()=>openDetail(i);
|
||||
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(j.subject||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(j.address||'')+'</div></div>'+badge(j);
|
||||
$('jobs').appendChild(d)});
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]))}
|
||||
function showList(){if(watch){navigator.geolocation.clearWatch(watch);watch=null}if(map){map.remove();map=null}$('detail').style.display='none';$('list').style.display='block';load()}
|
||||
function openDetail(i){
|
||||
CUR=JOBS[i];enteredZone=false;$('list').style.display='none';$('detail').style.display='block';
|
||||
$('dsub').textContent=CUR.subject||'Job';$('dcust').textContent=CUR.customer||'';$('daddr').textContent='📍 '+(CUR.address||'(adresse manquante)');
|
||||
$('dbadge').innerHTML=badge(CUR);
|
||||
const hasLL=CUR.lat&&Math.abs(+CUR.lat)>0.01;
|
||||
$('ditin').href=hasLL?('https://www.google.com/maps/dir/?api=1&destination='+CUR.lat+','+CUR.lon):'#';
|
||||
$('dsv').href=hasLL?('https://www.google.com/maps/@?api=1&map_action=pano&viewpoint='+CUR.lat+','+CUR.lon):'#';
|
||||
$('areply').href=CUR.reply||'#';$('areply').style.opacity=CUR.reply?1:.4;
|
||||
setChrono();initMap(hasLL);startWatch(hasLL);
|
||||
}
|
||||
function setChrono(){const b=$('chrono');if(CUR.ended){b.className='big doneb';b.textContent='✅ Terminé'+(CUR.minutes!=null?' · '+CUR.minutes+' min':'');b.disabled=true}
|
||||
else if(CUR.started){b.className='big stop';b.textContent='⏹ J\'ai terminé';b.disabled=false}
|
||||
else{b.className='big start';b.textContent='📍 Je suis arrivé';b.disabled=false}}
|
||||
async function toggleChrono(){
|
||||
let pos=null;try{pos=await getPos()}catch(e){}
|
||||
const type=CUR.started?'geo_exit':'geo_enter';
|
||||
const r=await checkpoint(type,pos);
|
||||
if(r&&r.ok){if(type==='geo_enter'){CUR.started=true}else{CUR.ended=true;CUR.minutes=r.minutes}setChrono();$('dbadge').innerHTML=badge(CUR);toast(type==='geo_enter'?'Arrivée enregistrée':'Terminé · '+(r.minutes||0)+' min')}
|
||||
}
|
||||
async function checkpoint(type,pos){const body={token:CUR.token,type};if(pos){body.lat=pos.coords.latitude;body.lon=pos.coords.longitude;body.acc=pos.coords.accuracy}
|
||||
return api('/field/checkpoint',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).catch(()=>({ok:false}))}
|
||||
function getPos(){return new Promise((res,rej)=>navigator.geolocation.getCurrentPosition(res,rej,{enableHighAccuracy:true,timeout:10000}))}
|
||||
function initMap(hasLL){mapboxgl.accessToken=MAPBOX;map=new mapboxgl.Map({container:'map',style:'mapbox://styles/mapbox/streets-v12',center:hasLL?[+CUR.lon,+CUR.lat]:[-73.9,45.18],zoom:hasLL?15:9});
|
||||
map.on('load',()=>{map.resize();if(hasLL){jobMk=new mapboxgl.Marker({color:'#ea580c'}).setLngLat([+CUR.lon,+CUR.lat]).addTo(map)}})}
|
||||
function startWatch(hasLL){if(!navigator.geolocation)return;
|
||||
watch=navigator.geolocation.watchPosition(p=>{const ll=[p.coords.longitude,p.coords.latitude];
|
||||
if(map){if(!meMk){meMk=new mapboxgl.Marker({color:'#38bdf8'}).setLngLat(ll).addTo(map)}else meMk.setLngLat(ll)}
|
||||
if(hasLL){const d=dist(+CUR.lat,+CUR.lon,p.coords.latitude,p.coords.longitude);$('dgeo').textContent='à '+d+' m de l\'adresse (±'+Math.round(p.coords.accuracy)+' m)';
|
||||
if(d<=250&&!enteredZone&&!CUR.started){enteredZone=true;CUR.started=true;setChrono();$('dbadge').innerHTML=badge(CUR);checkpoint('geo_enter',p);toast('Arrivée détectée (auto)')}}
|
||||
},e=>{$('dgeo').innerHTML='<span class=warn>Active la localisation pour le suivi auto</span>'},{enableHighAccuracy:true,maximumAge:15000,timeout:20000})}
|
||||
// PHOTO : redimensionne puis envoie en base64
|
||||
function sendPhoto(inp){const f=inp.files[0];if(!f)return;const img=new Image();const rd=new FileReader();
|
||||
rd.onload=()=>{img.onload=()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);
|
||||
const data=c.toDataURL('image/jpeg',0.7);api('/field/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,image:data})}).then(r=>toast(r&&r.ok?'📷 Photo envoyée':'Échec photo'))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
|
||||
// SCAN : BarcodeDetector (Android) sinon saisie manuelle ; en build natif, plugin MLKit prend le relais
|
||||
let scanStream=null,scanTimer=null;
|
||||
async function openScan(){$('scan').style.display='flex';$('serial').value='';$('mac').value='';$('vismsg').textContent='';
|
||||
if(MLKIT){$('cam').style.display='none';mlkitScan();return} // NATIF : MLKit on-device (instantané, hors-ligne) — pas de latence Gemini
|
||||
try{scanStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'environment'}});$('cam').srcObject=scanStream;$('cam').play();$('cam').style.display='block';
|
||||
if('BarcodeDetector'in window){const det=new BarcodeDetector();scanTimer=setInterval(async()=>{try{const codes=await det.detect($('cam'));if(codes[0]){const v=codes[0].rawValue;if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(v))$('mac').value=v;else $('serial').value=v;navigator.vibrate&&navigator.vibrate(80)}}catch(e){}},700)}
|
||||
}catch(e){$('cam').style.display='none'}}
|
||||
function closeScan(){if(scanTimer)clearInterval(scanTimer);if(scanStream){scanStream.getTracks().forEach(t=>t.stop());scanStream=null}$('scan').style.display='none'}
|
||||
// MLKit natif (on-device, hors-ligne, instantané) — via le plugin Capacitor @capacitor-mlkit/barcode-scanning
|
||||
async function mlkitScan(){try{$('vismsg').textContent='⚡ Scanner…';const r=await MLKIT.scan();const codes=(r&&r.barcodes)||[];
|
||||
if(codes.length){const v=codes[0].rawValue||codes[0].displayValue||'';if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(v))$('mac').value=v.replace(/[:-]/g,'').toUpperCase();else $('serial').value=v;$('vismsg').textContent='✓ '+v;navigator.vibrate&&navigator.vibrate(80)}
|
||||
else $('vismsg').textContent='Aucun code-barre — « Lire l\'étiquette (IA) » pour du texte'}catch(e){$('vismsg').textContent='Scan annulé'}}
|
||||
// Photo de l'étiquette → proxy IA Gemini (cross-plateforme, lit le texte imprimé, pas que les code-barres)
|
||||
function visionScan(){if(scanStream&&$('cam').videoWidth){const v=$('cam');const c=document.createElement('canvas');c.width=v.videoWidth;c.height=v.videoHeight;c.getContext('2d').drawImage(v,0,0);visionUpload(c.toDataURL('image/jpeg',0.7))}else $('scanphoto').click()}
|
||||
function visionFile(inp){const f=inp.files[0];if(!f)return;const img=new Image(),rd=new FileReader();rd.onload=()=>{img.onload=()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);visionUpload(c.toDataURL('image/jpeg',0.7))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
|
||||
async function visionUpload(data){$('vismsg').textContent='🔎 Lecture IA…';const r=await api('/field/vision',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,image:data})}).catch(()=>({ok:false}));
|
||||
if(r&&r.ok){if(r.serial_number)$('serial').value=r.serial_number;if(r.mac_address)$('mac').value=r.mac_address;$('vismsg').textContent='✓ '+([r.brand,r.model].filter(Boolean).join(' ')+(r.serial_number?' · SN '+r.serial_number:'')+(r.mac_address?' · MAC '+r.mac_address:'')||'lu, vérifie les champs');navigator.vibrate&&navigator.vibrate(80)}else $('vismsg').textContent='Échec lecture IA — saisis à la main'}
|
||||
async function sendDevice(){const serial=$('serial').value.trim(),mac=$('mac').value.trim();if(!serial&&!mac){toast('Série ou MAC requis');return}
|
||||
const r=await api('/field/device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,serial,mac})}).catch(()=>({ok:false}));
|
||||
if(r&&r.ok){CUR.started=true;setChrono();$('dbadge').innerHTML=badge(CUR);toast('📦 Appareil enregistré');closeScan()}else toast('Échec')}
|
||||
load();
|
||||
</script></body></html>
|
||||
|
|
@ -134,6 +134,8 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
|
||||
// Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
|
||||
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
|
||||
// App technicien — capture passive du temps (checkpoints GPS/scan), token signé par job, PUBLIC (pas de SSO).
|
||||
if (path === '/field' || path.startsWith('/field/')) return require('./lib/roster').handleFieldTech(req, res, method, path, url)
|
||||
// Portail self-service d'abonnement (staging) — page + submit, PUBLIC.
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return require('./lib/signup').handle(req, res, method, path)
|
||||
// Boutique matériel (page modèle, staging) — page + (à venir) catalogue ERPNext, PUBLIC.
|
||||
|
|
|
|||