Compare commits

..

No commits in common. "feat/dispatch-legacy-writeback" and "main" have entirely different histories.

789 changed files with 11673 additions and 75733 deletions

View File

@ -1,49 +0,0 @@
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

View File

@ -1,29 +0,0 @@
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.

8
.gitignore vendored
View File

@ -5,10 +5,6 @@
apps/**/.env
apps/**/.env.local
# Legacy migration exports — customer PII, never commit (purged from history 2026-06-16)
scripts/migration/genieacs-export/*.tsv
scripts/migration/genieacs-export/devices-*.json
# Dependencies
node_modules/
@ -55,7 +51,3 @@ services/targo-hub/templates/*.bak-*.html
# Legacy refresh creds (prod-only, never commit)
.refresh.env
**/.refresh.env
# Python bytecode
__pycache__/
*.pyc

View File

@ -30,9 +30,9 @@ echo "==> Installing dependencies..."
npm ci --silent
echo "==> Building PWA (base=/ for portal.gigafibre.ca)..."
# Do NOT bake an ERPNext token into the portal bundle. Any direct ERPNext
# calls (catalog, invoice PDFs) must go through a server-side token proxy.
DEPLOY_BASE=/ npx quasar build -m pwa
# VITE_ERP_TOKEN is still needed by a few API calls that hit ERPNext
# directly (catalog, invoice PDFs). TODO: migrate these behind the hub.
VITE_ERP_TOKEN="***ERP-TOKEN-REDACTED-20260616***" DEPLOY_BASE=/ npx quasar build -m pwa
if [ "${1:-}" = "local" ]; then
echo ""

View File

