Compare commits
No commits in common. "feat/dispatch-legacy-writeback" and "main" have entirely different histories.
feat/dispa
...
main
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
@ -5,10 +5,6 @@
|
||||||
apps/**/.env
|
apps/**/.env
|
||||||
apps/**/.env.local
|
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
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|
@ -55,7 +51,3 @@ services/targo-hub/templates/*.bak-*.html
|
||||||
# Legacy refresh creds (prod-only, never commit)
|
# Legacy refresh creds (prod-only, never commit)
|
||||||
.refresh.env
|
.refresh.env
|
||||||
**/.refresh.env
|
**/.refresh.env
|
||||||
|
|
||||||
# Python bytecode
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@ echo "==> Installing dependencies..."
|
||||||
npm ci --silent
|
npm ci --silent
|
||||||
|
|
||||||
echo "==> Building PWA (base=/ for portal.gigafibre.ca)..."
|
echo "==> Building PWA (base=/ for portal.gigafibre.ca)..."
|
||||||
# Do NOT bake an ERPNext token into the portal bundle. Any direct ERPNext
|
# VITE_ERP_TOKEN is still needed by a few API calls that hit ERPNext
|
||||||
# calls (catalog, invoice PDFs) must go through a server-side token proxy.
|
# directly (catalog, invoice PDFs). TODO: migrate these behind the hub.
|
||||||
DEPLOY_BASE=/ npx quasar build -m pwa
|
VITE_ERP_TOKEN="***ERP-TOKEN-REDACTED-20260616***" DEPLOY_BASE=/ npx quasar build -m pwa
|
||||||
|
|
||||||
if [ "${1:-}" = "local" ]; then
|
if [ "${1:-}" = "local" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,8 @@
|
||||||
*/
|
*/
|
||||||
const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
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) {
|
async function hubGet (path) {
|
||||||
const r = await fetch(HUB + path, { headers: authHeaders() })
|
const r = await fetch(HUB + path)
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const data = await r.json().catch(() => ({}))
|
const data = await r.json().catch(() => ({}))
|
||||||
throw new Error(data.error || `Hub ${r.status}`)
|
throw new Error(data.error || `Hub ${r.status}`)
|
||||||
|
|
@ -24,7 +15,7 @@ async function hubGet (path) {
|
||||||
async function hubPost (path, body) {
|
async function hubPost (path, body) {
|
||||||
const r = await fetch(HUB + path, {
|
const r = await fetch(HUB + path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
const data = await r.json().catch(() => ({}))
|
const data = await r.json().catch(() => ({}))
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@ import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getPortalUser } from 'src/api/portal'
|
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.
|
* Customer session store.
|
||||||
*
|
*
|
||||||
|
|
@ -64,20 +60,17 @@ export const useCustomerStore = defineStore('customer', () => {
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const customerId = ref('')
|
const customerId = ref('')
|
||||||
const customerName = ref('')
|
const customerName = ref('')
|
||||||
const token = ref('')
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
function hydrateFromToken () {
|
function hydrateFromToken () {
|
||||||
const t = readTokenFromLocation()
|
const token = readTokenFromLocation()
|
||||||
if (!t) return false
|
if (!token) return false
|
||||||
const payload = decodeJwtPayload(t)
|
const payload = decodeJwtPayload(token)
|
||||||
if (!payload || payload.scope !== 'customer') return false
|
if (!payload || payload.scope !== 'customer') return false
|
||||||
customerId.value = payload.sub
|
customerId.value = payload.sub
|
||||||
customerName.value = payload.name || payload.sub
|
customerName.value = payload.name || payload.sub
|
||||||
email.value = payload.email || ''
|
email.value = payload.email || ''
|
||||||
token.value = t
|
|
||||||
try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* private mode */ }
|
|
||||||
stripTokenFromUrl()
|
stripTokenFromUrl()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -89,24 +82,6 @@ export const useCustomerStore = defineStore('customer', () => {
|
||||||
// Path 1: magic-link token in URL
|
// Path 1: magic-link token in URL
|
||||||
if (hydrateFromToken()) return
|
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)
|
// Already authenticated in this tab? (e.g. subsequent nav)
|
||||||
if (customerId.value) return
|
if (customerId.value) return
|
||||||
|
|
||||||
|
|
@ -127,10 +102,8 @@ export const useCustomerStore = defineStore('customer', () => {
|
||||||
email.value = ''
|
email.value = ''
|
||||||
customerId.value = ''
|
customerId.value = ''
|
||||||
customerName.value = ''
|
customerName.value = ''
|
||||||
token.value = ''
|
|
||||||
error.value = null
|
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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
22
apps/field-tech/.gitignore
vendored
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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`.
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
101
apps/field-tech/android/.gitignore
vendored
|
|
@ -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
|
|
||||||
2
apps/field-tech/android/app/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
/build/*
|
|
||||||
!/build/.npmkeep
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
21
apps/field-tech/android/app/proguard-rules.pro
vendored
|
|
@ -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
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package ca.targo.field;
|
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
|
||||||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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')
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
248
apps/field-tech/android/gradlew
vendored
|
|
@ -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" "$@"
|
|
||||||
92
apps/field-tech/android/gradlew.bat
vendored
|
|
@ -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
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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'
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"appId": "ca.targo.field",
|
|
||||||
"appName": "Targo Tech",
|
|
||||||
"webDir": "www",
|
|
||||||
"server": { "androidScheme": "https" },
|
|
||||||
"plugins": {
|
|
||||||
"Geolocation": { "permissions": ["location"] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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}"
|
|
||||||
|
|
@ -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>
|
|
||||||
2346
apps/field-tech/package-lock.json
generated
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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 } })
|
|
||||||
|
|
@ -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
|
|
@ -1 +0,0 @@
|
||||||
public/tiles/
|
|
||||||
|
|
@ -150,11 +150,7 @@ createQuasarApp(createApp, quasarUserOptions)
|
||||||
|
|
||||||
return Promise[ method ]([
|
return Promise[ method ]([
|
||||||
|
|
||||||
import('boot/net-timeout'),
|
import('boot/pinia')
|
||||||
|
|
||||||
import('boot/pinia'),
|
|
||||||
|
|
||||||
import('boot/tooltip-ux')
|
|
||||||
|
|
||||||
]).then(bootFiles => {
|
]).then(bootFiles => {
|
||||||
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
|
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
|
||||||
|
|
|
||||||
|
|
@ -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} }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,29 +62,6 @@ else
|
||||||
|
|
||||||
rm -f /tmp/ops-build.tar.gz
|
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 ""
|
||||||
echo "Done! Targo Ops ($BUILD_MODE): https://erp.gigafibre.ca/ops/"
|
echo "Done! Targo Ops ($BUILD_MODE): https://erp.gigafibre.ca/ops/"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -6,52 +6,23 @@ server {
|
||||||
|
|
||||||
resolver 127.0.0.11 valid=30s;
|
resolver 127.0.0.11 valid=30s;
|
||||||
|
|
||||||
# Return Authentik user identity (email injected by Traefik forward-auth)
|
# ERPNext API proxy — token injected server-side (never in JS bundle)
|
||||||
location = /auth/whoami {
|
# To rotate: edit this file + docker restart ops-frontend
|
||||||
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.
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass https://erp.gigafibre.ca;
|
proxy_pass https://erp.gigafibre.ca;
|
||||||
proxy_ssl_verify off;
|
proxy_ssl_verify off;
|
||||||
proxy_set_header Host erp.gigafibre.ca;
|
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-Email $http_x_authentik_email;
|
||||||
proxy_set_header X-Authentik-Username $http_x_authentik_username;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ollama Vision API proxy (kept for legacy vision paths; primary OCR goes through the hub)
|
# NOTE: Ollama Vision proxy removed 2026-04-22 — invoice OCR and all
|
||||||
location /ollama/ {
|
# barcode/equipment scans now go directly to targo-hub (Gemini 2.5 Flash).
|
||||||
set $ollama_upstream http://ollama:11434;
|
# See docs/features/vision-ocr.md. The hub handles CORS + rate-limit, so no
|
||||||
proxy_pass $ollama_upstream/;
|
# nginx pass-through is needed here.
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
# SPA fallback — all routes serve index.html
|
# SPA fallback — all routes serve index.html
|
||||||
location / {
|
location / {
|
||||||
|
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
1006
apps/ops/package-lock.json
generated
|
|
@ -7,28 +7,19 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "quasar dev",
|
"dev": "quasar dev",
|
||||||
"build": "quasar build -m pwa",
|
"build": "quasar build -m pwa",
|
||||||
"lint": "eslint --ext .js,.vue ./src",
|
"lint": "eslint --ext .js,.vue ./src"
|
||||||
"knip": "npx --yes knip"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@quasar/extras": "^1.16.12",
|
"@quasar/extras": "^1.16.12",
|
||||||
"@twilio/voice-sdk": "^2.18.1",
|
"@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",
|
"chart.js": "^4.5.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.33.2",
|
||||||
"dompurify": "^3.4.11",
|
|
||||||
"grapesjs": "^0.22.16",
|
"grapesjs": "^0.22.16",
|
||||||
"grapesjs-mjml": "^1.0.8",
|
"grapesjs-mjml": "^1.0.8",
|
||||||
"grapesjs-preset-newsletter": "^1.0.2",
|
"grapesjs-preset-newsletter": "^1.0.2",
|
||||||
"hy-vue-gantt": "^5.2.1",
|
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-vue-next": "^1.0.0",
|
"lucide-vue-next": "^1.0.0",
|
||||||
"maplibre-gl": "^5.24.0",
|
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pmtiles": "^4.4.1",
|
|
||||||
"protomaps-themes-base": "^4.5.0",
|
|
||||||
"quasar": "^2.16.10",
|
"quasar": "^2.16.10",
|
||||||
"sip.js": "^0.21.2",
|
"sip.js": "^0.21.2",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,9 @@
|
||||||
/* eslint-env node */
|
/* eslint-env node */
|
||||||
const { configure } = require('quasar/wrappers')
|
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 () {
|
module.exports = configure(function () {
|
||||||
return {
|
return {
|
||||||
boot: ['net-timeout', 'pinia', 'tooltip-ux'],
|
boot: ['pinia'],
|
||||||
|
|
||||||
css: ['app.scss', 'tech.scss'],
|
css: ['app.scss', 'tech.scss'],
|
||||||
|
|
||||||
|
|
@ -19,7 +11,7 @@ module.exports = configure(function () {
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
target: {
|
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',
|
node: 'node20',
|
||||||
},
|
},
|
||||||
vueRouterMode: 'hash',
|
vueRouterMode: 'hash',
|
||||||
|
|
@ -42,20 +34,11 @@ module.exports = configure(function () {
|
||||||
target: 'https://erp.gigafibre.ca',
|
target: 'https://erp.gigafibre.ca',
|
||||||
changeOrigin: true,
|
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: {
|
framework: {
|
||||||
// Aligne les couleurs Quasar sur la palette de marque (vert). primary = accent (--ops-accent).
|
config: {},
|
||||||
// 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' } },
|
|
||||||
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
|
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useAuthStore } from 'src/stores/auth'
|
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 auth = useAuthStore()
|
||||||
const { isLoaded, loading: permsLoading, error: permsError } = usePermissions()
|
onMounted(() => auth.checkSession())
|
||||||
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)
|
|
||||||
})
|
|
||||||
</script>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,3 @@ export const campingsUpsert = (body) => jpost('/address/conformity/campings', bo
|
||||||
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
|
export const campingsApply = () => jpost('/address/conformity/campings/apply', {})
|
||||||
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
|
// Dernier repli : placer les unmatched restants au centre du code postal (sinon ville) → statut 'area'
|
||||||
export const conformityApplyArea = () => jpost('/address/conformity/apply-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))
|
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,9 @@ import { BASE_URL } from 'src/config/erpnext'
|
||||||
// Only needed for local dev (VITE_ERP_TOKEN in .env).
|
// Only needed for local dev (VITE_ERP_TOKEN in .env).
|
||||||
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || ''
|
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).
|
// Reconnexion auto sur session Authentik expirée, anti-boucle (1 reload / 20 s max).
|
||||||
let _lastReload = 0
|
let _lastReload = 0
|
||||||
function _reauth (status) {
|
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()
|
const now = Date.now()
|
||||||
if (now - _lastReload > 20000) {
|
if (now - _lastReload > 20000) {
|
||||||
_lastReload = now
|
_lastReload = now
|
||||||
|
|
@ -36,23 +24,18 @@ export async function authFetch (url, opts = {}) {
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, opts)
|
res = await fetch(url, opts)
|
||||||
} catch (e) {
|
} 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))
|
await new Promise(r => setTimeout(r, 800))
|
||||||
try {
|
|
||||||
res = await fetch(url, opts)
|
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") :
|
// Détection « session Authentik expirée » (la cause du "il faut recharger") :
|
||||||
// - 401/403 explicites
|
// - 401/403 explicites
|
||||||
// - réponse redirigée vers l'IdP (auth.targo.ca / /if/flow/)
|
// - 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 ct = (res.headers && res.headers.get && res.headers.get('content-type')) || ''
|
||||||
const redirectedToIdp = res.redirected && /auth\.targo\.ca|\/if\/flow\//.test(res.url || '')
|
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) {
|
if (res.status === 401 || res.status === 403 || redirectedToIdp || htmlForApi) {
|
||||||
return _reauth(res.status)
|
return _reauth(res.status)
|
||||||
}
|
}
|
||||||
|
|
@ -60,19 +43,12 @@ export async function authFetch (url, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLoggedUser () {
|
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)
|
// First try nginx whoami endpoint (returns Authentik email header)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(BASE_URL + '/auth/whoami')
|
const res = await fetch(BASE_URL + '/auth/whoami')
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
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
|
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 {}
|
} catch {}
|
||||||
// Fallback: ask ERPNext (returns API token owner, usually "Administrator")
|
// Fallback: ask ERPNext (returns API token owner, usually "Administrator")
|
||||||
|
|
@ -88,9 +64,5 @@ export async function getLoggedUser () {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout () {
|
export async function logout () {
|
||||||
// Déconnexion Authentik PUIS retour sur OPS : le flow d'invalidation honore `?next=` → il renvoie vers OPS,
|
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 } })
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
// Permanent deletion — removes the JSON on the hub. Used for clearing
|
||||||
// test campaigns from the list. Giftbit shortlinks are unaffected.
|
// test campaigns from the list. Giftbit shortlinks are unaffected.
|
||||||
export function deleteCampaign (id) {
|
export function deleteCampaign (id) {
|
||||||
|
|
@ -92,37 +84,6 @@ export function listGifts () {
|
||||||
return hubFetch('/campaigns/gifts').then(r => r.gifts || [])
|
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
|
// Kill switch — manually expire a single recipient's wrapper token so
|
||||||
// the underlying Giftbit URL becomes reassignable before the natural
|
// the underlying Giftbit URL becomes reassignable before the natural
|
||||||
// expiry date.
|
// expiry date.
|
||||||
|
|
@ -231,10 +192,10 @@ export function translateTemplate (srcName, targetName, { override = false } = {
|
||||||
|
|
||||||
// Send ONE rendered email to a specific address for visual QA.
|
// Send ONE rendered email to a specific address for visual QA.
|
||||||
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
|
// 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`, {
|
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { to, vars, from, subject, via },
|
body: { to, vars, from, subject },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,8 @@
|
||||||
// Swap BASE_URL in config/erpnext.js to change the target server.
|
// Swap BASE_URL in config/erpnext.js to change the target server.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
import { HUB_URL } from 'src/config/hub'
|
|
||||||
import { authFetch } from './auth'
|
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) {
|
async function apiGet (path) {
|
||||||
const res = await authFetch(BASE_URL + path)
|
const res = await authFetch(BASE_URL + path)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
@ -98,30 +76,6 @@ export async function updateJob (name, payload) {
|
||||||
return apiPut('Dispatch Job', 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) {
|
export async function createJob (payload) {
|
||||||
const res = await authFetch(
|
const res = await authFetch(
|
||||||
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
`${BASE_URL}/api/resource/Dispatch%20Job`,
|
||||||
|
|
|
||||||
|
|
@ -83,24 +83,13 @@ export async function deleteDoc (doctype, name) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count documents. ⚠️ frappe.client.get_count IGNORE or_filters (recherche OR) → il renverrait un compte
|
// Count documents
|
||||||
// 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.
|
|
||||||
export async function countDocs (doctype, filters = {}, or_filters) {
|
export async function countDocs (doctype, filters = {}, or_filters) {
|
||||||
if (or_filters && or_filters.length) {
|
const params = new URLSearchParams({
|
||||||
const p = new URLSearchParams({
|
|
||||||
doctype,
|
doctype,
|
||||||
filters: JSON.stringify(filters),
|
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 (or_filters) params.set('or_filters', JSON.stringify(or_filters))
|
||||||
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) })
|
|
||||||
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
|
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
|
||||||
if (!res.ok) return 0
|
if (!res.ok) return 0
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
|
||||||
|
|
@ -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' }) }
|
|
||||||
|
|
@ -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' })
|
|
||||||
|
|
@ -91,23 +91,3 @@ export function overpricedInternetCsvUrl ({ threshold = 90, segment = 'residenti
|
||||||
})
|
})
|
||||||
return `${HUB}/reports/legacy/overpriced-internet.csv?${qs}`
|
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}`
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,48 +5,33 @@
|
||||||
*/
|
*/
|
||||||
import { HUB_URL as HUB } from 'src/config/hub'
|
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
|
async function jget (path) {
|
||||||
// laisser l'interface figée (ex. « Suggérer » qui tourne sans fin). Au-delà, on lève → repli local.
|
const r = await fetch(HUB + path)
|
||||||
async function _fetch (path, init = {}, ms = 45000) {
|
if (!r.ok) throw new Error('Roster API ' + r.status)
|
||||||
const ctl = new AbortController()
|
return r.json()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return await r.json()
|
async function jpost (path, body) {
|
||||||
} catch (e) {
|
const r = await fetch(HUB + path, {
|
||||||
if (e && e.name === 'AbortError') throw new Error('Roster API timeout (' + Math.round(ms / 1000) + 's) — serveur trop lent ou injoignable')
|
method: 'POST',
|
||||||
throw e
|
headers: { 'Content-Type': 'application/json' },
|
||||||
} finally { clearTimeout(timer) }
|
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 listTechnicians = () => jget('/roster/technicians')
|
||||||
export const listTemplates = () => jget('/roster/templates')
|
export const listTemplates = () => jget('/roster/templates')
|
||||||
export const createTemplate = (t) => jpost('/roster/templates', t)
|
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 listRequirements = (start, days = 7) => jget(`/roster/requirements?start=${start}&days=${days}`)
|
||||||
export const createRequirement = (r) => jpost('/roster/requirements', r)
|
export const createRequirement = (r) => jpost('/roster/requirements', r)
|
||||||
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
|
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}`)
|
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 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 getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
|
||||||
export const getAbsences = (start, days = 7) => jget(`/roster/absences?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 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 generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
|
||||||
export const publish = (assignments) => jpost('/roster/publish', { assignments })
|
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 })
|
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })
|
||||||
// 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 updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
|
export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
|
||||||
|
|
||||||
// ── Copilote (Gemini Flash) + politique de reprise ──
|
// ── Copilote (Gemini Flash) + politique de reprise ──
|
||||||
export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history })
|
export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history })
|
||||||
export const getPolicy = () => jget('/roster/policy')
|
export const getPolicy = () => jget('/roster/policy')
|
||||||
export const savePolicy = (policy) => jpost('/roster/policy', 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) {
|
export async function deleteShiftTemplate (name) {
|
||||||
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||||
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
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 requestAvailability = (a) => jpost('/roster/availability', a)
|
||||||
export const approveAvailability = (name, body) => jpost('/roster/availability/' + encodeURIComponent(name) + '/approve', body || {})
|
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 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 setTechEfficiency = (id, efficiency) => jpost(`/roster/technician/${encodeURIComponent(id)}/efficiency`, { efficiency })
|
||||||
export const setTechCost = (id, body) => jpost(`/roster/technician/${encodeURIComponent(id)}/cost`, body)
|
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) }
|
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 })
|
export const redistributePlan = (plan) => jpost('/roster/skill-impact/redistribute', { plan })
|
||||||
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
|
// Jobs non assignés (+ groupe/dépendances) pour le panneau glisser-déposer
|
||||||
export const unassignedJobs = () => jget('/roster/unassigned-jobs')
|
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)
|
// 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
|
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
|
// 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))
|
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? }]
|
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
|
||||||
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
|
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 })
|
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? }
|
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
|
||||||
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
|
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 || '' })
|
|
||||||
|
|
|
||||||
|
|
@ -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', {})
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -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 */ }
|
|
||||||
})
|
|
||||||
544
apps/ops/src/components/customer/ChatterPanel.vue
Normal 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"> · {{ 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>
|
||||||
172
apps/ops/src/components/customer/ComposeBar.vue
Normal 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>
|
||||||
|
|
@ -22,14 +22,14 @@
|
||||||
<div class="notify-section q-mt-sm">
|
<div class="notify-section q-mt-sm">
|
||||||
<div class="notify-header" @click="notifyExpanded = !notifyExpanded">
|
<div class="notify-header" @click="notifyExpanded = !notifyExpanded">
|
||||||
<q-icon :name="notifyExpanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" />
|
<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>
|
<span class="text-caption text-weight-medium text-grey-7">Envoyer notification</span>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
|
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="notifyExpanded" class="notify-body">
|
<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"
|
<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="[
|
:options="[
|
||||||
{ label: 'SMS', value: 'sms', icon: 'sms' },
|
{ label: 'SMS', value: 'sms', icon: 'sms' },
|
||||||
{ label: 'Email', value: 'email', icon: 'email' },
|
{ label: 'Email', value: 'email', icon: 'email' },
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
|
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn unelevated dense size="sm" :label="channel === 'sms' ? 'Envoyer SMS' : 'Envoyer Email'"
|
<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" />
|
:disable="!canSend" :loading="sending" @click="sendNotification" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
|
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col" style="min-width:0">
|
<div class="col">
|
||||||
<div class="text-h5 text-weight-bold ellipsis" style="line-height:1.2">
|
<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"
|
<InlineField :value="customer.customer_name" field="customer_name" doctype="Customer" :docname="customer.name"
|
||||||
placeholder="Nom du client" @saved="v => customer.customer_name = v.value" />
|
placeholder="Nom du client" @saved="v => customer.customer_name = v.value" />
|
||||||
</div>
|
</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>
|
<span>{{ customer.name }}</span>
|
||||||
<template v-if="customer.legacy_customer_id"><span>· Legacy: {{ customer.legacy_customer_id }}</span></template>
|
<template v-if="customer.legacy_customer_id"><span>· Legacy: {{ customer.legacy_customer_id }}</span></template>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
|
|
@ -27,18 +27,23 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-auto text-right">
|
<div class="col-auto text-right">
|
||||||
<q-select v-model="customerStatus" dense borderless
|
<q-select v-model="customerStatus" dense borderless
|
||||||
:options="statusOptions" emit-value map-options
|
:options="statusOptions" emit-value map-options
|
||||||
input-class="editable-input text-weight-bold"
|
input-class="editable-input text-weight-bold"
|
||||||
:input-style="{ color: statusColor }"
|
:input-style="{ color: statusColor }"
|
||||||
style="min-width:100px;max-width:140px;text-align:right"
|
style="min-width:100px;max-width:140px;text-align:right"
|
||||||
@update:model-value="saveStatus" />
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions de contact (message / appel / récompense) — tout en haut -->
|
|
||||||
<slot name="actions" />
|
|
||||||
|
|
||||||
<!-- Contact & Info (collapsible) -->
|
<!-- Contact & Info (collapsible) -->
|
||||||
<q-expansion-item v-model="contactOpen" dense header-class="contact-toggle-header" class="q-mt-xs">
|
<q-expansion-item v-model="contactOpen" dense header-class="contact-toggle-header" class="q-mt-xs">
|
||||||
|
|
@ -67,6 +72,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import InlineField from 'src/components/shared/InlineField.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'
|
import { updateDoc } from 'src/api/erp'
|
||||||
|
|
||||||
const contactOpen = ref(false)
|
const contactOpen = ref(false)
|
||||||
|
|
|
||||||