@ -3,17 +3,8 @@
*/
const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
// Attach the customer's magic-link JWT (stored by the customer store) so the hub can
// authorize per-customer access to balance/methods/invoice/portal/toggle-ppa — see
// stores/customer.js TOKEN_KEY (kept in sync).
function authHeaders (base = {}) {
let t = ''
try { t = sessionStorage.getItem('targo_portal_jwt') || '' } catch { t = '' }
return t ? { ...base, Authorization: 'Bearer ' + t } : base
}
async function hubGet (path) {
const r = await fetch(HUB + path, { headers: authHeaders() })
const r = await fetch(HUB + path)
if (!r.ok) {
const data = await r.json().catch(() => ({}))
throw new Error(data.error || `Hub ${r.status}`)
@ -24,7 +15,7 @@ async function hubGet (path) {
async function hubPost (path, body) {
const r = await fetch(HUB + path, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await r.json().catch(() => ({}))

View File

@ -2,10 +2,6 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getPortalUser } from 'src/api/portal'
// sessionStorage key holding the raw magic-link JWT for the current tab session.
// Read by src/api/payments.js to authorize /payments/* calls — keep the two in sync.
const TOKEN_KEY = 'targo_portal_jwt'
/**
* Customer session store.
*
@ -64,20 +60,17 @@ export const useCustomerStore = defineStore('customer', () => {
const email = ref('')
const customerId = ref('')
const customerName = ref('')
const token = ref('')
const loading = ref(true)
const error = ref(null)
function hydrateFromToken () {
const t = readTokenFromLocation()
if (!t) return false
const payload = decodeJwtPayload(t)
const token = readTokenFromLocation()
if (!token) return false
const payload = decodeJwtPayload(token)
if (!payload || payload.scope !== 'customer') return false
customerId.value = payload.sub
customerName.value = payload.name || payload.sub
email.value = payload.email || ''
token.value = t
try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* private mode */ }
stripTokenFromUrl()
return true
}
@ -89,24 +82,6 @@ export const useCustomerStore = defineStore('customer', () => {
// Path 1: magic-link token in URL
if (hydrateFromToken()) return
// Restore a prior magic-link session (sessionStorage) so a page reload keeps
// both the identity AND the raw token we send on /payments/* calls.
if (!customerId.value) {
let saved = ''
try { saved = sessionStorage.getItem(TOKEN_KEY) || '' } catch { saved = '' }
if (saved) {
const payload = decodeJwtPayload(saved)
if (payload && payload.scope === 'customer') {
token.value = saved
customerId.value = payload.sub
customerName.value = payload.name || payload.sub
email.value = payload.email || ''
return
}
try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* expired/invalid */ }
}
}
// Already authenticated in this tab? (e.g. subsequent nav)
if (customerId.value) return
@ -127,10 +102,8 @@ export const useCustomerStore = defineStore('customer', () => {
email.value = ''
customerId.value = ''
customerName.value = ''
token.value = ''
error.value = null
try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* ignore */ }
}
return { email, customerId, customerName, token, loading, error, init, hydrateFromToken, clear }
return { email, customerId, customerName, loading, error, init, hydrateFromToken, clear }
})

View File

@ -1,22 +0,0 @@
# 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

View File

@ -1,54 +0,0 @@
# 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.

View File

@ -1,36 +0,0 @@
# 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`.

View File

@ -1,19 +0,0 @@
# 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"]

View File

@ -1,106 +0,0 @@
# 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
```

View File

@ -1,101 +0,0 @@
# 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

View File

@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep

View File

@ -1,69 +0,0 @@
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")
}

View File

@ -1,23 +0,0 @@
// 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()
}

View File

@ -1,21 +0,0 @@
# 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

View File

@ -1,26 +0,0 @@
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());
}
}

View File

@ -1,41 +0,0 @@
<?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>

View File

@ -1,5 +0,0 @@
package ca.targo.field;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,34 +0,0 @@
<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>

View File

@ -1,170 +0,0 @@
<?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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1,12 +0,0 @@
<?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>

View File

@ -1,5 +0,0 @@
<?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>

View File

@ -1,5 +0,0 @@
<?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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -1,7 +0,0 @@
<?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>

View File

@ -1,22 +0,0 @@
<?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>

View File

@ -1,5 +0,0 @@
<?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>

View File

@ -1,18 +0,0 @@
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);
}
}

View File

@ -1,33 +0,0 @@
// 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
}

View File

@ -1,18 +0,0 @@
// 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')

View File

@ -1,22 +0,0 @@
# 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

View File

@ -1,7 +0,0 @@
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

View File

@ -1,248 +0,0 @@
#!/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" "$@"

View File

@ -1,92 +0,0 @@
@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

View File

@ -1,5 +0,0 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View File

@ -1,19 +0,0 @@
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'
}

View File

@ -1,9 +0,0 @@
{
"appId": "ca.targo.field",
"appName": "Targo Tech",
"webDir": "www",
"server": { "androidScheme": "https" },
"plugins": {
"Geolocation": { "permissions": ["location"] }
}
}

View File

@ -1,14 +0,0 @@
#!/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}"

View File

@ -1,25 +0,0 @@
<!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>

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
{
"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"
}
}

View File

@ -1,58 +0,0 @@
// 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()

View File

@ -1,3 +0,0 @@
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 } })

View File

@ -1,6 +0,0 @@
# Production builds MUST NOT bake the ERPNext token into the JS bundle.
# In prod the ops-frontend nginx injects `Authorization: token …` server-side
# (see apps/ops/infra/nginx.conf). The client calls same-origin /ops/api and never
# holds a token. Leaving this empty keeps the admin key out of the shipped bundle.
# Local dev still uses apps/ops/.env (which may set VITE_ERP_TOKEN for `quasar dev`).
VITE_ERP_TOKEN=

1
apps/ops/.gitignore vendored
View File

@ -1 +0,0 @@
public/tiles/

View File

@ -150,11 +150,7 @@ createQuasarApp(createApp, quasarUserOptions)
return Promise[ method ]([
import('boot/net-timeout'),
import('boot/pinia'),
import('boot/tooltip-ux')
import('boot/pinia')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')

View File

@ -17,5 +17,5 @@ import {Notify,Loading,LocalStorage,Dialog} from 'quasar'
export default { config: {"brand":{"primary":"#16a34a","positive":"#16a34a","negative":"#ef4444","warning":"#f59e0b","info":"#2563eb"}},plugins: {Notify,Loading,LocalStorage,Dialog} }
export default { config: {},plugins: {Notify,Loading,LocalStorage,Dialog} }

View File

@ -62,29 +62,6 @@ else
rm -f /tmp/ops-build.tar.gz
# ── Render nginx.conf from the versioned template, injecting tokens SERVER-SIDE ──
# Tokens live only on the server (/opt/targo-hub/.env): ERP_TOKEN = ERPNext service account
# (hub-service@targo.ca, NOT Administrator) · HUB_SERVICE_TOKEN = hub Bearer. They are never
# in the repo, never in the JS bundle. This makes the conf reproducible (fixes the past
# "nginx.conf deleted from host → ops down on restart" landmine) and rotation a one-command redeploy.
echo "==> Rendering nginx.conf (server-side token injection)..."
scp -i "$SSH_KEY" infra/nginx.conf "$SERVER":/tmp/ops-nginx.tmpl
ssh -i "$SSH_KEY" "$SERVER" '
set -e
ERP=$(grep -E "^ERP_TOKEN=" /opt/targo-hub/.env | cut -d= -f2-)
HUB=$(grep -E "^HUB_SERVICE_TOKEN=" /opt/targo-hub/.env | cut -d= -f2-)
[ -z "$HUB" ] && HUB=$(grep -E "^HUB_TOKEN=" /opt/targo-hub/.env | cut -d= -f2-)
if [ -z "$ERP" ] || [ -z "$HUB" ]; then echo "ABORT: ERP_TOKEN/HUB token missing in /opt/targo-hub/.env"; exit 1; fi
sed -e "s#__ERP_API_TOKEN__#${ERP}#g" -e "s#__HUB_TOKEN__#${HUB}#g" /tmp/ops-nginx.tmpl > '"$DEST"'/nginx.conf
rm -f /tmp/ops-nginx.tmpl
# validate against a throwaway nginx before touching the live container
docker run --rm -v '"$DEST"'/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:alpine nginx -t
# restart re-binds the file (avoids stale //deleted inode); ~1s blip
docker restart ops-frontend >/dev/null
sleep 2
docker exec ops-frontend sh -c "wget -qO- http://127.0.0.1/api/method/frappe.auth.get_logged_user 2>/dev/null" | head -c 80; echo
'
echo ""
echo "Done! Targo Ops ($BUILD_MODE): https://erp.gigafibre.ca/ops/"
fi

View File

@ -6,52 +6,23 @@ server {
resolver 127.0.0.11 valid=30s;
# Return Authentik user identity (email injected by Traefik forward-auth)
location = /auth/whoami {
default_type application/json;
return 200 '{"email":"$http_x_authentik_email","username":"$http_x_authentik_username","groups":"$http_x_authentik_groups"}';
}
# ERPNext API proxy token injected SERVER-SIDE at deploy (never in the JS bundle, never committed).
# deploy.sh fills the placeholder below from the server's /opt/targo-hub/.env ERP_TOKEN
# (the dedicated service account hub-service@targo.ca NOT Administrator). Rotate = re-run deploy.sh.
# ERPNext API proxy token injected server-side (never in JS bundle)
# To rotate: edit this file + docker restart ops-frontend
location /api/ {
proxy_pass https://erp.gigafibre.ca;
proxy_ssl_verify off;
proxy_set_header Host erp.gigafibre.ca;
proxy_set_header Authorization "token __ERP_API_TOKEN__";
proxy_set_header Authorization "token ***ERP-TOKEN-REDACTED-20260616***";
proxy_set_header X-Authentik-Email $http_x_authentik_email;
proxy_set_header X-Authentik-Username $http_x_authentik_username;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# Ollama Vision API proxy (kept for legacy vision paths; primary OCR goes through the hub)
location /ollama/ {
set $ollama_upstream http://ollama:11434;
proxy_pass $ollama_upstream/;
proxy_set_header Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 20m;
}
# targo-hub proxy service token injected SERVER-SIDE at deploy (never bundled/committed).
# deploy.sh fills the placeholder below from the server's /opt/targo-hub/.env HUB_SERVICE_TOKEN.
location /hub/ {
proxy_pass https://msg.gigafibre.ca/;
proxy_ssl_verify off;
proxy_set_header Host msg.gigafibre.ca;
proxy_set_header Authorization "Bearer __HUB_TOKEN__";
proxy_set_header X-Authentik-Email $http_x_authentik_email;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 3600s;
client_max_body_size 8m; # pièces jointes événement (image/PDF base64, ~5.5m pour 4m de binaire)
}
# NOTE: Ollama Vision proxy removed 2026-04-22 invoice OCR and all
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
# See docs/features/vision-ocr.md. The hub handles CORS + rate-limit, so no
# nginx pass-through is needed here.
# SPA fallback all routes serve index.html
location / {

View File

@ -1,14 +0,0 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"paths": { "src/*": ["./src/*"] },
"entry": [
"quasar.config.js",
"src/App.vue",
"src/router/index.js",
"src/boot/*.js",
"src/modules/**/routes.js"
],
"project": ["src/**/*.{js,vue}"],
"ignore": [".quasar/**", "src-pwa/**", "dist/**"],
"ignoreDependencies": []
}

File diff suppressed because it is too large Load Diff

View File

@ -7,28 +7,19 @@
"scripts": {
"dev": "quasar dev",
"build": "quasar build -m pwa",
"lint": "eslint --ext .js,.vue ./src",
"knip": "npx --yes knip"
"lint": "eslint --ext .js,.vue ./src"
},
"dependencies": {
"@quasar/extras": "^1.16.12",
"@twilio/voice-sdk": "^2.18.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"chart.js": "^4.5.1",
"cytoscape": "^3.33.2",
"dompurify": "^3.4.11",
"grapesjs": "^0.22.16",
"grapesjs-mjml": "^1.0.8",
"grapesjs-preset-newsletter": "^1.0.2",
"hy-vue-gantt": "^5.2.1",
"idb-keyval": "^6.2.1",
"lucide-vue-next": "^1.0.0",
"maplibre-gl": "^5.24.0",
"pinia": "^2.1.7",
"pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0",
"quasar": "^2.16.10",
"sip.js": "^0.21.2",
"vue": "^3.4.21",

View File

@ -1,17 +1,9 @@
/* eslint-env node */
const { configure } = require('quasar/wrappers')
// ⚠️ HARNAIS D'APERÇU LOCAL UNIQUEMENT — À RETIRER AVANT TOUT BUILD PROD.
// Lit le jeton hub depuis .env.local (gitignored) et l'injecte CÔTÉ PROXY (jamais dans le bundle client).
let _HUB_TOKEN = ''
try {
const _m = require('fs').readFileSync(__dirname + '/.env.local', 'utf8').match(/^HUB_TOKEN=(.+)$/m)
_HUB_TOKEN = _m ? _m[1].trim() : ''
} catch (e) { /* pas de .env.local → proxy /hub inactif */ }
module.exports = configure(function () {
return {
boot: ['net-timeout', 'pinia', 'tooltip-ux'],
boot: ['pinia'],
css: ['app.scss', 'tech.scss'],
@ -19,7 +11,7 @@ module.exports = configure(function () {
build: {
target: {
browser: ['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14'], // es2020+/safari14 : MapLibre GL v5 utilise des littéraux BigInt (0n)
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node20',
},
vueRouterMode: 'hash',
@ -42,20 +34,11 @@ module.exports = configure(function () {
target: 'https://erp.gigafibre.ca',
changeOrigin: true,
},
// ⚠️ HARNAIS APERÇU LOCAL — /hub → hub live, jeton injecté côté proxy (jamais bundlé). À RETIRER avant build prod.
'/hub': {
target: 'https://msg.gigafibre.ca',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/hub/, ''),
headers: { Authorization: 'Bearer ' + _HUB_TOKEN, 'X-Authentik-Email': 'louis@targo.ca' },
},
},
},
framework: {
// Aligne les couleurs Quasar sur la palette de marque (vert). primary = accent (--ops-accent).
// Après migration color="indigo-6/7"→"primary", tout l'accent devient vert d'un coup.
config: { brand: { primary: '#16a34a', positive: '#16a34a', negative: '#ef4444', warning: '#f59e0b', info: '#2563eb' } },
config: {},
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
},

View File

@ -1,58 +1,11 @@
<template>
<router-view />
<!-- « FAIL-LOUD » : les permissions OPS n'ont pas chargé (mauvais compte / identité Authentik non transmise).
On affiche un message CLAIR avec le compte réellement reçu, au lieu d'une coquille vide (menu blanc) qu'on
prenait à tort pour un bug de token/session. Inerte en dev local (profil permissif). -->
<div v-if="showAccessError" class="ops-access-error">
<div class="ops-access-card">
<q-icon name="person_off" size="44px" color="negative" />
<div class="text-h6 q-mt-sm">Accès OPS indisponible</div>
<div class="text-body2 text-grey-7 q-mt-sm" style="max-width:400px;line-height:1.5">
<template v-if="authIdentity">
OPS n'a pas pu charger les permissions de <b>{{ authIdentity }}</b> (le compte y a pourtant droit).
<div class="q-mt-sm">Souvent une <b>extension</b> (bloqueur de pub/vie privée) ou un proxy qui bloque la requête
<code>/auth/permissions</code> : essaie une <b>fenêtre privée</b> ou un <b>autre navigateur</b>.</div>
</template>
<template v-else>
Authentik n'a pas transmis ton identité (session incomplète). Reconnecte-toi via
<b>erp.gigafibre.ca/ops</b>.
</template>
<div v-if="permsError" class="text-caption text-negative q-mt-sm" style="font-family:monospace">Détail : {{ permsError }}</div>
</div>
<div class="row q-gutter-sm q-mt-md justify-center">
<q-btn outline no-caps color="grey-8" icon="logout" label="Changer de compte" @click="auth.doLogout()" />
<q-btn unelevated no-caps color="primary" icon="refresh" label="Recharger" @click="reload" />
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { onMounted } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { loadSavedTheme } from 'src/composables/useTheme'
import { startSessionKeepAlive } from 'src/composables/useSessionKeepAlive'
loadSavedTheme() // applique le thème enregistré (couleurs --ops-*) dès le démarrage
const auth = useAuthStore()
const { isLoaded, loading: permsLoading, error: permsError } = usePermissions()
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// PROD uniquement : une fois le contrôle de session ET le chargement des permissions terminés, si aucune
// permission n'a chargé accès indisponible (mauvais compte / identité vide) overlay clair.
const showAccessError = computed(() => !isLocal && !auth.loading && !permsLoading.value && !isLoaded.value)
// Le compte réellement transmis à OPS (courriel whoami) clé du diagnostic : « mauvais compte ? ».
const authIdentity = computed(() => (auth.user && auth.user !== 'authenticated') ? auth.user : '')
function reload () { window.location.reload() }
onMounted(() => {
auth.checkSession()
startSessionKeepAlive() // garde la session Authentik vivante (ping /auth/whoami /4 min + au retour d'onglet)
})
onMounted(() => auth.checkSession())
</script>
<style>
.ops-access-error { position: fixed; inset: 0; z-index: 99999; display: flex; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.55); backdrop-filter: blur(2px); }
.ops-access-card { background: #fff; border-radius: 14px; padding: 28px 32px; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); display: flex; flex-direction: column; align-items: center; max-width: 92vw; }
</style>

View File

@ -27,6 +27,3 @@ export const campingsUpsert = (body) => jpost('/address/conformity/campings', bo
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
export const conformityApplyArea = () => jpost('/address/conformity/apply-area', {})
// Géocodage INVERSE (coord → adresse) via notre RQA locale (plus de Nominatim externe).
export const reverse = (lat, lon) => jget('/address/reverse?lat=' + encodeURIComponent(lat) + '&lon=' + encodeURIComponent(lon))

View File

@ -4,21 +4,9 @@ import { BASE_URL } from 'src/config/erpnext'
// Only needed for local dev (VITE_ERP_TOKEN in .env).
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
const isLocalHost = () => window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
// Nom d'utilisateur Authentik renvoyé par le dernier whoami — repli de recherche de permissions côté hub
// quand le courriel ne correspond à aucun compte OPS provisionné (voir stores/auth.js → loadPermissions).
let _authUsername = ''
export function getAuthUsername () { return _authUsername }
// Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
let _lastReload = 0
function _reauth (status) {
// DEV local (pas d'Authentik devant) : un 401 ERPNext ne doit PAS déclencher un rechargement en boucle.
// On renvoie le 401 et l'UI affiche ses états vides. En prod (Authentik) le comportement est inchangé.
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return new Response('{}', { status: status || 401 })
}
const now = Date.now()
if (now - _lastReload > 20000) {
_lastReload = now
@ -36,23 +24,18 @@ export async function authFetch (url, opts = {}) {
try {
res = await fetch(url, opts)
} catch (e) {
// Coupure réseau transitoire (backend qui redémarre) OU timeout du garde net (session lente) → 1 retry court
// Coupure réseau transitoire (ex: backend qui redémarre) → 1 retry court avant d'abandonner
await new Promise(r => setTimeout(r, 800))
try {
res = await fetch(url, opts)
} catch (e2) {
// 2e échec (réseau/timeout) : NE PAS planter l'appelant ni boucler sur reload → réponse vide, l'app reste réactive
return new Response('{}', { status: 504 })
}
}
// Détection « session Authentik expirée » (la cause du "il faut recharger") :
// - 401/403 explicites
// - réponse redirigée vers l'IdP (auth.targo.ca / /if/flow/)
// - page HTML de login renvoyée à la place du JSON attendu sur un endpoint app (/api, /auth, /hub)
// - page HTML de login renvoyée à la place du JSON attendu sur un /api/
const ct = (res.headers && res.headers.get && res.headers.get('content-type')) || ''
const redirectedToIdp = res.redirected && /auth\.targo\.ca|\/if\/flow\//.test(res.url || '')
const htmlForApi = res.status === 200 && ct.includes('text/html') && /\/(api|auth|hub)\//.test(String(url))
const htmlForApi = res.status === 200 && ct.includes('text/html') && /\/api\//.test(String(url))
if (res.status === 401 || res.status === 403 || redirectedToIdp || htmlForApi) {
return _reauth(res.status)
}
@ -60,19 +43,12 @@ export async function authFetch (url, opts = {}) {
}
export async function getLoggedUser () {
// ⚠️ APERÇU LOCAL UNIQUEMENT : identité forcée via VITE_DEV_USER (gardé localhost + env). `import.meta.env.DEV`
// vaut false en build prod → esbuild élimine toute cette branche (et la valeur inlinée) du bundle livré.
if (import.meta.env.DEV && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && import.meta.env.VITE_DEV_USER) return import.meta.env.VITE_DEV_USER
// First try nginx whoami endpoint (returns Authentik email header)
try {
const res = await fetch(BASE_URL + '/auth/whoami')
if (res.ok) {
const data = await res.json()
_authUsername = data.username || '' // mémorisé pour le repli par username (recherche de permissions)
if (data.email && data.email !== '') return data.email
// whoami joignable mais courriel VIDE en prod = identité Authentik non transmise → renvoyer '' (pas de repli
// trompeur sur le propriétaire du jeton ERP « Administrator »). App.vue affiche alors un message clair « fail-loud ».
if (!isLocalHost()) return ''
}
} catch {}
// Fallback: ask ERPNext (returns API token owner, usually "Administrator")
@ -88,9 +64,5 @@ export async function getLoggedUser () {
}
export async function logout () {
// Déconnexion Authentik PUIS retour sur OPS : le flow d'invalidation honore `?next=` → il renvoie vers OPS,
// qui (non authentifié) redéclenche le login Authentik AVEC chemin de retour → après login on revient sur OPS,
// au lieu d'atterrir sur la page par défaut d'Authentik (/if/user/#/library).
const back = window.location.origin + (import.meta.env.BASE_URL || '/') // ex. https://erp.gigafibre.ca/ops/
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/?next=' + encodeURIComponent(back)
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
}

View File

@ -1,35 +0,0 @@
/**
* api/billing.js Client for Hub /billing approval endpoints (P1).
*
* Porte d'approbation de facturation : lister les installations complétées en
* attente, prévisualiser le prorata (LECTURE SEULE), approuver (= active le
* service ; la facturation reste gatée côté hub par PRORATION_WRITE).
*/
import { HUB_URL } from 'src/config/hub'
async function hubFetch (path, { method = 'GET', body } = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(`${HUB_URL}${path}`, opts)
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : {} } catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
if (!res.ok) { const err = new Error(data.error || `HTTP ${res.status}`); err.status = res.status; throw err }
return data
}
// Activations en attente d'approbation (enrichies client + adresse).
export function getApprovals () {
return hubFetch('/billing/approvals').then(r => r.pending || [])
}
// Aperçu prorata consolidé pour un job (LECTURE SEULE — aucune facture créée).
export function getApprovalPreview (job) {
return hubFetch(`/billing/approvals/preview?job=${encodeURIComponent(job)}`)
}
// Approuve → active les abonnements. L'approbateur est attribué côté serveur
// (en-tête x-authentik-email). La facturation reste gatée (PRORATION_WRITE).
export function approveActivation (job) {
return hubFetch('/billing/approvals/approve', { method: 'POST', body: { job } })
}

View File

@ -69,14 +69,6 @@ export function sendCampaign (id) {
})
}
// Duplicate a campaign as a fresh draft (id "_2", recipients reset to pending).
// Returns the new draft campaign so the caller can open it for review/send.
export function cloneCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}/clone`, {
method: 'POST',
})
}
// Permanent deletion — removes the JSON on the hub. Used for clearing
// test campaigns from the list. Giftbit shortlinks are unaffected.
export function deleteCampaign (id) {
@ -92,37 +84,6 @@ export function listGifts () {
return hubFetch('/campaigns/gifts').then(r => r.gifts || [])
}
// Pool de liens-cadeaux récupérés (jamais cliqués) prêts à réallouer à d'autres clients.
export function getGiftPool () {
return hubFetch('/campaigns/gifts/pool')
}
// Récupère les liens non cliqués d'une campagne vers le pool (dry_run par défaut = aperçu).
export function reclaimGifts ({ campaign_id, dry_run = true, revoke_live = true }) {
return hubFetch('/campaigns/gifts/reclaim', { method: 'POST', body: { campaign_id, dry_run, revoke_live } })
}
// Envoi unitaire d'une récompense depuis le pool vers un client (dry_run pour aperçu).
export function rewardSend (body) {
return hubFetch('/campaigns/reward-send', { method: 'POST', body })
}
// Récompenses déjà préparées/envoyées à un client (pour la fiche : voir / copier le lien).
export function rewardByCustomer (customer_id, email) {
const q = customer_id ? 'customer_id=' + encodeURIComponent(customer_id) : 'email=' + encodeURIComponent(email || '')
return hubFetch('/campaigns/reward/by-customer?' + q)
}
// Supprime un brouillon / révoque une récompense non cliquée (remet le lien en réserve ; refusé si déjà ouverte).
export function revokeRewardByToken (gift_token) {
return hubFetch('/campaigns/reward/revoke', { method: 'POST', body: { gift_token } })
}
// Révoque une récompense déjà envoyée + remet le lien Giftbit au pool (refusé si déjà cliqué/redirigé).
export function revokeReward (gift_url) {
return hubFetch('/campaigns/gifts/revoke-reward', { method: 'POST', body: { gift_url } })
}
// Kill switch — manually expire a single recipient's wrapper token so
// the underlying Giftbit URL becomes reassignable before the natural
// expiry date.
@ -231,10 +192,10 @@ export function translateTemplate (srcName, targetName, { override = false } = {
// Send ONE rendered email to a specific address for visual QA.
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
export function testSendTemplate (name, { to, vars, from, subject, via } = {}) {
export function testSendTemplate (name, { to, vars, from, subject } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
method: 'POST',
body: { to, vars, from, subject, via },
body: { to, vars, from, subject },
})
}

View File

@ -3,30 +3,8 @@
// Swap BASE_URL in config/erpnext.js to change the target server.
// ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { authFetch } from './auth'
// Créneaux disponibles (hub : gap-finding + tampon de route + classement) pour un nouveau job à une adresse.
// payload = { duration_h, latitude, longitude, after_date?, limit? } → [{ tech_id, tech_name, date, start_time, end_time, travel_min, distance_km, reasons[] }]
export async function suggestSlots (payload) {
const res = await fetch(`${HUB_URL}/dispatch/suggest-slots`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}),
})
if (!res.ok) throw new Error('suggest-slots ' + res.status)
const data = await res.json()
return data.slots || data || []
}
// Occupation par ressource (tech) sur l'horizon, filtrée compétence → bandes verticales « Trouver un créneau ».
// payload = { after_date?, days?, skill? } → { start, days, dates[], techs: [{ tech_id, tech_name, occupancy, shift_h, busy_h, free_h, days:[{date,dow,off,reason,occupancy,shift_start,shift_end,shift_h,busy_h,free_h,jobs,blocks:[{top,height,start,end,reserved}]}] }] }
export async function techOccupancy (payload) {
const res = await fetch(`${HUB_URL}/dispatch/occupancy`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}),
})
if (!res.ok) throw new Error('occupancy ' + res.status)
return res.json()
}
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
const data = await res.json()
@ -98,30 +76,6 @@ export async function updateJob (name, payload) {
return apiPut('Dispatch Job', name, payload)
}
// Complète un job VIA le chaînage canonique du hub (déblocage dépendances + activation abonnement
// + facture prorata BROUILLON en bout de chaîne, gatée PRORATION_WRITE / BILLING_APPROVAL_GATE).
// Réutilisé par la complétion d'un ticket de VENTE/installation. Renvoie { activated, invoices, pending_approval? }.
export async function setJobStatusChain (job, status) {
const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ job, status }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || ('Dispatch API ' + res.status))
return data
}
// Aperçu d'activation (LECTURE SEULE) : abonnements « En attente » du client+lieu + facture prorata consolidée
// (TOUS services : internet+tv+téléphone en 1 facture). Sert à SAVOIR si une complétion activerait qqch + montre le total.
export async function activationPreview ({ customer, service_location }) {
const res = await fetch(`${HUB_URL}/billing/activation-preview`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer, service_location, statuses: ['En attente'] }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || ('Billing API ' + res.status))
return data
}
export async function createJob (payload) {
const res = await authFetch(
`${BASE_URL}/api/resource/Dispatch%20Job`,

View File

@ -83,24 +83,13 @@ export async function deleteDoc (doctype, name) {
return true
}
// Count documents. ⚠️ frappe.client.get_count IGNORE or_filters (recherche OR) → il renverrait un compte
// faux (souvent 0) dès qu'on cherche. Quand or_filters est présent, on compte via get_list (fields=name,
// sans limite) — les jeux de recherche sont bornés par le terme tapé. Sinon (chips seuls) : get_count rapide.
// Count documents
export async function countDocs (doctype, filters = {}, or_filters) {
if (or_filters && or_filters.length) {
const p = new URLSearchParams({
const params = new URLSearchParams({
doctype,
filters: JSON.stringify(filters),
or_filters: JSON.stringify(or_filters),
fields: JSON.stringify(['name']),
limit_page_length: '0',
})
const r = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + p)
if (!r.ok) return 0
const d = await r.json()
return ((d.message || d.data) || []).length
}
const params = new URLSearchParams({ doctype, filters: JSON.stringify(filters) })
if (or_filters) params.set('or_filters', JSON.stringify(or_filters))
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
if (!res.ok) return 0
const data = await res.json()

View File

@ -1,57 +0,0 @@
/**
* api/events.js Client des endpoints Hub /events (vue staff RSVP).
* Miroir de services/targo-hub/lib/events.js. Passe par le proxy /ops/hub
* (jeton HUB_SERVICE_TOKEN injecté) routes /events gatées (PII).
*
* listEvents() { events:[{id,name,date_iso,active}] }
* listRsvps(eventId) { event:{...,public_url}, responses:[], count, headcount, matched, unmatched }
* deleteRsvp(eventId, key) { ok }
* sendInvite(eventId, body) envoi d'invitations (GATÉ — non branché pour l'instant)
*/
import { HUB_URL } from 'src/config/hub'
async function hubFetch (path, { method = 'GET', body } = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(`${HUB_URL}${path}`, opts)
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : {} } catch { throw new Error(`Réponse invalide de ${path}`) }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
return data
}
export async function listEvents () { return hubFetch('/events') }
export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps`) }
export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) }
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) }
// Aperçu HTML de l'invitation (gabarit festif OU template éditable). template: undefined=modèle configuré, ''=festif, '<nom>'=ce template.
export async function getInvitePreview (eventId, { lang = 'fr', template } = {}) {
const qs = new URLSearchParams({ lang })
if (template !== undefined) qs.set('template', template)
return hubFetch(`/events/${encodeURIComponent(eventId)}/invite-preview?${qs.toString()}`)
}
// Recherche FLOUE de clients (pg_trgm, typo/accent-tolérante) — réutilise l'endpoint app-wide.
export async function searchCustomersFuzzy (q) { return hubFetch(`/collab/customer-search?q=${encodeURIComponent(q)}`) }
// Liste principale d'audience (additive, persistée par événement).
export async function getAudienceList (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list`) }
export async function audienceListAdd (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list/add`, { method: 'POST', body }) }
export async function audienceListRemove (eventId, email) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list/remove`, { method: 'POST', body: { email } }) }
export async function audienceListClear (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list`, { method: 'DELETE' }) }
export async function listTemplates () { return hubFetch('/campaigns/templates') }
// Config brute éditable d'un événement (pour le formulaire d'édition).
export async function getEventConfig (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}`) }
// Création / édition / suppression d'un événement (config persistée côté hub).
export async function createEvent (body) { return hubFetch('/events', { method: 'POST', body }) }
export async function updateEvent (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}`, { method: 'PUT', body }) }
export async function deleteEvent (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}`, { method: 'DELETE' }) }
// Pièces jointes du courriel d'invitation (image/PDF, base64).
export async function uploadAttachment (eventId, { filename, mime, data, lang }) { return hubFetch(`/events/${encodeURIComponent(eventId)}/attachments`, { method: 'POST', body: { filename, mime, data, lang } }) }
export async function deleteAttachment (eventId, stored) { return hubFetch(`/events/${encodeURIComponent(eventId)}/attachments/${encodeURIComponent(stored)}`, { method: 'DELETE' }) }

View File

@ -1,17 +0,0 @@
// API identité unifiée — appelle le hub /identity/* (écritures réservées admin côté hub).
import { HUB_URL as HUB } from 'src/config/hub'
async function j (path, init) {
const r = await fetch(HUB + path, init)
const d = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(d.error || ('Identity API ' + r.status))
return d
}
const post = (path, body) => j(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
export const getIdentityMap = () => j('/identity/map')
// upsert : { key?, label, primary_email, alias_emails?, tech_id?, employee? }
export const upsertIdentity = (body) => post('/identity', body)
export const setAlias = (key, email, remove = false) => post('/identity/' + encodeURIComponent(key) + '/alias', { email, remove })
export const mergeIdentity = (into, from) => post('/identity/merge', { into, from })
export const deleteIdentity = (key) => j('/identity/' + encodeURIComponent(key), { method: 'DELETE' })

View File

@ -91,23 +91,3 @@ export function overpricedInternetCsvUrl ({ threshold = 90, segment = 'residenti
})
return `${HUB}/reports/legacy/overpriced-internet.csv?${qs}`
}
/**
* Explorateur de revenus MRR net récurrent regroupé par DIMENSION (un seul rapport,
* critères ajoutables). Remplace les rapports F fixes (par service / code postal / ville / segment).
* @param {object} opts { dimension:'category|zip|city|segment|plan', segment, type:'internet|tv|phone|all', active, threshold, limit }
*/
export function fetchRevenueExplorer ({ dimension = 'category', segment = 'all', type = 'all', active = true, threshold = 0, limit = 300 } = {}) {
const qs = new URLSearchParams({
dimension, segment, type, active: active ? '1' : '0', threshold: String(threshold), limit: String(limit),
})
return hubFetch(`/reports/legacy/revenue-explorer?${qs}`)
}
/** URL CSV directe pour l'explorateur de revenus (mêmes critères). */
export function revenueExplorerCsvUrl ({ dimension = 'category', segment = 'all', type = 'all', active = true, threshold = 0 } = {}) {
const qs = new URLSearchParams({
dimension, segment, type, active: active ? '1' : '0', threshold: String(threshold),
})
return `${HUB}/reports/legacy/revenue-explorer.csv?${qs}`
}

View File

@ -5,48 +5,33 @@
*/
import { HUB_URL as HUB } from 'src/config/hub'
// Toute requête a un TIMEOUT (AbortController) : un solveur/hub lent ou injoignable ne doit JAMAIS
// laisser l'interface figée (ex. « Suggérer » qui tourne sans fin). Au-delà, on lève → repli local.
async function _fetch (path, init = {}, ms = 45000) {
const ctl = new AbortController()
const timer = setTimeout(() => ctl.abort(), ms)
try {
const r = await fetch(HUB + path, { ...init, signal: ctl.signal })
if (!r.ok) {
// Le corps d'erreur porte parfois un contexte actionnable (ex. 409 conflict de /roster/assign-job) → l'attacher.
let body = null; try { body = await r.json() } catch (e2) { /* corps non-JSON */ }
const err = new Error((body && body.error) || ('Roster API ' + r.status)); err.status = r.status; if (body) err.body = body
throw err
async function jget (path) {
const r = await fetch(HUB + path)
if (!r.ok) throw new Error('Roster API ' + r.status)
return r.json()
}
return await r.json()
} catch (e) {
if (e && e.name === 'AbortError') throw new Error('Roster API timeout (' + Math.round(ms / 1000) + 's) — serveur trop lent ou injoignable')
throw e
} finally { clearTimeout(timer) }
async function jpost (path, body) {
const r = await fetch(HUB + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
})
if (!r.ok) throw new Error('Roster API ' + r.status)
return r.json()
}
async function jput (path, body) {
const r = await fetch(HUB + path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
if (!r.ok) throw new Error('Roster API ' + r.status)
return r.json()
}
async function jget (path, ms) { return _fetch(path, {}, ms) }
async function jpost (path, body, ms) { return _fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }, ms) }
async function jput (path, body, ms) { return _fetch(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }, ms) }
export const listTechnicians = () => jget('/roster/technicians')
export const listTemplates = () => jget('/roster/templates')
export const createTemplate = (t) => jpost('/roster/templates', t)
// Fériés QC déterministes (calendrier) + préavis de disponibilité avant un férié.
export const holidays = (from, to) => jget(`/roster/holidays?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
export const holidayNotice = (days = 35) => jget('/roster/holiday-notice?days=' + days)
export const sendHolidayNotice = (date) => jpost('/roster/holiday-notice', { date })
export const listRequirements = (start, days = 7) => jget(`/roster/requirements?start=${start}&days=${days}`)
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.
// skill (chaîne ou tableau) → dénominateur FILTRÉ : ne compte que les techs qui ont cette/ces compétence(s).
export const getCapacity = (start, days = 7, skill = '') => {
const s = (Array.isArray(skill) ? skill : (skill ? [skill] : [])).filter(Boolean)
return jget(`/roster/capacity?start=${start}&days=${days}${s.length ? '&skill=' + encodeURIComponent(s.join(',')) : ''}`)
}
// Résolveur « raison → compétence(s) » : { text?, department?, jobType?, useAI? } → { skills, primary, confidence, source, reason }.
export const resolveSkills = (body) => jpost('/roster/resolve-skills', body || {})
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}`)
@ -54,25 +39,13 @@ export const applyGardeHorizon = (start, weeks, assignments, shifts) => jpost('/
export const setAbsence = (tech, date, type, remove) => jpost('/roster/absence/set', { tech, date, type, remove })
export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
export const publish = (assignments) => jpost('/roster/publish', { assignments })
export const publishWeek = (start, days, assignments, notify, mode) => jpost('/roster/publish-week', { start, days, assignments, notify, mode })
// Notifier les techs de leur tournée (SMS/Email, lien token + infos) : preview=true → compose sans envoyer.
export const notifyDispatch = (payload) => jpost('/roster/notify-dispatch', payload)
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })
export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
// ── Copilote (Gemini Flash) + politique de reprise ──
export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history })
export const getPolicy = () => jget('/roster/policy')
export const savePolicy = (policy) => jpost('/roster/policy', policy)
export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job
export const setJobSkills = (name, skills) => jpost('/roster/job-skills', { name, skills }) // compétences requises (LISTE) persistantes par job → dispatch auto
export const setJobRemote = (name, remote) => jpost('/roster/job-remote', { name, remote }) // 🖥 sans déplacement (à distance) — exclu des tournées
export const groupJobs = (parent, children) => jpost('/roster/job-group', { parent, children }) // 📦 grouper en « projet » (parent_job)
// Répondre au fil du billet depuis le volet job (note interne par défaut, au client si public). agentName = prénom+nom de l'agent (attribution).
export const postJobComment = ({ job, lid, text, isPublic, agentName }) => jpost('/roster/job-comment', { job, lid, text, public: !!isPublic, agentName: agentName || '' })
// #5 — Réserver le temps d'un tech (bloc « Réservation » priorité moyenne) → dé-priorise au dispatch auto + soustrait des créneaux.
export const reserveTech = ({ tech, date, start_time, duration_h, reason }) => jpost('/roster/reserve', { tech, date, start_time, duration_h, reason })
// Job « bouche-trou » : job générique standard assigné → retire le tech du dispatch (option : génère un ticket).
export const createFillerJob = (body) => jpost('/roster/filler-job', body || {})
export async function deleteShiftTemplate (name) {
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
@ -89,7 +62,6 @@ export const listAvailability = (status) => jget('/roster/availability' + (statu
export const requestAvailability = (a) => jpost('/roster/availability', a)
export const approveAvailability = (name, body) => jpost('/roster/availability/' + encodeURIComponent(name) + '/approve', body || {})
export const pauseTechnician = (id, paused, reason) => jpost(`/roster/technician/${encodeURIComponent(id)}/pause`, { paused, reason })
export const archiveTechnician = (id, archived) => jpost(`/roster/technician/${encodeURIComponent(id)}/archive`, { archived }) // archive réversible (masqué du dispatch/solveur/créneaux, historique conservé)
export const setTechEfficiency = (id, efficiency) => jpost(`/roster/technician/${encodeURIComponent(id)}/efficiency`, { efficiency })
export const setTechCost = (id, body) => jpost(`/roster/technician/${encodeURIComponent(id)}/cost`, body)
export const setTechSkills = (id, skills, skillLevels, skillEff) => { const body = { skills }; if (skillLevels !== undefined) body.skill_levels = skillLevels; if (skillEff !== undefined) body.skill_eff = skillEff; return jpost(`/roster/technician/${encodeURIComponent(id)}/skills`, body) }
@ -119,127 +91,13 @@ 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')
// Optimisation de tournées (VRPTW + compétences + temps sur place) via le solveur OR-Tools. payload = { jobs[], vehicles[], ... }
export const optimizeRoutes = (payload) => jpost('/roster/optimize-routes', payload)
// P4 — orchestration COMPLÈTE côté hub (pool+quarts+occupation+règles d'adresse) : { dates?|days?, tech_ids?, opts, dur_overrides, job_windows } → { plan[], unassigned[], assumed_shift }
export const optimizePlanHub = (payload) => jpost('/roster/optimize-plan', payload, 90000) // solveur VRP : timeout 90s → sinon repli local (jamais figé)
// Proxys OSRM auto-hébergé (points [[lon,lat],…]) — tracés + matrices SANS Mapbox payant.
export const osrmRoute = (points) => jpost('/roster/osrm-route', { points }) // → { km, mins, geometry, legs[] (temps entre arrêts) }
export const osrmTable = (points) => jpost('/roster/osrm-table', { points }) // → { durations (s), distances (m) } même forme que Mapbox Matrix
export const osrmTrip = (points) => jpost('/roster/osrm-trip', { points }) // TSP → { code, waypoints:[{waypoint_index}] } (remplace Mapbox optimized-trips)
// 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))
// Poster au fil d'un ticket : isPublic=false (défaut) = note INTERNE (jamais au client) · true = message public
export const postTicket = (ticket, msg, isPublic) => jpost('/dispatch/legacy-sync/ticket-post', { ticket, msg, public: !!isPublic })
// GÉNÉRALISÉ par JOB (osTicket OU ERPNext-natif) : fil+contact (lecture seule) et résolution de conversation pour répondre.
export const jobThread = (job) => jget('/dispatch/legacy-sync/job-thread?job=' + encodeURIComponent(job))
export const jobConversation = (job) => jget('/dispatch/legacy-sync/job-conversation?job=' + encodeURIComponent(job))
// P2 — réponse au CLIENT via le MÊME chemin que la Boîte (/conversations/{token}/messages → Outbox courriel/SMS + miroir osTicket).
// X-Authentik-Email REQUIS : sans lui le hub enregistre l'envoi comme message CLIENT (from='customer') + déclenche l'IA. À passer (useAuthStore().user).
export async function sendConvMessage (token, text, agentEmail) {
const headers = { 'Content-Type': 'application/json' }
if (agentEmail) headers['X-Authentik-Email'] = agentEmail
const r = await fetch(HUB + '/conversations/' + encodeURIComponent(token) + '/messages', { method: 'POST', headers, body: JSON.stringify({ text }) })
if (!r.ok) throw new Error('Envoi conv ' + r.status)
return r.json()
}
// 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)
// Assignation avec GARDE-FOU F : si le ticket legacy est déjà assigné dans F à un autre vrai tech (fenêtre aveugle
// du miroir ~3 min), le hub renvoie 409 conflict → on demande confirmation AVANT d'écraser (jamais silencieux).
export const assignJob = async (job, tech, date, { force = false } = {}) => {
try {
return await jpost('/roster/assign-job', { job, tech, date, force })
} catch (e) {
const b = e && e.body
if (b && b.conflict) {
const { Dialog } = await import('quasar')
return new Promise((resolve, reject) => {
Dialog.create({
title: 'Déjà assigné dans F',
message: `Le ticket ${b.ticket || ''} est déjà assigné dans F à ${b.f_staff_name || ('staff #' + b.f_staff_id)}${b.f_status ? ' (ticket ' + b.f_status + ')' : ''}. Réassigner quand même ?`,
cancel: { label: 'Annuler', flat: true, color: 'grey-8' },
ok: { label: 'Réassigner quand même', color: 'negative', unelevated: true },
persistent: true,
}).onOk(() => { assignJob(job, tech, date, { force: true }).then(resolve, reject) })
.onCancel(() => reject(new Error('Assignation annulée — déjà assigné dans F à ' + (b.f_staff_name || ('staff #' + b.f_staff_id)))))
})
}
throw 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é) — ERPNext SEULEMENT (redistributions auto)
// Retirer un job d'un tech (retour au pool non assigné)
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
// Médias terrain d'un job : photos du tech (géo-vérifiées) + journal completion_notes (arrivées, scans, notes).
export const jobMedia = (job) => jget('/roster/job-media?job=' + encodeURIComponent(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 })
// Actions rapides du pool (mobile) : patch d'un Dispatch Job (priority/status/required_skill/scheduled_date/notes — liste blanche côté hub).
export const updateJob = (job, patch) => jpost('/roster/job/update', { job, patch })
// Persister UN quart immédiatement (sinon addShift reste local/Proposé et disparaît au rechargement). Idempotent côté hub.
export const createShift = (s) => jpost('/roster/shift', s)
// Hybride : patron récurrent (source) + matérialisation (fériés/vacances sautés, manuels préservés)
export const setWeeklySchedule = (techId, schedule) => jpost('/roster/technician/' + encodeURIComponent(techId) + '/weekly-schedule', { schedule })
export const materializeShifts = (payload) => jpost('/roster/materialize-shifts', payload || {})
// ÉQUIPE d'un job (assistants en renfort) — le lead reste inchangé ; l'assistant épinglé voit un bloc hachuré dans son horaire.
export const setTechContact = (b) => jpost('/roster/technician/contact', b) // édition inline téléphone/courriel d'un tech
export const getJobTeam = (job) => jget('/roster/job/team?job=' + encodeURIComponent(job))
export const addAssistant = (job, a) => jpost('/roster/job/team', { job, add: a })
export const removeAssistant = (job, techId) => jpost('/roster/job/team', { job, remove: techId })
// 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(',')), {})
// Jobs Legacy (osTicket) ouverts assignés à UN tech (lecture seule) — vue « jobs courants du tech »
export const legacyTechTickets = (tech) => jget('/dispatch/legacy-sync/tech-tickets?tech=' + encodeURIComponent(tech))
// Charge Legacy DATÉE par tech×jour sur la fenêtre affichée → durées estimées appliquées aux timelines
export const legacyWindowLoad = (start, days) => jget('/dispatch/legacy-sync/window-load?start=' + encodeURIComponent(start) + '&days=' + encodeURIComponent(days))
// 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)
// Proposer un RDV au client : envoie le lien /book (choix d'un créneau OU proposition de 3 dispos). Résout OU crée le
// job ouvert depuis { job | source_issue | customer } (+ subject/skill/duration/service_location/phone). Voir /roster/book/propose.
export const proposeAppointment = (body) => jpost('/roster/book/propose', body)
// URL d'export GPX du parcours GPS d'un tech (jour courant) via Traccar — { tech, from, to } ISO 8601. window.open() = téléchargement.
export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon).
export const traccarTrack = (tech, from, to) => jget('/traccar/track?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to))
// Validation « service livré » d'un job : statut service+modem (en ligne + signal) pour le client/adresse du job.
// Croise tech-présent (geofence) + service-en-ligne → preuve de complétion d'install.
export const jobServiceStatus = (job) => jget('/roster/job-service-status?job=' + encodeURIComponent(job))
// Aperçu croisement temps-sur-site (Traccar × emplacement job) + ancre d'activation GenieACS (clamped_minutes / service_anchor).
export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days)
// Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile).
export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech))
// Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }.
export const techPositions = () => jget('/roster/tech-positions')
// Géofencing : timeline d'un job → { state, events:[{status,at,dist}] } (status: en_route|on_site|departed).
export const jobGeofence = (name) => jget('/roster/job/' + encodeURIComponent(name) + '/geofence')
// Géofencing : états live pour une liste de jobs → { states: { jobName: state } }.
export const geofenceStates = (names) => jget('/roster/geofence-states?jobs=' + encodeURIComponent((names || []).join(',')))
// Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }.
export const listTraccarDevices = () => jget('/traccar/devices')
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
export const setTechTraccarDevice = (id, deviceId) => jpost('/roster/technician/' + encodeURIComponent(id) + '/traccar-device', { device_id: deviceId })
// Roster appareils GPS enrichi (device + tech(s) assigné(s) + non-associé + dernier signal) — vue « appareil → tech ».
export const listTraccarDevicesRoster = () => jget('/roster/traccar-devices')
// Assigner un appareil → un tech (SENS INVERSE) ; libère l'ancien détenteur. tech_id vide = dissocier de tous.
export const assignTraccarDevice = (deviceId, techId) => jpost('/roster/traccar-device-assign', { device_id: deviceId, tech_id: techId || '' })

View File

@ -1,21 +0,0 @@
// API console STAFF — réconciliation Authentik ↔ System User/Employee/Tech/identité (hub /auth/staff/*).
// Écritures réservées admin (gaté côté hub) ; suppression GARDÉE (409 si références → proposer désactivation).
import { HUB_URL as HUB } from 'src/config/hub'
async function j (path, init) {
const r = await fetch(HUB + path, init)
const d = await r.json().catch(() => ({}))
if (!r.ok && r.status !== 409) throw new Error(d.error || ('Staff API ' + r.status))
return { status: r.status, ...d }
}
const post = (path, body) => j(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
export const listStaff = () => j('/auth/staff')
export const provisionStaff = (body) => post('/auth/staff/provision', body)
export const setStaffActive = (email, active) => post('/auth/staff/active', { email, active })
export const staffImpact = (email) => j('/auth/staff/impact?email=' + encodeURIComponent(email))
export const deleteStaff = (email) => j('/auth/staff?email=' + encodeURIComponent(email), { method: 'DELETE' })
// Dé-dup : garde le compte Authentik utilisé, supprime le(s) doublon(s) jamais connecté(s) pour ce courriel.
export const dedupeAccounts = (email) => post('/auth/staff/dedupe', { email })
// Backfill : lie automatiquement Authentik ↔ Employee ↔ Tech dans l'identité unifiée (alias depuis le courriel entreprise).
export const syncIdentities = () => post('/auth/staff/sync-identities', {})

View File

@ -1,31 +0,0 @@
/**
* Sous-traitants payés à l'acte appelle targo-hub /acte/*.
* Barème (prix par acte), génération/envoi du lien à token, revue des soumissions.
* Voir services/targo-hub/lib/subcontractor.js. Token hub injecté par nginx /hub (même patron que roster.js).
*/
import { HUB_URL as HUB } from 'src/config/hub'
async function jget (p) { const r = await fetch(HUB + p); if (!r.ok) throw new Error('Acte API ' + r.status); return r.json() }
async function jpost (p, b) {
const r = await fetch(HUB + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b || {}) })
if (!r.ok) throw new Error('Acte API ' + r.status); return r.json()
}
export const getRates = (sub) => jget('/acte/rates' + (sub ? '?sub=' + encodeURIComponent(sub) : '')) // sub → barème effectif du sous-traitant (global + surcharge)
export const saveRates = (rates, sub) => jpost('/acte/rates', sub ? { rates, sub } : { rates }) // sub → surcharge par sous-traitant ; sinon barème global
export const listSubmissions = (ticket) => jget('/acte/list' + (ticket ? '?ticket=' + encodeURIComponent(ticket) : ''))
export const sendLink = (ticket, subcontractor, phone) => jpost('/acte/send', { ticket, subcontractor, phone })
export const approve = (id, action, reason) => jpost('/acte/approve', { id, action, reason }) // action: 'approve' | 'reject' (gaté admin/superviseur côté hub)
export const imgUrl = (file) => HUB + '/acte/img?file=' + encodeURIComponent(file) // servi gaté via le proxy /hub
// ── Autofacturation : config fiscale par sous-traitant, relevé par période, marquer payé, export Acomba (CSV) ──
export const getSubs = () => jget('/acte/subs') // { subs:[{name,registrant,gst_no,qst_no,email,phone}], names:[...vus en soumission] }
export const saveSub = (sub) => jpost('/acte/subs', sub) // {name, registrant, gst_no, qst_no, email, phone} — gaté admin/superviseur
const qstr = (sub, from, to) => '?subcontractor=' + encodeURIComponent(sub) + (from ? '&from=' + from : '') + (to ? '&to=' + to : '')
export const getReleve = (sub, from, to) => jget('/acte/releve' + qstr(sub, from, to)) // { releve:{config,lines,subtotal,tps,tvq,total,...} }
export const markPaid = (body) => jpost('/acte/mark-paid', body) // { subcontractor, from, to } OU { ids:[] } — gaté admin/superviseur
export const exportUrl = (sub, from, to) => HUB + '/acte/export' + qstr(sub, from, to) // CSV (téléchargé via le proxy /hub)
// ── Entente de services : lien web ou PDF des conditions (montré au sous-traitant à la certification) ──
export const getEntente = () => jget('/acte/entente') // { url }
export const saveEntente = (body) => jpost('/acte/entente', body) // { url } | { pdf: dataUrl } | { clear: true } — gaté admin/superviseur

View File

@ -1,26 +0,0 @@
// Empêche TOUTE requête de l'app (same-origin : /api, /hub, /auth) de PENDRE indéfiniment.
// Cause du gel « même un clic de menu ne répond plus » : sur session Authentik morte/lente, un fetch
// sans timeout ne résout jamais → la page attendue ne s'affiche pas, la vue ne change pas au clic,
// et les 69 appels /hub (fetch nu) restent suspendus. On borne chaque requête app à 30 s.
// Externes (Mapbox api.mapbox.com, etc.) et appels fournissant déjà un signal : inchangés.
// EventSource (SSE) n'utilise pas window.fetch → non affecté.
import { boot } from 'quasar/wrappers'
const APP_TIMEOUT_MS = 30000
export default boot(() => {
if (typeof window === 'undefined' || window.__netTimeoutInstalled) return
window.__netTimeoutInstalled = true
const orig = window.fetch.bind(window)
const origin = window.location.origin
window.fetch = function (input, init) {
init = init || {}
let url = ''
try { url = typeof input === 'string' ? input : (input && input.url) || '' } catch { /* Request exotique */ }
const sameApp = url.startsWith('/') || url.startsWith(origin) // requêtes app ; exclut absolu cross-origin
if (!sameApp || init.signal) return orig(input, init) // externe ou signal déjà fourni → ne pas toucher
const ctrl = new AbortController()
const id = setTimeout(() => ctrl.abort(), APP_TIMEOUT_MS)
return orig(input, { ...init, signal: ctrl.signal }).finally(() => clearTimeout(id))
}
})

View File

@ -1,37 +0,0 @@
import { boot } from 'quasar/wrappers'
// Comportement GLOBAL des bulles d'aide (q-tooltip), app-wide :
// 1) Le CLIC a PRÉSÉANCE — un pointerdown masque la bulle immédiatement et tant qu'on reste sur le même
// élément ; elle ne réapparaît qu'en survolant un AUTRE élément.
// 2) JAMAIS deux bulles à la fois — sur des éléments imbriqués (ex. cellule de planif + bloc job, ou
// timeline + job), on ne garde que la PLUS RÉCENTE (la plus interne). Anti-empilement.
//
// Quasar monte chaque tooltip comme élément `.q-tooltip` (téléporté dans <body>). On agit au niveau DOM
// (display) sans toucher chaque q-tooltip individuellement → fix unique pour toute l'app. Entièrement
// défensif : si l'hypothèse DOM change, le pire cas est un no-op (bulles inchangées), jamais une casse.
export default boot(() => {
if (typeof document === 'undefined' || !document.body) return
let suppressed = false // vrai juste après un clic → on masque tout jusqu'au prochain survol d'un autre élément
let clicked = null
const apply = () => {
const tips = Array.from(document.querySelectorAll('.q-tooltip'))
if (!tips.length) return
if (suppressed) { for (const t of tips) t.style.display = 'none'; return }
// ne garder QUE la plus récente (dernière dans le DOM = la plus interne / dernière survolée)
tips.forEach((t, i) => { t.style.display = i === tips.length - 1 ? '' : 'none' })
}
// 1) clic = masquer la bulle (préséance)
document.addEventListener('pointerdown', (e) => { suppressed = true; clicked = e.target; apply() }, true)
// … jusqu'à ce qu'on survole un élément DIFFÉRENT
document.addEventListener('pointerover', (e) => {
if (suppressed && clicked && e.target !== clicked && !(clicked.contains && clicked.contains(e.target))) {
suppressed = false; clicked = null; apply()
}
}, true)
// 2) anti-empilement : à chaque ajout/retrait de bulle (téléport dans body), garder une seule bulle
try { new MutationObserver(apply).observe(document.body, { childList: true }) } catch (e) { /* no-op */ }
})

View File

@ -0,0 +1,544 @@
<template>
<div class="chatter-panel">
<div class="chatter-header">
<span class="text-weight-bold" style="font-size:1.1rem">Convos</span>
<q-space />
<q-badge v-if="unreadCount" color="red" text-color="white" class="q-mr-xs">{{ unreadCount }}</q-badge>
<q-btn flat dense round size="sm" icon="refresh" color="grey-6" @click="loadAll" :loading="loading" />
</div>
<ComposeBar
v-model:channel="composeChannel" v-model:text="composeText"
v-model:email-subject="emailSubject" v-model:sms-to="smsTo" v-model:call-to="callTo"
:customer-phone="customerPhone" :phone-options="phoneOptions"
:sending="sending" :calling="calling" :canned-responses="cannedResponses"
@send="send" @call="initiateCall" @use-canned="useCanned" @manage-canned="cannedModal = true" />
<!-- Bulk action bar -->
<div v-if="selectedIds.size > 0" class="chatter-bulk-bar">
<q-checkbox :model-value="selectedIds.size === filteredEntries.length" dense size="sm"
@update:model-value="v => v ? selectAllEntries() : selectedIds.clear()" class="q-mr-xs" />
<span class="text-caption text-weight-medium">{{ selectedIds.size }} sélectionné{{ selectedIds.size > 1 ? 's' : '' }}</span>
<q-space />
<q-btn flat dense size="sm" icon="delete_outline" color="red-5" :loading="bulkDeleting" @click="confirmBulkDelete">
<q-tooltip>Supprimer la sélection</q-tooltip>
</q-btn>
<q-btn flat dense size="sm" icon="close" color="grey-6" @click="selectedIds.clear()" />
</div>
<div class="chatter-tabs">
<q-btn-toggle v-model="activeTab" no-caps dense unelevated size="xs" spread
toggle-color="indigo-6" color="grey-2" text-color="grey-7" :options="tabOptions" />
</div>
<div class="chatter-timeline" ref="timelineRef">
<div v-if="loading && !entries.length" class="flex flex-center q-pa-lg">
<q-spinner size="24px" color="indigo-5" />
</div>
<div v-else-if="!filteredEntries.length" class="text-center text-grey-5 q-pa-lg text-caption">
{{ activeTab === 'all' ? 'Aucune communication' : 'Aucun ' + tabLabel }}
</div>
<template v-else>
<template v-for="(group, idx) in groupedEntries" :key="idx">
<div class="chatter-date-sep"><span>{{ group.label }}</span></div>
<div v-for="entry in group.items" :key="entry.id" class="chatter-entry"
:class="[...entryClass(entry), { 'entry-selected': selectedIds.has(entry.id) }]"
@mouseenter="hoveredEntry = entry.id" @mouseleave="hoveredEntry = null">
<div v-if="selectedIds.size > 0" class="entry-checkbox">
<q-checkbox :model-value="selectedIds.has(entry.id)" dense size="sm"
@update:model-value="toggleEntry(entry.id)" @click.stop />
</div>
<div v-else class="entry-icon" :class="'icon-' + entry.channel"
@click.stop="startSelection(entry.id)">
<q-icon :name="channelIcon(entry)" size="16px" />
</div>
<div class="entry-body">
<div class="entry-header">
<span class="entry-direction">
<q-icon :name="entry.direction === 'out' ? 'call_made' : entry.direction === 'in' ? 'call_received' : 'edit_note'"
size="12px" :color="entry.direction === 'out' ? 'indigo-4' : entry.direction === 'in' ? 'green-6' : 'grey-5'" />
</span>
<span class="entry-author text-weight-medium">{{ entry.author }}</span>
<q-space />
<div v-if="hoveredEntry === entry.id" class="entry-actions">
<q-btn flat dense round size="xs" icon="edit" color="grey-5" @click="startEdit(entry)" title="Modifier" />
<q-btn flat dense round size="xs" icon="delete_outline" color="red-4" @click="confirmDelete(entry)" title="Supprimer" />
</div>
<span class="entry-time text-caption text-grey-5">{{ formatTime(entry.date) }}</span>
</div>
<div v-if="editingEntry === entry.id" class="entry-edit">
<q-input v-model="editText" dense outlined autogrow :input-style="{ fontSize: '0.82rem' }"
@keydown.escape="editingEntry = null"
@keydown.ctrl.enter="saveEdit(entry)" @keydown.meta.enter="saveEdit(entry)" />
<div class="row q-gutter-xs q-mt-xs">
<q-btn dense unelevated size="xs" color="indigo-6" label="Sauver" @click="saveEdit(entry)" :loading="savingEdit" />
<q-btn dense flat size="xs" color="grey-6" label="Annuler" @click="editingEntry = null" />
</div>
</div>
<div v-else class="entry-content" :class="{ 'entry-note': entry.channel === 'note' }">{{ entry.text }}</div>
<div v-if="entry.channel === 'phone' && entry.meta" class="entry-meta text-caption text-grey-5">
<q-icon name="timer" size="12px" class="q-mr-xs" />
{{ entry.meta.duration || '0s' }}
<template v-if="entry.meta.status"> &middot; {{ entry.meta.status }}</template>
</div>
<div v-if="entry.linkedTo && entry.linkedTo !== customerName" class="entry-link">
<q-chip dense size="sm" icon="link" clickable color="grey-2" text-color="grey-8"
@click="$emit('navigate', entry.linkedDoctype, entry.linkedTo)">
{{ entry.linkedDoctype }}: {{ entry.linkedTo }}
</q-chip>
</div>
</div>
</div>
</template>
</template>
</div>
<q-dialog v-model="deleteDialog">
<q-card style="min-width:300px">
<q-card-section>
<div class="text-weight-bold">Supprimer ce message ?</div>
<div class="text-caption text-grey-6 q-mt-xs">Cette action est irréversible.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated label="Supprimer" color="red" :loading="deleting" @click="doDelete" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="bulkDeleteDialog">
<q-card style="min-width:300px">
<q-card-section>
<div class="text-weight-bold">Supprimer {{ selectedIds.size }} message{{ selectedIds.size > 1 ? 's' : '' }} ?</div>
<div class="text-caption text-grey-6 q-mt-xs">Cette action est irréversible.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated label="Supprimer tout" color="red" :loading="bulkDeleting" @click="doBulkDelete" />
</q-card-actions>
</q-card>
</q-dialog>
<PhoneModal v-model="phoneModalOpen" :initial-number="callTo || smsTo || customerPhone"
:customer-name="props.customerName" :customer-erp-name="props.customerName"
:provider="phoneProvider" @call-ended="onCallEnded" />
<q-dialog v-model="cannedModal">
<q-card style="min-width:460px;max-width:600px">
<q-card-section>
<div class="text-weight-bold" style="font-size:1rem">Réponses rapides</div>
<div class="text-caption text-grey-6">Tapez <code>::raccourci</code> dans la zone de texte pour insérer une réponse rapide.</div>
</q-card-section>
<q-separator />
<q-card-section style="max-height:350px;overflow-y:auto;padding:8px 16px">
<div v-for="(cr, i) in cannedResponses" :key="i" class="canned-row">
<div class="canned-shortcut">
<q-input v-model="cr.shortcut" dense borderless prefix="::" :input-style="{ fontSize:'0.82rem', fontWeight:600 }" style="width:100px" />
</div>
<div class="canned-text">
<q-input v-model="cr.text" dense borderless autogrow :input-style="{ fontSize:'0.82rem' }" placeholder="Texte de la réponse..." style="flex:1" />
</div>
<q-btn flat dense round size="xs" icon="delete_outline" color="red-4" @click="removeCanned(i)" />
</div>
<div v-if="!cannedResponses.length" class="text-grey-5 text-caption text-center q-py-md">
Aucune réponse rapide. Cliquez + pour en ajouter.
</div>
</q-card-section>
<q-separator />
<q-card-actions>
<q-btn flat dense icon="add" label="Ajouter" color="indigo-6" @click="addCanned" />
<q-space />
<q-btn flat label="Fermer" color="grey-7" v-close-popup />
<q-btn unelevated label="Sauver" color="indigo-6" @click="saveCanned" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue'
import { Notify } from 'quasar'
import { listDocs, createDoc, updateDoc, deleteDoc } from 'src/api/erp'
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import ComposeBar from './ComposeBar.vue'
import PhoneModal from './PhoneModal.vue'
const TAB_OPTIONS = [
{ label: 'Tout', value: 'all', icon: 'forum' },
{ label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Appels', value: 'phone', icon: 'phone' },
{ label: 'Email', value: 'email', icon: 'email' },
{ label: 'Notes', value: 'note', icon: 'sticky_note_2' },
]
const TAB_LABEL_MAP = { sms: 'SMS', phone: 'appel', email: 'email', note: 'note' }
const CHANNEL_ICONS = { sms: 'sms', phone: 'phone', email: 'email', note: 'sticky_note_2', other: 'chat' }
const props = defineProps({
customerName: { type: String, required: true },
customerPhone: { type: String, default: '' },
customerPhones: { type: Array, default: () => [] },
customerEmail: { type: String, default: '' },
comments: { type: Array, default: () => [] },
})
const emit = defineEmits(['navigate', 'note-added', 'note-updated'])
const loading = ref(false)
const activeTab = ref('all')
const composeChannel = ref('sms')
const composeText = ref('')
const emailSubject = ref('')
const sending = ref(false)
const calling = ref(false)
const smsTo = ref('')
const callTo = ref('')
const timelineRef = ref(null)
const communications = ref([])
const unreadCount = ref(0)
const hoveredEntry = ref(null)
const editingEntry = ref(null)
const editText = ref('')
const savingEdit = ref(false)
const deleteDialog = ref(false)
const deleteTarget = ref(null)
const deleting = ref(false)
const phoneModalOpen = ref(false)
const phoneProvider = ref('twilio')
const cannedModal = ref(false)
const cannedResponses = ref(loadCannedResponses())
const selectedIds = ref(new Set())
const bulkDeleteDialog = ref(false)
const bulkDeleting = ref(false)
function loadCannedResponses () {
try { return JSON.parse(localStorage.getItem('ops-canned-responses') || '[]') } catch { return [] }
}
function addCanned () { cannedResponses.value.push({ shortcut: '', text: '' }) }
function removeCanned (i) { cannedResponses.value.splice(i, 1) }
function saveCanned () {
const valid = cannedResponses.value.filter(c => c.shortcut.trim() && c.text.trim())
cannedResponses.value = valid
localStorage.setItem('ops-canned-responses', JSON.stringify(valid))
Notify.create({ type: 'positive', message: 'Réponses rapides sauvegardées', timeout: 2000 })
}
function useCanned (cr) { composeText.value = cr.text }
const tabOptions = computed(() => TAB_OPTIONS)
const tabLabel = computed(() => TAB_LABEL_MAP[activeTab.value] || 'message')
const phoneOptions = computed(() => {
if (props.customerPhones.length) return props.customerPhones
return props.customerPhone
? [{ label: props.customerPhone, value: props.customerPhone }]
: [{ label: 'Aucun numero', value: '', disable: true }]
})
watch(() => props.customerPhone, v => {
if (v && !smsTo.value) smsTo.value = v
if (v && !callTo.value) callTo.value = v
}, { immediate: true })
const { connect: sseConnect, disconnect: sseDisconnect, connected: sseConnected } = useSSE({
onMessage: async (data) => {
if (data.customer === props.customerName) {
await loadCommunications()
if (data.direction === 'in') {
playNotificationSound()
Notify.create({
type: 'info', icon: 'sms',
message: `${data.type === 'sms' ? 'SMS' : 'Message'} de ${data.customer_name || data.phone || 'Client'}`,
timeout: 3000, position: 'top-right',
})
}
}
},
})
let fallbackTimer = null
function startFallbackPoll () {
stopFallbackPoll()
fallbackTimer = setInterval(() => loadCommunications(), sseConnected.value ? 30000 : 5000)
}
function stopFallbackPoll () { if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null } }
async function loadCommunications () {
try {
const data = await listDocs('Communication', {
filters: { reference_doctype: 'Customer', reference_name: props.customerName },
fields: ['name', 'subject', 'content', 'text_content', 'communication_medium',
'sent_or_received', 'communication_date', 'creation', 'phone_no',
'sender', 'sender_full_name', 'status', 'delivery_status',
'reference_doctype', 'reference_name', 'message_id', 'communication_type'],
limit: 100, orderBy: 'communication_date asc',
})
communications.value = data
unreadCount.value = data.filter(m => m.sent_or_received === 'Received' && m.status === 'Open').length
} catch {}
}
function playNotificationSound () {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctx.currentTime)
osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.1)
gain.gain.setValueAtTime(0.15, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.3)
} catch {}
}
async function loadAll () {
loading.value = true
await loadCommunications()
loading.value = false
sseConnect(['customer:' + props.customerName])
startFallbackPoll()
}
const entries = computed(() => {
const items = []
for (const c of communications.value) {
const medium = (c.communication_medium || '').toLowerCase()
const channel = medium === 'sms' ? 'sms' : medium === 'phone' ? 'phone' : medium === 'email' ? 'email' : 'other'
items.push({
id: c.name, channel,
direction: c.sent_or_received === 'Sent' ? 'out' : 'in',
author: c.sent_or_received === 'Sent' ? (c.sender_full_name || 'Targo Ops') : (c.sender_full_name || c.phone_no || c.sender || 'Client'),
text: stripHtml(c.content || c.text_content || c.subject || ''),
date: c.communication_date || c.creation,
status: c.status, deliveryStatus: c.delivery_status,
linkedDoctype: c.reference_doctype, linkedTo: c.reference_name,
meta: channel === 'phone' ? { duration: null, status: c.delivery_status || null } : null,
raw: c, doctype: 'Communication',
})
}
for (const n of (props.comments || [])) {
items.push({
id: 'note-' + n.name, channel: 'note', direction: 'internal',
author: n.comment_by?.split('@')[0] || 'Admin',
text: stripHtml(n.content || ''),
date: n.creation, status: null, linkedDoctype: null, linkedTo: null, meta: null,
raw: n, doctype: 'Comment', docName: n.name,
})
}
return items.sort((a, b) => new Date(b.date) - new Date(a.date))
})
const filteredEntries = computed(() => activeTab.value === 'all' ? entries.value : entries.value.filter(e => e.channel === activeTab.value))
const groupedEntries = computed(() => {
const groups = []
let currentLabel = ''
for (const entry of filteredEntries.value) {
const label = dateLabel(entry.date)
if (label !== currentLabel) { currentLabel = label; groups.push({ label, items: [] }) }
groups[groups.length - 1].items.push(entry)
}
return groups
})
function startEdit (entry) { editingEntry.value = entry.id; editText.value = entry.text }
async function saveEdit (entry) {
if (!editText.value.trim()) return
savingEdit.value = true
try {
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) return
await updateDoc(doctype, name, { content: editText.value.trim() })
entry.raw.content = editText.value.trim()
editingEntry.value = null
doctype === 'Comment' ? emit('note-updated') : await loadCommunications()
Notify.create({ type: 'positive', message: 'Message modifié', timeout: 2000 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
} finally { savingEdit.value = false }
}
function confirmDelete (entry) { deleteTarget.value = entry; deleteDialog.value = true }
async function doDelete () {
if (!deleteTarget.value) return
const entry = deleteTarget.value
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) return
// Optimistic removal
if (doctype === 'Comment') {
const idx = props.comments.findIndex(n => n.name === entry.raw?.name)
if (idx !== -1) props.comments.splice(idx, 1)
} else {
const idx = communications.value.findIndex(c => c.name === name)
if (idx !== -1) communications.value.splice(idx, 1)
}
deleteDialog.value = false; deleteTarget.value = null
Notify.create({ type: 'positive', message: 'Message supprimé', timeout: 2000 })
try {
await deleteDoc(doctype, name)
if (doctype === 'Comment') emit('note-updated')
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur suppression: ' + e.message, timeout: 3000 })
await loadCommunications()
if (doctype === 'Comment') emit('note-updated')
}
}
// Bulk selection
function toggleEntry (id) {
const s = new Set(selectedIds.value)
s.has(id) ? s.delete(id) : s.add(id)
selectedIds.value = s
}
function startSelection (id) {
selectedIds.value = new Set([id])
}
function selectAllEntries () {
selectedIds.value = new Set(filteredEntries.value.map(e => e.id))
}
function confirmBulkDelete () { bulkDeleteDialog.value = true }
async function doBulkDelete () {
bulkDeleting.value = true
const toDelete = entries.value.filter(e => selectedIds.value.has(e.id))
let ok = 0, fail = 0
for (const entry of toDelete) {
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) continue
try {
await deleteDoc(doctype, name)
ok++
} catch { fail++ }
}
// Remove deleted from local state
for (const entry of toDelete) {
if (entry.doctype === 'Comment' || entry.channel === 'note') {
const idx = props.comments.findIndex(n => n.name === entry.raw?.name)
if (idx !== -1) props.comments.splice(idx, 1)
} else {
const idx = communications.value.findIndex(c => c.name === (entry.docName || entry.raw?.name))
if (idx !== -1) communications.value.splice(idx, 1)
}
}
selectedIds.value = new Set()
bulkDeleteDialog.value = false
bulkDeleting.value = false
Notify.create({ type: ok > 0 ? 'positive' : 'negative', message: `${ok} message${ok > 1 ? 's' : ''} supprimé${ok > 1 ? 's' : ''}${fail ? `, ${fail} erreur${fail > 1 ? 's' : ''}` : ''}`, timeout: 3000 })
if (toDelete.some(e => e.channel === 'note')) emit('note-updated')
}
// Clear selection when switching tabs
watch(activeTab, () => { selectedIds.value = new Set() })
async function send () {
if (!composeText.value.trim() || sending.value) return
sending.value = true
try {
if (composeChannel.value === 'sms') await sendSms()
else if (composeChannel.value === 'note') await addNote()
else if (composeChannel.value === 'email') await sendEmail()
composeText.value = ''; emailSubject.value = ''
await loadCommunications()
setTimeout(loadCommunications, 2000)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
} finally { sending.value = false }
}
async function sendSms () {
if (!smsTo.value) throw new Error('Aucun numero')
await sendSmsViaHub(smsTo.value, composeText.value.trim(), props.customerName)
Notify.create({ type: 'positive', message: 'SMS envoyé', timeout: 2000 })
}
async function addNote () {
const doc = await createDoc('Comment', {
comment_type: 'Comment', reference_doctype: 'Customer',
reference_name: props.customerName, content: composeText.value.trim(),
})
emit('note-added', doc)
Notify.create({ type: 'positive', message: 'Note ajoutée', timeout: 2000 })
}
async function sendEmail () {
const { authFetch } = await import('src/api/auth')
const { BASE_URL } = await import('src/config/erpnext')
const res = await authFetch(BASE_URL + '/api/method/send_email_notification', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: props.customerEmail, subject: emailSubject.value.trim(), message: composeText.value.trim(), customer: props.customerName }),
})
if (!res.ok) throw new Error('Email failed')
Notify.create({ type: 'positive', message: 'Email envoyé', timeout: 2000 })
}
async function initiateCall (provider = 'twilio') {
const phone = callTo.value || smsTo.value || props.customerPhone
if (!phone) { Notify.create({ type: 'warning', message: 'Aucun numéro', timeout: 3000 }); return }
phoneProvider.value = provider; phoneModalOpen.value = true
}
async function onCallEnded (callInfo) {
const phone = callInfo.remote || callTo.value || smsTo.value || props.customerPhone
const durationStr = `${Math.floor(callInfo.duration / 60)}m${String(callInfo.duration % 60).padStart(2, '0')}s`
try {
await createDoc('Communication', {
subject: (callInfo.direction === 'in' ? 'Appel de ' : 'Appel vers ') + phone,
communication_type: 'Communication', communication_medium: 'Phone',
sent_or_received: callInfo.direction === 'in' ? 'Received' : 'Sent',
status: 'Linked', phone_no: phone, sender: 'sms@gigafibre.ca',
sender_full_name: callInfo.direction === 'in' ? (callInfo.remoteName || phone) : 'Targo Ops',
content: `Appel ${callInfo.direction === 'in' ? 'recu de' : 'vers'} ${phone} — Duree: ${durationStr}`,
reference_doctype: 'Customer', reference_name: props.customerName,
})
await loadCommunications()
} catch {}
}
function channelIcon (entry) { return CHANNEL_ICONS[entry.channel] || 'chat' }
function entryClass (entry) {
return [
'entry-' + entry.channel,
entry.direction === 'in' ? 'entry-inbound' : entry.direction === 'out' ? 'entry-outbound' : 'entry-internal',
entry.status === 'Open' ? 'entry-unread' : '',
]
}
function stripHtml (html) {
if (!html) return ''
const div = document.createElement('div'); div.innerHTML = html
return div.textContent || div.innerText || ''
}
function formatTime (dt) {
return dt ? new Date(dt).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) : ''
}
function dateLabel (dt) {
if (!dt) return ''
const d = new Date(dt), now = new Date()
if (d.toDateString() === now.toDateString()) return "Aujourd'hui"
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1)
if (d.toDateString() === yesterday.toDateString()) return 'Hier'
return d.toLocaleDateString('fr-CA', { weekday: 'short', month: 'short', day: 'numeric' })
}
onMounted(loadAll)
watch(() => props.customerName, loadAll)
onUnmounted(() => { sseDisconnect(); stopFallbackPoll() })
</script>
<style src="./chatter-panel.scss" scoped></style>

View File

@ -0,0 +1,172 @@
<template>
<div class="chatter-compose">
<div class="compose-channel-row">
<q-btn-toggle v-model="channel" no-caps dense unelevated size="xs"
toggle-color="indigo-6" color="grey-2" text-color="grey-7"
:options="composeOptions" />
<q-space />
<q-btn flat dense round size="xs" icon="bolt" color="amber-8" @click="$emit('manage-canned')" title="Réponses rapides">
<q-tooltip>Gérer les réponses rapides</q-tooltip>
</q-btn>
<div v-if="customerPhone" class="call-btn-group">
<q-btn flat dense round size="sm" icon="phone" color="green-7"
@click="$emit('call', 'twilio')" :loading="calling">
<q-tooltip>Appeler via Twilio (WebRTC)</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="sip" color="indigo-6"
@click="$emit('call', 'sip')" :loading="calling">
<q-tooltip>Appeler via SIP (Fonoster)</q-tooltip>
</q-btn>
</div>
</div>
<!-- Canned response dropdown -->
<div v-if="cannedMatches.length" class="canned-dropdown">
<div v-for="(cr, i) in cannedMatches" :key="i" class="canned-option"
:class="{ 'canned-highlighted': i === cannedHighlight }"
@mousedown.prevent="$emit('use-canned', cr)">
<span class="canned-shortcut-label">::{{ cr.shortcut }}</span>
<span class="canned-preview">{{ cr.text.slice(0, 60) }}{{ cr.text.length > 60 ? '…' : '' }}</span>
</div>
</div>
<!-- SMS -->
<div v-if="channel === 'sms'" class="compose-body">
<q-select v-if="phoneOptions.length > 1" v-model="smsTo" dense outlined emit-value map-options
:options="phoneOptions" style="font-size:0.8rem" class="q-mb-xs" />
<q-input v-model="text" dense outlined placeholder="SMS..."
:input-style="{ fontSize: '0.82rem' }" autogrow
@update:model-value="onTextChange"
@keydown.enter.exact.prevent="handleEnter"
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
@keydown.escape="cannedMatches.length ? closeCanned() : null">
<template #append>
<span class="text-caption text-grey-4 q-mr-xs">{{ text.length }}</span>
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
:disable="!text.trim() || !smsTo" :loading="sending" @click="$emit('send')" />
</template>
</q-input>
</div>
<!-- Email -->
<div v-if="channel === 'email'" class="compose-body">
<q-input v-model="emailSubject" dense outlined placeholder="Sujet"
:input-style="{ fontSize: '0.8rem' }" class="q-mb-xs" />
<q-input v-model="text" dense outlined placeholder="Message email..."
type="textarea" autogrow :input-style="{ fontSize: '0.82rem', minHeight: '50px' }"
@update:model-value="onTextChange"
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
@keydown.escape="cannedMatches.length ? closeCanned() : null">
<template #append>
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
:disable="!text.trim() || !emailSubject.trim()" :loading="sending" @click="$emit('send')" />
</template>
</q-input>
</div>
<!-- Note -->
<div v-if="channel === 'note'" class="compose-body">
<q-input v-model="text" dense outlined placeholder="Note interne..."
type="textarea" autogrow :input-style="{ fontSize: '0.82rem', minHeight: '40px' }"
@update:model-value="onTextChange"
@keydown.ctrl.enter="$emit('send')" @keydown.meta.enter="$emit('send')"
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
@keydown.escape="cannedMatches.length ? closeCanned() : null">
<template #append>
<q-btn flat dense round icon="note_add" color="amber-8" size="sm"
:disable="!text.trim()" :loading="sending" @click="$emit('send')" />
</template>
</q-input>
</div>
<!-- Phone -->
<div v-if="channel === 'phone'" class="compose-body">
<div class="row items-center q-gutter-sm">
<q-select v-model="callTo" dense outlined emit-value map-options
:options="phoneOptions" style="font-size:0.8rem; flex:1" label="Appeler" />
<q-btn unelevated dense color="green-7" icon="phone" label="Appeler"
:disable="!callTo" :loading="calling" @click="$emit('call')" />
</div>
<div class="text-caption text-grey-5 q-mt-xs">
L'appel connectera votre poste au client via Twilio.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const composeOptions = [
{ label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Appel', value: 'phone', icon: 'phone' },
{ label: 'Email', value: 'email', icon: 'email' },
{ label: 'Note', value: 'note', icon: 'sticky_note_2' },
]
const props = defineProps({
customerPhone: { type: String, default: '' },
phoneOptions: { type: Array, default: () => [] },
sending: Boolean,
calling: Boolean,
cannedResponses: { type: Array, default: () => [] },
})
const emit = defineEmits(['send', 'call', 'use-canned', 'manage-canned'])
const channel = defineModel('channel', { type: String, default: 'sms' })
const text = defineModel('text', { type: String, default: '' })
const emailSubject = defineModel('emailSubject', { type: String, default: '' })
const smsTo = defineModel('smsTo', { type: String, default: '' })
const callTo = defineModel('callTo', { type: String, default: '' })
// Canned response detection
const cannedQuery = ref('')
const cannedHighlight = ref(0)
const cannedMatches = computed(() => {
if (!cannedQuery.value) return []
const q = cannedQuery.value.toLowerCase()
return props.cannedResponses.filter(cr =>
cr.shortcut.toLowerCase().startsWith(q) || cr.text.toLowerCase().includes(q)
).slice(0, 6)
})
function onTextChange (val) {
// Detect :: prefix for canned response
const match = val?.match(/::(\S*)$/)
if (match) {
cannedQuery.value = match[1]
cannedHighlight.value = 0
} else {
cannedQuery.value = ''
}
}
function handleEnter () {
if (cannedMatches.value.length) {
selectCanned(cannedMatches.value[cannedHighlight.value])
} else {
emit('send')
}
}
function selectCanned (cr) {
// Replace the ::query with the canned text
text.value = text.value.replace(/::(\S*)$/, cr.text)
cannedQuery.value = ''
emit('use-canned', cr)
}
function cannedDown () {
if (cannedMatches.value.length) {
cannedHighlight.value = (cannedHighlight.value + 1) % cannedMatches.value.length
}
}
function cannedUp () {
if (cannedMatches.value.length) {
cannedHighlight.value = cannedHighlight.value <= 0 ? cannedMatches.value.length - 1 : cannedHighlight.value - 1
}
}
function closeCanned () { cannedQuery.value = '' }
</script>

View File

@ -22,14 +22,14 @@
<div class="notify-section q-mt-sm">
<div class="notify-header" @click="notifyExpanded = !notifyExpanded">
<q-icon :name="notifyExpanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" />
<q-icon name="notifications" size="16px" color="green-5" class="q-mr-xs" />
<q-icon name="notifications" size="16px" color="indigo-5" class="q-mr-xs" />
<span class="text-caption text-weight-medium text-grey-7">Envoyer notification</span>
<q-space />
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
</div>
<div v-show="notifyExpanded" class="notify-body">
<q-btn-toggle v-model="channel" no-caps dense unelevated size="sm" class="q-mb-xs full-width"
toggle-color="primary" color="grey-3" text-color="grey-8"
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
:options="[
{ label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Email', value: 'email', icon: 'email' },
@ -48,7 +48,7 @@
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
<q-space />
<q-btn unelevated dense size="sm" :label="channel === 'sms' ? 'Envoyer SMS' : 'Envoyer Email'"
color="primary" icon="send"
color="indigo-6" icon="send"
:disable="!canSend" :loading="sending" @click="sendNotification" />
</div>
</div>

View File

@ -5,12 +5,12 @@
<div class="col-auto">
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
</div>
<div class="col" style="min-width:0">
<div class="text-h5 text-weight-bold ellipsis" style="line-height:1.2">
<div class="col">
<div class="text-h5 text-weight-bold" style="line-height:1.2">
<InlineField :value="customer.customer_name" field="customer_name" doctype="Customer" :docname="customer.name"
placeholder="Nom du client" @saved="v => customer.customer_name = v.value" />
</div>
<div class="text-caption text-grey-6 row items-center q-gutter-x-xs" style="flex-wrap:wrap">
<div class="text-caption text-grey-6 row items-center no-wrap q-gutter-x-xs">
<span>{{ customer.name }}</span>
<template v-if="customer.legacy_customer_id"><span>&middot; Legacy: {{ customer.legacy_customer_id }}</span></template>
<span>&middot;</span>
@ -27,18 +27,23 @@
</template>
</div>
</div>
<div class="col-12 col-sm-auto text-right">
<div class="col-auto text-right">
<q-select v-model="customerStatus" dense borderless
:options="statusOptions" emit-value map-options
input-class="editable-input text-weight-bold"
:input-style="{ color: statusColor }"
style="min-width:100px;max-width:140px;text-align:right"
@update:model-value="saveStatus" />
<div class="q-mt-xs q-gutter-x-xs">
<q-btn flat dense size="xs" icon="open_in_new" label="ERPNext"
:href="erpDeskUrl + '/app/customer/' + customer.name" target="_blank"
class="text-grey-6" no-caps />
<q-btn flat dense size="xs" icon="manage_accounts" label="Users"
:href="erpDeskUrl + '/app/user?customer=' + customer.name" target="_blank"
class="text-grey-6" no-caps />
</div>
</div>
</div>
<!-- Actions de contact (message / appel / récompense) tout en haut -->
<slot name="actions" />
<!-- Contact & Info (collapsible) -->
<q-expansion-item v-model="contactOpen" dense header-class="contact-toggle-header" class="q-mt-xs">
@ -67,6 +72,7 @@
<script setup>
import { ref, computed } from 'vue'
import InlineField from 'src/components/shared/InlineField.vue'
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
import { updateDoc } from 'src/api/erp'
const contactOpen = ref(false)

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