feat: add ops app + CONTEXT.md, simplify URL to /ops/

Ops app (Vue/Quasar PWA) with dispatch V2 integration, tag system,
customer 360, tickets, and dashboard. Served via standalone nginx
container at erp.gigafibre.ca/ops/ with Traefik StripPrefix + Authentik SSO.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-30 22:41:58 -04:00
parent 08cf1c94e3
commit 13dcd4bf77
76 changed files with 20668 additions and 0 deletions

151
CONTEXT.md Normal file
View File

@ -0,0 +1,151 @@
# Targo/Gigafibre FSM — Context for Claude Cowork
> Last updated: 2026-03-30
## Project Overview
Targo Internet is a fiber ISP in Quebec. Gigafibre is the consumer brand. We're migrating from a legacy PHP/MariaDB system to ERPNext v16 + custom Vue.js (Quasar) apps.
## Architecture
```
Server: 96.125.196.67 (Proxmox VM)
Traefik v2.11 (80/443) → Docker containers:
erp.gigafibre.ca → ERPNext v16.10.1 (PostgreSQL, 9 containers)
erp.gigafibre.ca/ops/ → Ops PWA (Quasar/Vue3, Authentik SSO via forwardAuth)
dispatch.gigafibre.ca → Legacy dispatch app (being replaced by ops /dispatch)
auth.targo.ca → Authentik SSO
oss.gigafibre.ca → Oktopus CE (TR-069)
git.targo.ca → Gitea
n8n.gigafibre.ca → n8n workflows
```
## Codebase Layout
```
gigafibre-fsm/
├── apps/
│ ├── ops/ ← Main ops PWA (Quasar v2 + Vite)
│ │ ├── src/
│ │ │ ├── api/
│ │ │ │ ├── auth.js # authFetch with token: b273a666c86d2d0:06120709db5e414
│ │ │ │ ├── dispatch.js # CRUD for Dispatch Job, Tech, Tag + rename/delete
│ │ │ │ ├── service-request.js # ServiceRequest, ServiceBid, EquipmentInstall
│ │ │ │ └── traccar.js # GPS tracking (Traccar API)
│ │ │ ├── components/
│ │ │ │ ├── shared/
│ │ │ │ │ ├── TagEditor.vue # Universal inline tag editor (autocomplete, color, level, required)
│ │ │ │ │ └── TagInput.vue # Old tag input (deprecated, replaced by TagEditor)
│ │ │ │ └── customer/ # CustomerHeader, CustomerInfoCard, etc.
│ │ │ ├── composables/ # useHelpers, useScheduler, useMap, useDragDrop, etc.
│ │ │ ├── config/
│ │ │ │ └── erpnext.js # BASE_URL='', MAPBOX_TOKEN, TECH_COLORS
│ │ │ ├── modules/
│ │ │ │ └── dispatch/
│ │ │ │ └── components/ # TimelineRow, BottomPanel, JobEditModal, RightPanel, etc.
│ │ │ ├── pages/
│ │ │ │ ├── DispatchPage.vue # Full-screen dispatch V2 (1600+ lines)
│ │ │ │ ├── ClientDetailPage.vue
│ │ │ │ ├── ClientsPage.vue
│ │ │ │ ├── TicketsPage.vue
│ │ │ │ └── ...
│ │ │ ├── stores/
│ │ │ │ ├── dispatch.js # Pinia store: technicians, jobs, allTags
│ │ │ │ └── auth.js
│ │ │ └── router/index.js # / = MainLayout, /dispatch = standalone
│ │ └── deploy.sh # Build + deploy to erp.gigafibre.ca
│ └── dispatch/ # Legacy standalone dispatch app (deprecated)
├── scripts/migration/ # Python migration scripts (tickets, customers, etc.)
└── CONTEXT.md # This file
```
## Key ERPNext Custom Doctypes
### Dispatch Job
- `ticket_id`, `subject`, `customer` (Link→Customer), `service_location` (Link→Service Location)
- `job_type` (Select: Installation/Réparation/Maintenance/Retrait/Dépannage/Autre)
- `source_issue` (Link→Issue), `address`, `longitude`, `latitude`
- `priority` (low/medium/high), `duration_h`, `status` (open/assigned/in_progress/done)
- `assigned_tech` (Link→Dispatch Technician), `assigned_user` (Link→User)
- `scheduled_date`, `start_time`, `end_date`
- `tags` (Table→Dispatch Tag Link), `assistants` (Table→Dispatch Job Assistant)
- `equipment_items`, `materials_used`, `checklist`, `photos`, `customer_signature`
- `actual_start`, `actual_end`, `travel_time_min`, `completion_notes`
### Dispatch Technician
- `technician_id`, `full_name`, `user` (Link→User), `phone`, `email`
- `status` (Disponible/En route/En pause/Hors ligne), `color_hex`
- `longitude`, `latitude`, `traccar_device_id`, `employee` (Link→Employee)
- `tags` (Table→Dispatch Tag Link)
### Dispatch Tag
- `label`, `color` (hex), `category` (Skill/Service/Region/Equipment/Custom)
- Current tags: Fibre (#3b82f6), Téléphonie (#f59e0b), TV (#06b6d4), Installation (#06b6d4), Fusionneur (#f59e0b), Monteur (#8b5cf6), Câblage (#10b981), Caméra IP (#a855f7), Garage (#78716c), Urgence (#ef4444), Rive-Sud (#14b8a6), Montréal (#06b6d4)
### Dispatch Tag Link (child table)
- `tag` (Link→Dispatch Tag), `level` (Int, default 0), `required` (Check, default 0)
- On **techs**: level = skill proficiency (1=base, 5=expert)
- On **jobs**: level = minimum required proficiency, required = mandatory for dispatch matching
### Issue (ERPNext standard + custom fields)
- `legacy_ticket_id`, `assigned_staff` (group name e.g. "Tech Targo"), `opened_by_staff`
- `issue_type`: "Reparation Fibre", "Installation Fibre", "Install/Reparation Télé", "Téléphonie", "Télévision", "Monteur", "Fusionneur", "Installation", "Support", "ToDo", "Projet", "Conception"
- `is_incident`, `impact_zone`, `affected_clients`, `parent_incident`, `is_important`
- `service_location` (Link→Service Location)
## Auth Pattern
All API calls use token auth via `authFetch()`:
```js
Authorization: token b273a666c86d2d0:06120709db5e414
```
Authentik SSO protects the ops app at Traefik level (forwardAuth). The token is baked into the build via `VITE_ERP_TOKEN`.
## Tag/Skill System — Auto-Dispatch Logic (designed, not yet wired)
The dispatch system uses a cost-optimization model inspired by AI agent routing:
1. Each **job** has tags with `required` flag and `level` (minimum skill needed)
2. Each **tech** has tags with `level` (skill proficiency 1-5)
3. Auto-dispatch: find techs where `tech.level >= job.requiredLevel` for all **required** tags
4. Among matches, pick the **lowest adequate** tech (closest skill to required level)
5. This preserves experts for complex jobs — don't send the level-5 splicer to a basic install
Example:
- Job: Fibre (required, level 3) → needs someone competent
- Tech A: Fibre level 5 (expert) — can do it but overkill
- Tech B: Fibre level 3 (adequate) — send this one, keep A free
## 20 Test Dispatch Jobs (2026-03-31)
Created from Tech Targo Issues. Tagged by issue_type:
- 6 jobs: Fibre only (Reparation Fibre)
- 4 jobs: Fibre + Installation (Installation/Installation Fibre)
- 10 jobs: TV + Téléphonie (Install/Reparation Télé)
## 46 Dispatch Technicians
All imported from legacy system. **Not yet tagged with skills** — need team breakdown.
## Deploy
```bash
cd apps/ops && bash deploy.sh # builds Quasar PWA + deploys to erp.gigafibre.ca
```
## Pending Work
1. **Tag technicians with skills** — assign Fibre/Télé/etc. tags + levels to 46 techs
2. **Wire auto-dispatch logic** — implement the matching algorithm in useAutoDispatch.js
3. **Ticket-to-dispatch UI** — button in ticket detail modal to create Dispatch Job from Issue
4. **Customer portal** — store.targo.ca/clients replacement with Stripe payments
5. **Field tech mobile app** — barcode scanner, equipment install, job completion
6. **Code optimization** — extract components from ClientDetailPage.vue (1500+ lines)
## ERPNext PostgreSQL Gotchas
- GROUP BY requires all selected columns (not just aggregates)
- HAVING clause needs explicit column references
- Double-quoted identifiers are case-sensitive
- Transaction abort cascades (one error blocks subsequent queries until rollback)
- Patches applied in `scripts/migration/` for bulk operations

56
apps/ops/.quasar/app.js Normal file
View File

@ -0,0 +1,56 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({})
: createRouter
)
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
router
}
}

View File

@ -0,0 +1 @@
{"folders":["/Users/louispaul/Documents/testap/gigafibre-fsm/apps/ops/dist/spa","/Users/louispaul/Documents/testap/gigafibre-fsm/apps/ops/dist/pwa"]}

View File

@ -0,0 +1,160 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.css'
import 'src/css/app.scss'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
import 'app/src-pwa/register-service-worker'
const publicPath = `/`
async function start ({
app,
router
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) {
return
}
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import('boot/pinia')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@ -0,0 +1,116 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@ -0,0 +1,21 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config.js > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import {Notify,Loading,LocalStorage,Dialog} from 'quasar'
export default { config: {},plugins: {Notify,Loading,LocalStorage,Dialog} }

57
apps/ops/deploy.sh Executable file
View File

@ -0,0 +1,57 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# deploy.sh — Build Targo Ops PWA and deploy to ops-frontend nginx container
#
# The ops app is served by a standalone nginx container (ops-frontend) at
# erp.gigafibre.ca/ops/. Traefik strips /ops prefix before proxying to nginx.
# Authentik protection is handled via Traefik forwardAuth middleware.
#
# Static files go to /opt/ops-app/ on the host, mounted into the container.
#
# Usage:
# ./deploy.sh # deploy to remote server (production)
# ./deploy.sh local # deploy to local Docker (development)
#
# Prerequisites (remote):
# - SSH key ~/.ssh/proxmox_vm for root@96.125.196.67
# - ops-frontend container running (see infra/docker-compose.yaml)
# ─────────────────────────────────────────────────────────────────────────────
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
SERVER="root@96.125.196.67"
SSH_KEY="$HOME/.ssh/proxmox_vm"
DEST="/opt/ops-app"
echo "==> Installing dependencies..."
npm ci --silent
echo "==> Building PWA (base=/ops/)..."
VITE_ERP_TOKEN="b273a666c86d2d0:06120709db5e414" DEPLOY_BASE=/ops/ npx quasar build -m pwa
if [ "$1" = "local" ]; then
# ── Local deploy ──
echo "==> Deploying to local $DEST..."
rm -rf "$DEST"/*
cp -r dist/pwa/* "$DEST/"
echo ""
echo "Done! Targo Ops: http://localhost/ops/"
else
# ── Remote deploy ──
echo "==> Packaging..."
tar czf /tmp/ops-pwa.tar.gz -C dist/pwa .
echo "==> Deploying to $SERVER..."
cat /tmp/ops-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \
"cat > /tmp/ops.tar.gz && \
rm -rf $DEST/*.js $DEST/*.html $DEST/*.json $DEST/assets $DEST/icons && \
cd $DEST && tar xzf /tmp/ops.tar.gz && \
rm -f /tmp/ops.tar.gz"
rm -f /tmp/ops-pwa.tar.gz
echo ""
echo "Done! Targo Ops: https://erp.gigafibre.ca/ops/"
fi

19
apps/ops/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Targo Ops</title>
<meta charset="utf-8">
<meta name="description" content="Targo Operations Platform">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

View File

@ -0,0 +1,47 @@
# Targo Ops — nginx container served at erp.gigafibre.ca/ops/
# Deploy: docker compose -f docker-compose.yaml up -d
#
# Requires:
# - Traefik proxy network
# - Authentik forwardAuth middleware in Traefik
#
# Routing:
# erp.gigafibre.ca/ops/* → Traefik → StripPrefix /ops → nginx (static SPA)
# erp.gigafibre.ca/api/* → ERPNext directly (same domain, no proxy needed)
services:
ops-frontend:
image: nginx:alpine
container_name: ops-frontend
restart: unless-stopped
volumes:
- /opt/ops-app:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- proxy
labels:
- "traefik.enable=true"
# Main router: erp.gigafibre.ca/ops/* with Authentik + StripPrefix
- "traefik.http.routers.ops.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/ops`)"
- "traefik.http.routers.ops.entrypoints=web,websecure"
- "traefik.http.routers.ops.middlewares=authentik@file,ops-strip@docker"
- "traefik.http.routers.ops.service=ops"
- "traefik.http.routers.ops.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops.priority=200"
# StripPrefix middleware (removes /ops before sending to nginx)
- "traefik.http.middlewares.ops-strip.stripprefix.prefixes=/ops"
- "traefik.http.middlewares.ops-strip.stripprefix.forceSlash=false"
# Authentik outpost callback (required for login redirect)
- "traefik.http.routers.ops-ak.rule=Host(`erp.gigafibre.ca`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.ops-ak.entrypoints=web,websecure"
- "traefik.http.routers.ops-ak.middlewares=authentik@file"
- "traefik.http.routers.ops-ak.service=ops"
- "traefik.http.routers.ops-ak.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops-ak.priority=250"
# Service
- "traefik.http.services.ops.loadbalancer.server.port=80"
- "traefik.docker.network=proxy"
networks:
proxy:
external: true

View File

@ -0,0 +1,102 @@
#!/bin/bash
# ─────────────────────────────────────────────────────────────────────────────
# migrate-to-erpnext.sh — One-time migration: serve Ops from ERPNext container
#
# Run on the server (96.125.196.67) as root.
# This script:
# 1. Stops and removes the standalone ops-frontend container
# 2. Patches ERPNext docker-compose to add Authentik Traefik labels
# 3. Recreates ERPNext frontend with new labels
#
# After running this, use deploy.sh to push new builds.
# ─────────────────────────────────────────────────────────────────────────────
set -e
# Find ERPNext compose file
ERP_COMPOSE=""
for p in /opt/erpnext/docker-compose.yml /opt/frappe_docker/docker-compose.yml; do
[ -f "$p" ] && ERP_COMPOSE="$p" && break
done
[ -z "$ERP_COMPOSE" ] && echo "ERROR: ERPNext docker-compose not found" && exit 1
ERP_DIR="$(dirname "$ERP_COMPOSE")"
echo "=== Targo Ops Infrastructure Simplification ==="
echo "Using: $ERP_COMPOSE"
echo ""
# ── Step 1: Stop ops-frontend container ──
echo "==> Step 1: Stopping ops-frontend container..."
if docker ps -a --format '{{.Names}}' | grep -q 'ops-frontend'; then
(cd /opt/ops-app/infra 2>/dev/null && docker compose down 2>/dev/null) || \
(docker stop ops-frontend 2>/dev/null; docker rm ops-frontend 2>/dev/null) || true
echo " Done."
else
echo " Already removed."
fi
# ── Step 2: Patch docker-compose.yml ──
echo ""
echo "==> Step 2: Adding Authentik labels to ERPNext frontend..."
if grep -q 'routers.ops-app' "$ERP_COMPOSE"; then
echo " Already present. Skipping."
else
cp "$ERP_COMPOSE" "${ERP_COMPOSE}.bak"
python3 <<PYEOF
import re
compose_path = "$ERP_COMPOSE"
with open(compose_path) as f:
content = f.read()
# Labels to inject
ops_labels = ''' # ── Ops SPA (Authentik-protected) ──
- "traefik.http.routers.ops-app.rule=Host(\`erp.gigafibre.ca\`) && PathPrefix(\`/assets/ops-app\`)"
- "traefik.http.routers.ops-app.entrypoints=web,websecure"
- "traefik.http.routers.ops-app.middlewares=authentik@file"
- "traefik.http.routers.ops-app.service=erp"
- "traefik.http.routers.ops-app.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops-app.priority=200"
# ── Authentik outpost callback ──
- "traefik.http.routers.ops-ak.rule=Host(\`erp.gigafibre.ca\`) && PathPrefix(\`/outpost.goauthentik.io/\`)"
- "traefik.http.routers.ops-ak.entrypoints=web,websecure"
- "traefik.http.routers.ops-ak.middlewares=authentik@file"
- "traefik.http.routers.ops-ak.service=erp"
- "traefik.http.routers.ops-ak.tls.certresolver=letsencrypt"
- "traefik.http.routers.ops-ak.priority=250"'''
# Find the last traefik label line and insert after it
lines = content.split('\\n')
last_traefik_idx = -1
for i, line in enumerate(lines):
if 'traefik.' in line and line.strip().startswith('-'):
last_traefik_idx = i
if last_traefik_idx == -1:
print("ERROR: No traefik labels found in compose file")
exit(1)
lines.insert(last_traefik_idx + 1, ops_labels)
with open(compose_path, 'w') as f:
f.write('\\n'.join(lines))
print(f" Injected after line {last_traefik_idx + 1}. Backup: {compose_path}.bak")
PYEOF
fi
# ── Step 3: Recreate ERPNext frontend ──
echo ""
echo "==> Step 3: Recreating ERPNext frontend..."
cd "$ERP_DIR"
docker compose up -d frontend
echo " Done."
echo ""
echo "=== Migration complete ==="
echo ""
echo "Deploy ops build: ./deploy.sh"
echo "Access: https://erp.gigafibre.ca/assets/ops-app/"
echo "Cleanup: rm -rf /opt/ops-app"
echo ""

25
apps/ops/infra/nginx.conf Normal file
View File

@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# No cache for index.html and service worker
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Cache static assets (30 days, immutable)
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

10015
apps/ops/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
apps/ops/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "ops-app",
"version": "0.1.0",
"description": "Targo Ops — Unified ISP operations platform",
"productName": "Targo Ops",
"private": true,
"scripts": {
"dev": "quasar dev",
"build": "quasar build -m pwa",
"lint": "eslint --ext .js,.vue ./src"
},
"dependencies": {
"@quasar/extras": "^1.16.12",
"pinia": "^2.1.7",
"quasar": "^2.16.10",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.10.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0",
"sass": "^1.72.0",
"workbox-build": "7.0.x",
"workbox-cacheable-response": "7.0.x",
"workbox-core": "7.0.x",
"workbox-expiration": "7.0.x",
"workbox-precaching": "7.0.x",
"workbox-routing": "7.0.x",
"workbox-strategies": "7.0.x"
},
"engines": {
"node": "^18 || ^20",
"npm": ">= 6.13.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.5 KiB

66
apps/ops/quasar.config.js Normal file
View File

@ -0,0 +1,66 @@
/* eslint-env node */
const { configure } = require('quasar/wrappers')
module.exports = configure(function () {
return {
boot: ['pinia'],
css: ['app.scss'],
extras: ['material-icons'],
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node20',
},
vueRouterMode: 'hash',
extendViteConf (viteConf) {
viteConf.base = process.env.DEPLOY_BASE || '/ops/'
},
},
devServer: {
open: false,
host: '0.0.0.0',
port: 9001,
proxy: {
'/api': {
target: 'https://erp.gigafibre.ca',
changeOrigin: true,
},
},
},
framework: {
config: {},
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
},
animations: [],
pwa: {
workboxMode: 'generateSW',
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialForManifestTag: false,
workboxOptions: {
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api\//],
},
extendManifestJson (json) {
json.name = 'Targo Ops'
json.short_name = 'Ops'
json.description = 'Targo Operations Platform'
json.display = 'standalone'
json.background_color = '#ffffff'
json.theme_color = '#1e293b'
json.start_url = '.'
},
},
}
})

View File

@ -0,0 +1,26 @@
/* eslint-env serviceworker */
import { clientsClaim } from 'workbox-core'
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
self.skipWaiting()
clientsClaim()
// Listen for skip waiting message from register-service-worker
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
if (process.env.MODE !== 'ssr' || process.env.PROD) {
registerRoute(
new NavigationRoute(
createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML),
{ denylist: [/sw\.js$/, /workbox-(.)*\.js$/] }
)
)
}

View File

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

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

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

View File

@ -0,0 +1,20 @@
import { register } from 'register-service-worker'
register(process.env.SERVICE_WORKER_FILE, {
ready () {},
registered (reg) {
// Check for updates every 5 minutes
setInterval(() => { reg.update() }, 5 * 60 * 1000)
},
cached () {},
updatefound () {},
updated (reg) {
// New service worker available — activate it and reload
if (reg && reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' })
}
window.location.reload()
},
offline () {},
error () {}
})

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

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

40
apps/ops/src/api/auth.js Normal file
View File

@ -0,0 +1,40 @@
import { BASE_URL } from 'src/config/erpnext'
const SERVICE_TOKEN = import.meta.env.VITE_ERP_TOKEN || window.__ERP_TOKEN__ || ''
export function authFetch (url, opts = {}) {
opts.headers = {
...opts.headers,
Authorization: 'token ' + SERVICE_TOKEN,
}
opts.redirect = 'manual'
// For state-changing requests, omit cookies to avoid CSRF check
// (token auth doesn't require CSRF, but session cookies trigger it)
if (opts.method && opts.method !== 'GET') {
opts.credentials = 'omit'
}
return fetch(url, opts).then(res => {
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) {
window.location.reload()
return new Response('{}', { status: 401 })
}
return res
})
}
export async function getLoggedUser () {
try {
const res = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user', {
headers: { Authorization: 'token ' + SERVICE_TOKEN },
})
if (res.ok) {
const data = await res.json()
return data.message || 'authenticated'
}
} catch {}
return 'authenticated'
}
export async function logout () {
window.location.href = 'https://auth.targo.ca/if/flow/default-invalidation-flow/'
}

View File

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

64
apps/ops/src/api/erp.js Normal file
View File

@ -0,0 +1,64 @@
// Unified ERPNext API helper for all modules
import { BASE_URL } from 'src/config/erpnext'
import { authFetch } from './auth'
// List documents with filters, fields, pagination
export async function listDocs (doctype, { filters = {}, fields = ['name'], limit = 20, offset = 0, orderBy = 'creation desc' } = {}) {
const params = new URLSearchParams({
fields: JSON.stringify(fields),
filters: JSON.stringify(filters),
limit_page_length: limit,
limit_start: offset,
order_by: orderBy,
})
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '?' + params)
if (!res.ok) throw new Error('API error: ' + res.status)
const data = await res.json()
return data.data || []
}
// Get single document
export async function getDoc (doctype, name) {
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name))
if (!res.ok) throw new Error('Not found: ' + name)
const data = await res.json()
return data.data
}
// Search (for autocomplete / search bars)
export async function searchDocs (doctype, text, { filters = {}, fields = ['name'], limit = 20 } = {}) {
const params = new URLSearchParams({
doctype,
txt: text,
filters: JSON.stringify(filters),
limit_page_length: limit,
})
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + params)
if (!res.ok) return []
const data = await res.json()
return data.message || []
}
// Update a document (partial update)
export async function updateDoc (doctype, name, data) {
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Update failed: ' + res.status)
const json = await res.json()
return json.data
}
// Count documents
export async function countDocs (doctype, filters = {}) {
const params = new URLSearchParams({
doctype,
filters: JSON.stringify(filters),
})
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?' + params)
if (!res.ok) return 0
const data = await res.json()
return data.message || 0
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { boot } from 'quasar/wrappers'
import { createPinia } from 'pinia'
export default boot(({ app }) => {
app.use(createPinia())
})

View File

@ -0,0 +1,44 @@
<template>
<div class="ops-card" style="height:100%">
<div class="section-title">
<q-icon name="account_balance" size="18px" class="q-mr-xs" /> Facturation
</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<div class="text-center">
<div class="text-h6 text-weight-bold" style="color:var(--ops-accent)">{{ formatMoney(totalMonthly) }}</div>
<div class="kpi-label">Total mensuel</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="text-h6 text-weight-bold" style="color:var(--ops-danger)">{{ formatMoney(totalOutstanding) }}</div>
<div class="kpi-label">Solde </div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="text-h6 text-weight-bold">{{ invoiceCount }}</div>
<div class="kpi-label">Factures</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="text-h6 text-weight-bold" style="color:var(--ops-success)">{{ formatMoney(totalPaid) }}</div>
<div class="kpi-label">Total payé</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { formatMoney } from 'src/composables/useFormatters'
defineProps({
totalMonthly: { type: Number, default: 0 },
totalOutstanding: { type: Number, default: 0 },
invoiceCount: { type: Number, default: 0 },
totalPaid: { type: Number, default: 0 },
})
</script>

View File

@ -0,0 +1,49 @@
<template>
<div class="ops-card" style="height:100%">
<div class="section-title">
<q-icon name="contact_phone" size="18px" class="q-mr-xs" /> Contact
</div>
<div class="info-grid">
<div class="info-row editable-row">
<q-icon name="person" size="16px" color="grey-6" />
<q-input v-model="customer.contact_name_legacy" dense borderless placeholder="Contact"
input-class="editable-input" @change="$emit('save', 'contact_name_legacy')" />
</div>
<div class="info-row editable-row">
<q-icon name="badge" size="16px" color="grey-6" />
<q-input v-model="customer.mandataire" dense borderless placeholder="Mandataire"
input-class="editable-input" @change="$emit('save', 'mandataire')" />
</div>
<div class="info-row editable-row">
<q-icon name="phone" size="16px" color="grey-6" />
<q-input v-model="customer.tel_home" dense borderless placeholder="Tél. maison"
input-class="editable-input" @change="$emit('save', 'tel_home')" />
</div>
<div class="info-row editable-row">
<q-icon name="business" size="16px" color="grey-6" />
<q-input v-model="customer.tel_office" dense borderless placeholder="Tél. bureau"
input-class="editable-input" @change="$emit('save', 'tel_office')" />
</div>
<div class="info-row editable-row">
<q-icon name="smartphone" size="16px" color="grey-6" />
<q-input v-model="customer.cell_phone" dense borderless placeholder="Cellulaire"
input-class="editable-input" @change="$emit('save', 'cell_phone')" />
</div>
<div class="info-row editable-row">
<q-icon name="email" size="16px" color="grey-6" />
<q-input v-model="customer.email_billing" dense borderless placeholder="Email facturation"
input-class="editable-input text-caption" style="word-break:break-all"
@change="$emit('save', 'email_billing')" />
</div>
<div v-if="customer.stripe_id" class="info-row">
<q-icon name="payment" size="16px" color="grey-6" />
<span class="text-caption text-grey-6">Stripe: {{ customer.stripe_id }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({ customer: { type: Object, required: true } })
defineEmits(['save'])
</script>

View File

@ -0,0 +1,46 @@
<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
</div>
<div class="col">
<div class="text-h5 text-weight-bold">{{ customer.customer_name }}</div>
<div class="text-caption text-grey-6 row items-center no-wrap q-gutter-x-xs">
<span>{{ customer.name }}</span>
<template v-if="customer.legacy_customer_id"><span>&middot; Legacy: {{ customer.legacy_customer_id }}</span></template>
<span>&middot;</span>
<q-select v-model="customer.customer_type" dense borderless
:options="['Individual', 'Company']" emit-value map-options
input-class="editable-input text-caption" style="min-width:80px;max-width:110px"
@update:model-value="$emit('save', 'customer_type')" />
<span>&middot;</span>
<q-select v-model="customer.customer_group" dense borderless
:options="customerGroups" emit-value map-options
input-class="editable-input text-caption" style="min-width:90px;max-width:130px"
@update:model-value="$emit('save', 'customer_group')" />
<template v-if="customer.language">
<span>&middot;</span>
<q-select v-model="customer.language" dense borderless
:options="[{label:'Français',value:'fr'},{label:'English',value:'en'}]" emit-value map-options
input-class="editable-input text-caption" style="min-width:70px;max-width:100px"
@update:model-value="$emit('save', 'language')" />
</template>
</div>
</div>
<div class="col-auto text-right">
<span class="ops-badge q-mb-xs" :class="customer.disabled ? 'inactive' : 'active'" style="font-size:0.85rem">
{{ customer.disabled ? 'Inactif' : 'Actif' }}
</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
customer: { type: Object, required: true },
customerGroups: { type: Array, default: () => ['Commercial', 'Individual', 'Government', 'Non Profit'] },
})
defineEmits(['save'])
</script>

View File

@ -0,0 +1,64 @@
<template>
<div class="ops-card" style="height:100%">
<div class="section-title">
<q-icon name="info" size="18px" class="q-mr-xs" /> Informations
</div>
<div class="info-grid">
<div class="info-row editable-row">
<q-icon name="mail" size="16px" color="grey-6" />
<span class="info-label">Envoi:</span>
<q-select v-model="customer.invoice_delivery_method" dense borderless
:options="['Email', 'Poste', 'Email + Poste']" emit-value map-options
style="min-width:120px" input-class="editable-input text-weight-bold"
@update:model-value="$emit('save', 'invoice_delivery_method')" />
</div>
<div class="info-row editable-row">
<q-icon name="receipt_long" size="16px" color="grey-6" />
<span class="info-label">Taxe:</span>
<q-select v-model="customer.tax_category_legacy" dense borderless
:options="['Federal + Provincial (9.5%)', 'Federal seulement', 'Exempté']"
emit-value map-options style="min-width:140px" input-class="editable-input"
@update:model-value="$emit('save', 'tax_category_legacy')" />
</div>
<div class="info-row">
<q-toggle v-model="customer.is_commercial" dense size="xs" color="blue"
@update:model-value="$emit('save', 'is_commercial')" />
<q-icon name="storefront" size="16px" :color="customer.is_commercial ? 'blue-6' : 'grey-4'" />
<span :class="customer.is_commercial ? 'text-blue-8 text-weight-medium' : 'text-grey-5'">Commercial</span>
</div>
<div class="info-row">
<q-toggle v-model="customer.is_bad_payer" dense size="xs" color="red"
@update:model-value="$emit('save', 'is_bad_payer')" />
<q-icon name="warning" size="16px" :color="customer.is_bad_payer ? 'red-6' : 'grey-4'" />
<span :class="customer.is_bad_payer ? 'text-red-8 text-weight-medium' : 'text-grey-5'">Mauvais payeur</span>
</div>
<div class="info-row">
<q-toggle v-model="customer.exclude_fees" dense size="xs" color="orange"
@update:model-value="$emit('save', 'exclude_fees')" />
<q-icon name="money_off" size="16px" :color="customer.exclude_fees ? 'orange-6' : 'grey-4'" />
<span :class="customer.exclude_fees ? 'text-orange-8' : 'text-grey-5'">Exclure frais</span>
</div>
<div class="info-row">
<q-toggle v-model="customer.ppa_enabled" dense size="xs" color="green"
@update:model-value="$emit('save', 'ppa_enabled')" />
<q-icon :name="customer.ppa_enabled ? 'credit_card' : 'credit_card_off'" size="16px"
:color="customer.ppa_enabled ? 'green-6' : 'grey-4'" />
<span :class="customer.ppa_enabled ? 'text-green-7' : 'text-grey-5'">PPA</span>
</div>
<div v-if="customer.date_created_legacy" class="info-row">
<q-icon name="event" size="16px" color="grey-6" />
<span class="text-caption text-grey-6">Client depuis {{ customer.date_created_legacy }}</span>
</div>
</div>
<div class="q-mt-sm" style="border-top:1px solid #e2e8f0;padding-top:6px">
<q-input v-model="customer.notes_internal" dense borderless type="textarea" autogrow
placeholder="Notes internes..." input-class="editable-input text-caption"
@change="$emit('save', 'notes_internal')" />
</div>
</div>
</template>
<script setup>
defineProps({ customer: { type: Object, required: true } })
defineEmits(['save'])
</script>

View File

@ -0,0 +1,418 @@
<template>
<q-dialog :model-value="open" @update:model-value="$emit('update:open', $event)" position="right" full-height>
<q-card style="width:600px;max-width:90vw" class="column no-wrap">
<!-- Header -->
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col">
<div class="text-subtitle1 text-weight-bold">
<slot name="title-prefix" />
{{ title }}
</div>
<div class="text-caption text-grey-6">
{{ docName }}
<slot name="title-suffix" />
</div>
</div>
<!-- Action buttons -->
<slot name="header-actions" />
<q-btn v-if="doctype === 'Sales Invoice'" flat round dense icon="picture_as_pdf"
@click="$emit('open-pdf', docName)" class="q-mr-xs" color="red-7">
<q-tooltip>Voir PDF</q-tooltip>
</q-btn>
<q-btn flat round dense icon="open_in_new" @click="openExternal(erpLinkUrl)" class="q-mr-xs" />
<q-btn flat round dense icon="close" @click="$emit('update:open', false)" />
</q-card-section>
<!-- Loading -->
<q-card-section v-if="loading" class="flex flex-center" style="min-height:200px">
<q-spinner size="32px" color="indigo-6" />
</q-card-section>
<!-- Content -->
<q-card-section v-else-if="doc" class="col q-pt-sm" style="overflow-y:auto">
<!-- Sales Invoice -->
<template v-if="doctype === 'Sales Invoice'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
<div class="mf"><span class="mf-label">Echeance</span>{{ doc.due_date || '---' }}</div>
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="invStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">Total HT</span>{{ formatMoney(doc.net_total) }}</div>
<div class="mf"><span class="mf-label">Taxes</span>{{ formatMoney(doc.total_taxes_and_charges) }}</div>
<div class="mf"><span class="mf-label">Total TTC</span><strong>{{ formatMoney(doc.grand_total) }}</strong></div>
<div class="mf"><span class="mf-label">Solde du</span><span :class="{'text-red': doc.outstanding_amount > 0}">{{ formatMoney(doc.outstanding_amount) }}</span></div>
<div class="mf"><span class="mf-label">Devise</span>{{ doc.currency || 'CAD' }}</div>
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-indigo-6 cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
</div>
<div v-if="doc.remarks && doc.remarks !== 'No Remarks'" class="q-mt-md">
<div class="info-block-title">Remarques</div>
<div class="modal-desc text-grey-8" style="white-space:pre-line">{{ doc.remarks }}</div>
</div>
<div v-if="doc.items?.length" class="q-mt-md">
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
<q-table
:rows="doc.items" :columns="invItemCols" row-key="idx"
flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-amount="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.amount) }}</q-td>
</template>
<template #body-cell-rate="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
</template>
</q-table>
</div>
<div v-if="doc.taxes?.length" class="q-mt-md">
<div class="info-block-title">Taxes</div>
<div v-for="t in doc.taxes" :key="t.idx" class="info-row q-py-xs">
<span>{{ t.description || t.account_head }}</span>
<q-space />
<span>{{ formatMoney(t.tax_amount) }}</span>
</div>
</div>
<div v-if="comments.length" class="q-mt-md">
<div class="info-block-title">Notes ({{ comments.length }})</div>
<div v-for="mc in comments" :key="mc.name" class="q-py-xs" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="text-caption text-grey-6">{{ mc.comment_by || 'Systeme' }} &middot; {{ formatDateShort(mc.creation) }}</div>
<div class="text-body2" style="white-space:pre-line">{{ mc.content }}</div>
</div>
</div>
</template>
<!-- Issue / Ticket -->
<template v-else-if="doctype === 'Issue'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="ticketStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">Priorite</span><span class="ops-badge" :class="priorityClass(doc.priority)">{{ doc.priority }}</span></div>
<div class="mf"><span class="mf-label">Ouvert le</span>{{ formatDate(doc.opening_date) }}</div>
<div class="mf" v-if="doc.resolution_time"><span class="mf-label">Resolu le</span>{{ doc.resolution_time }}</div>
<div class="mf" v-if="doc.issue_type"><span class="mf-label">Type</span>{{ doc.issue_type }}</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.raised_by"><span class="mf-label">Soumis par</span>{{ doc.raised_by }}</div>
<div class="mf" v-if="doc.owner"><span class="mf-label">Proprietaire</span>{{ doc.owner }}</div>
</div>
<div v-if="doc.issue_split_from" class="q-mt-md">
<div class="info-block-title">Ticket parent</div>
<div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
{{ doc.issue_split_from }}
</div>
</div>
<div v-if="doc.description" class="q-mt-md">
<div class="info-block-title">Description</div>
<div class="modal-desc" v-html="doc.description"></div>
</div>
<div v-if="doc.resolution_details" class="q-mt-md">
<div class="info-block-title">Resolution</div>
<div class="modal-desc" v-html="doc.resolution_details"></div>
</div>
<div v-if="comms.length" class="q-mt-md">
<div class="info-block-title">Echanges ({{ comms.length }})</div>
<div v-for="comm in comms" :key="comm.name" class="comm-row">
<div class="row items-start">
<div class="col">
<div class="text-caption text-weight-bold">{{ comm.sender || comm.owner }}</div>
<div class="modal-desc q-mt-xs" v-html="comm.content"></div>
</div>
<div class="col-auto text-caption text-grey-6 text-right" style="min-width:90px">
{{ formatDate(comm.creation) }}
</div>
</div>
</div>
</div>
<div v-if="files.length" class="q-mt-md">
<div class="info-block-title">Pieces jointes ({{ files.length }})</div>
<div v-for="f in files" :key="f.name" class="q-py-xs">
<a :href="erpFileUrl(f.file_url)" target="_blank" class="erp-link">
<q-icon name="attach_file" size="14px" /> {{ f.file_name }}
</a>
</div>
</div>
<div v-if="comments.length" class="q-mt-md">
<div class="info-block-title">Fil de discussion ({{ comments.length }})</div>
<div v-for="c in comments" :key="c.name" class="thread-msg">
<div class="thread-header">
<q-icon name="person" size="14px" color="grey-6" class="q-mr-xs" />
<span class="text-weight-bold">{{ c.comment_by || 'Systeme' }}</span>
<span class="text-grey-5 q-ml-auto">{{ formatDateTime(c.creation) }}</span>
</div>
<div class="thread-body" v-html="c.content"></div>
</div>
</div>
<div v-if="!doc.description && !doc.resolution_details && !comms.length" class="text-center text-grey-5 q-pa-lg">
Aucun contenu pour ce ticket
</div>
</template>
<!-- Payment Entry -->
<template v-else-if="doctype === 'Payment Entry'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
<div class="mf"><span class="mf-label">Mode</span>{{ doc.mode_of_payment || '---' }}</div>
<div class="mf"><span class="mf-label">Montant paye</span><strong>{{ formatMoney(doc.paid_amount) }}</strong></div>
<div class="mf"><span class="mf-label">Reference</span>{{ doc.reference_no || '---' }}</div>
<div class="mf" v-if="doc.reference_date"><span class="mf-label">Date ref.</span>{{ doc.reference_date }}</div>
<div class="mf"><span class="mf-label">Compte paye de</span>{{ doc.paid_from || '---' }}</div>
<div class="mf"><span class="mf-label">Compte paye a</span>{{ doc.paid_to || '---' }}</div>
</div>
<div v-if="doc.references?.length" class="q-mt-md">
<div class="info-block-title">Factures liees</div>
<div v-for="r in doc.references" :key="r.idx" class="info-row q-py-xs" style="cursor:pointer" @click="$emit('navigate', r.reference_doctype, r.reference_name)">
<span class="erp-link">{{ r.reference_name }}</span>
<q-space />
<span>{{ formatMoney(r.allocated_amount) }}</span>
</div>
</div>
</template>
<!-- Subscription (editable) -->
<template v-else-if="doctype === 'Subscription'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="subStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">SKU</span><code>{{ doc.item_code }}</code></div>
<div class="mf"><span class="mf-label">Article</span>{{ doc.item_name }}</div>
<div class="mf"><span class="mf-label">Frequence</span>{{ doc.billing_frequency === 'A' ? 'Annuel' : 'Mensuel' }}</div>
<div class="mf"><span class="mf-label">Debut</span>{{ doc.start_date || '---' }}</div>
<div class="mf"><span class="mf-label">Fin</span>{{ doc.end_date || '---' }}</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.radius_user"><span class="mf-label">PPPoE</span><code>{{ doc.radius_user }}</code></div>
<div class="mf" v-if="doc.radius_pwd"><span class="mf-label">Mot de passe</span><code>{{ doc.radius_pwd }}</code></div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Modifier</div>
<div class="q-gutter-y-sm">
<q-input v-model="doc.custom_description" dense outlined
label="Description personnalisee" @change="$emit('save-field', doc, 'custom_description')" />
<q-input v-model.number="doc.actual_price" dense outlined type="number" step="0.01"
label="Prix" prefix="$" @change="$emit('save-field', doc, 'actual_price')" />
<div class="row items-center q-gutter-x-md q-mt-sm">
<q-toggle
:model-value="!Number(doc.cancel_at_period_end)"
@update:model-value="$emit('toggle-recurring', doc)"
:color="Number(doc.cancel_at_period_end) ? 'grey' : 'green'"
label="Recurrent"
/>
<div v-if="doc.current_invoice_start" class="text-caption text-grey-6">
Prochaine facture: {{ formatDate(doc.current_invoice_start) }} --- {{ formatDate(doc.current_invoice_end) }}
</div>
</div>
</div>
</div>
<div v-if="doc.plans?.length" class="q-mt-md">
<div class="info-block-title">Plans</div>
<div v-for="p in doc.plans" :key="p.idx" class="info-row q-py-xs">
<span>{{ p.plan || p.item }}</span>
<q-space />
<span>{{ formatMoney(p.cost) }}</span>
</div>
</div>
</template>
<!-- Service Equipment -->
<template v-else-if="doctype === 'Service Equipment'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Type</span>{{ doc.equipment_type }}</div>
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="eqStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">Marque</span>{{ doc.brand || '---' }}</div>
<div class="mf"><span class="mf-label">Modele</span>{{ doc.model || '---' }}</div>
<div class="mf"><span class="mf-label">N serie</span><code>{{ doc.serial_number }}</code></div>
<div class="mf" v-if="doc.mac_address"><span class="mf-label">MAC</span><code>{{ doc.mac_address }}</code></div>
<div class="mf" v-if="doc.barcode"><span class="mf-label">Code-barres</span><code>{{ doc.barcode }}</code></div>
<div class="mf" v-if="doc.ip_address"><span class="mf-label">IP</span><code>{{ doc.ip_address }}</code></div>
<div class="mf" v-if="doc.firmware_version"><span class="mf-label">Firmware</span>{{ doc.firmware_version }}</div>
<div class="mf"><span class="mf-label">Propriete</span>{{ doc.ownership || '---' }}</div>
</div>
<div v-if="doc.olt_name" class="q-mt-md">
<div class="info-block-title">Information OLT</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">OLT</span>{{ doc.olt_name }}</div>
<div class="mf"><span class="mf-label">IP OLT</span><code>{{ doc.olt_ip }}</code></div>
<div class="mf"><span class="mf-label">Slot / Port</span>{{ doc.olt_slot }} / {{ doc.olt_port }}</div>
<div class="mf"><span class="mf-label">ONT ID</span>{{ doc.olt_ontid }}</div>
</div>
<div class="mf" v-if="doc.installation_date"><span class="mf-label">Installe le</span>{{ doc.installation_date }}</div>
<div class="mf" v-if="doc.warranty_end"><span class="mf-label">Fin garantie</span>{{ doc.warranty_end }}</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
</div>
<div v-if="doc.login_user" class="q-mt-md">
<div class="info-block-title">Acces distant</div>
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span><code>{{ doc.login_user }}</code></div>
</div>
<div v-if="doc.notes" class="q-mt-md">
<div class="info-block-title">Notes</div>
<div class="modal-desc">{{ doc.notes }}</div>
</div>
<div v-if="doc.move_log?.length" class="q-mt-md">
<div class="info-block-title">Historique ({{ doc.move_log.length }})</div>
<div v-for="m in doc.move_log" :key="m.idx" class="info-row q-py-xs text-caption">
{{ m.date }} --- {{ m.from_location || '?' }} &rarr; {{ m.to_location || '?' }} {{ m.reason ? '(' + m.reason + ')' : '' }}
</div>
</div>
</template>
<!-- Generic fallback -->
<template v-else>
<div class="modal-field-grid">
<div v-for="(val, key) in docFields" :key="key" class="mf">
<span class="mf-label">{{ key }}</span>
<span v-if="typeof val === 'number'">{{ val }}</span>
<span v-else>{{ val || '---' }}</span>
</div>
</div>
</template>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
import { formatDate, formatDateShort, formatMoney, erpFileUrl } from 'src/composables/useFormatters'
import { invStatusClass, ticketStatusClass, priorityClass, subStatusClass, eqStatusClass } from 'src/composables/useStatusClasses'
import { erpLink } from 'src/composables/useFormatters'
import { computed } from 'vue'
const props = defineProps({
open: Boolean,
loading: Boolean,
doctype: String,
docName: String,
title: String,
doc: Object,
comments: { type: Array, default: () => [] },
comms: { type: Array, default: () => [] },
files: { type: Array, default: () => [] },
docFields: { type: Object, default: () => ({}) },
})
defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring'])
const erpLinkUrl = computed(() => erpLink(props.doctype, props.docName))
const invItemCols = [
{ name: 'item_name', label: 'Article', field: r => decodeHtml(r.item_name || r.item_code), align: 'left' },
{ name: 'qty', label: 'Qte', field: 'qty', align: 'center' },
{ name: 'rate', label: 'Prix unit.', field: 'rate', align: 'right' },
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
]
function decodeHtml (str) {
if (!str) return str
const el = document.createElement('textarea')
el.innerHTML = str
return el.value
}
function formatDateTime (dt) {
if (!dt) return ''
const d = new Date(dt)
if (isNaN(d)) return dt
return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
}
function openExternal (url) {
window.open(url, '_blank')
}
</script>
<style scoped>
.erp-link {
color: #6366f1;
text-decoration: none;
cursor: pointer;
}
.erp-link:hover {
text-decoration: underline;
}
.modal-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 16px;
}
.mf {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 0;
font-size: 0.875rem;
border-bottom: 1px solid #f1f5f9;
}
.mf-label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
min-width: 80px;
flex-shrink: 0;
}
.info-block-title {
font-size: 0.75rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.modal-desc {
font-size: 0.85rem;
line-height: 1.5;
background: #f8fafc;
border-radius: 8px;
padding: 10px 12px;
max-height: 300px;
overflow-y: auto;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
}
.comm-row {
padding: 10px 0;
border-bottom: 1px solid #e2e8f0;
}
.comm-row:last-child {
border-bottom: none;
}
.thread-msg {
border-left: 3px solid #e2e8f0;
padding: 8px 0 8px 12px;
margin-bottom: 2px;
}
.thread-msg:hover {
border-left-color: #6366f1;
}
.thread-header {
display: flex;
align-items: center;
font-size: 0.78rem;
color: #374151;
margin-bottom: 4px;
}
.thread-body {
font-size: 0.84rem;
line-height: 1.5;
color: #1f2937;
}
.thread-body :deep(p) {
margin: 0 0 4px;
}
.thread-body :deep(hr) {
border: none;
border-top: 1px solid #e5e7eb;
margin: 6px 0;
}
.thread-body :deep(br + br) {
display: none;
}
</style>

View File

@ -0,0 +1,408 @@
<script setup>
/**
* TagEditor Universal inline tag editor with autocomplete.
*
* Features:
* - Autocomplete from allTags (label + color dot + category)
* - Click chip inline rename (Enter to save, Esc to cancel)
* - Right-click chip mini popover: rename, color picker, delete
* - Optional `level` badge per tag (for skill proficiency on resources)
* - Create new tags on-the-fly with color picker
* - Reusable everywhere: jobs, techs, sidebar filter
*/
import { ref, computed, nextTick, watch } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] }, // [{ tag, level? }] or ['label', ...]
allTags: { type: Array, default: () => [] }, // [{ name, label, color, category }]
getColor: { type: Function, default: () => '#6b7280' },
placeholder: { type: String, default: 'Ajouter un tag…' },
canCreate: { type: Boolean, default: true },
canEdit: { type: Boolean, default: true }, // allow inline rename/color/delete
showLevel: { type: Boolean, default: false }, // show skill level per tag
levelLabel: { type: String, default: 'Compétence' }, // context label for level
levelHint: { type: String, default: '1 = base · 5 = expert' },
showRequired:{ type: Boolean, default: false }, // show required pin per tag (jobs)
compact: { type: Boolean, default: false }, // smaller chips
})
const emit = defineEmits(['update:modelValue', 'create', 'update-tag', 'rename-tag', 'delete-tag'])
// Normalise modelValue: accept both string[] and {tag, level, required}[]
function _labels () {
return props.modelValue.map(v => typeof v === 'string' ? v : v.tag)
}
function _getLevel (label) {
const item = props.modelValue.find(v => typeof v !== 'string' && v.tag === label)
return item?.level ?? 0
}
function _isRequired (label) {
const item = props.modelValue.find(v => typeof v !== 'string' && v.tag === label)
return !!(item?.required)
}
// Search / autocomplete
const query = ref('')
const focused = ref(false)
const inputEl = ref(null)
const filtered = computed(() => {
const labels = _labels()
const q = query.value.trim().toLowerCase()
const pool = props.allTags.filter(t => !labels.includes(t.label) && !labels.includes(t.name))
if (!q) return pool.slice(0, 14)
return pool.filter(t => t.label.toLowerCase().includes(q)).slice(0, 14)
})
const showCreate = computed(() => {
if (!props.canCreate) return false
const q = query.value.trim()
if (!q || q.length < 2) return false
return !props.allTags.some(t => t.label.toLowerCase() === q.toLowerCase())
})
// Add / remove
function addTag (label) {
if (!label || _labels().includes(label)) return
const useObj = props.showLevel || props.showRequired
const out = useObj
? [...props.modelValue, { tag: label, level: 1, required: 0 }]
: [...props.modelValue, label]
emit('update:modelValue', out)
query.value = ''
}
function removeTag (label) {
const out = props.modelValue.filter(v => (typeof v === 'string' ? v : v.tag) !== label)
emit('update:modelValue', out)
}
// Level change
function setLevel (label, level) {
const out = props.modelValue.map(v => {
if (typeof v !== 'string' && v.tag === label) return { ...v, level }
return v
})
emit('update:modelValue', out)
}
// Required toggle (jobs)
function toggleRequired (label) {
const out = props.modelValue.map(v => {
if (typeof v !== 'string' && v.tag === label) return { ...v, required: v.required ? 0 : 1 }
return v
})
emit('update:modelValue', out)
}
// Create new tag
const newColor = ref('#6b7280')
const PALETTE = [
'#6366f1', '#3b82f6', '#06b6d4', '#10b981', '#14b8a6',
'#f59e0b', '#f97316', '#f43f5e', '#d946ef', '#8b5cf6',
'#78716c', '#64748b',
]
function createAndAdd () {
const label = query.value.trim()
if (!label) return
emit('create', { label, color: newColor.value })
addTag(label)
newColor.value = '#6b7280'
}
// Inline edit popover
const editPop = ref(null) // { label, newLabel, color, x, y }
function openEdit (label, ev) {
if (!props.canEdit) return
ev.preventDefault()
ev.stopPropagation()
const tag = props.allTags.find(t => t.label === label || t.name === label)
const rect = ev.currentTarget.getBoundingClientRect()
editPop.value = {
label,
newLabel: label,
color: tag?.color || props.getColor(label),
category: tag?.category || 'Custom',
x: rect.left,
y: rect.bottom + 4,
}
nextTick(() => {
const inp = document.querySelector('.te-edit-input')
if (inp) inp.focus()
})
}
function closeEdit () { editPop.value = null }
function saveEdit () {
if (!editPop.value) return
const { label, newLabel, color } = editPop.value
if (newLabel.trim() && newLabel.trim() !== label) {
emit('rename-tag', { oldName: label, newName: newLabel.trim() })
}
if (color !== (props.allTags.find(t => t.label === label)?.color)) {
emit('update-tag', { name: newLabel.trim() || label, color })
}
closeEdit()
}
function deleteFromEdit () {
if (!editPop.value) return
emit('delete-tag', editPop.value.label)
removeTag(editPop.value.label)
closeEdit()
}
// Keyboard nav
function onKeydown (e) {
if (e.key === 'Enter' && query.value.trim()) {
e.preventDefault()
if (filtered.value.length) addTag(filtered.value[0].label)
else if (showCreate.value) createAndAdd()
}
if (e.key === 'Backspace' && !query.value && _labels().length) {
removeTag(_labels()[_labels().length - 1])
}
if (e.key === 'Escape') { focused.value = false; inputEl.value?.blur() }
}
function onBlur () {
setTimeout(() => { focused.value = false }, 200)
}
// Close edit popover on outside click
function onEditBlur () {
setTimeout(() => closeEdit(), 250)
}
</script>
<template>
<div class="te-wrap" :class="{ 'te-focused': focused, 'te-compact': compact }">
<!-- Existing tags as chips -->
<span v-for="label in _labels()" :key="label" class="te-chip"
:class="{ 'te-chip-required': showRequired && _isRequired(label) }"
:style="'background:' + getColor(label)"
@click.exact="canEdit ? openEdit(label, $event) : null"
@contextmenu="openEdit(label, $event)">
<span v-if="showRequired && _isRequired(label)" class="te-pin" title="Requis">&#x1F4CC;</span>
<span class="te-chip-label">{{ label }}</span>
<span v-if="showLevel && _getLevel(label)" class="te-chip-level" :class="'te-lvl-' + Math.min(_getLevel(label), 5)">{{ _getLevel(label) }}</span>
<button class="te-chip-rm" @click.stop="removeTag(label)">×</button>
</span>
<!-- Input -->
<input ref="inputEl" class="te-input" type="text"
v-model="query" :placeholder="modelValue.length ? '' : placeholder"
@focus="focused = true" @blur="onBlur" @keydown="onKeydown" />
<!-- Autocomplete dropdown -->
<div v-if="focused && (filtered.length || showCreate)" class="te-dropdown">
<div v-for="t in filtered" :key="t.label" class="te-option" @mousedown.prevent="addTag(t.label)">
<span class="te-dot" :style="'background:' + (t.color || '#6b7280')"></span>
<span class="te-opt-label">{{ t.label }}</span>
<span class="te-opt-cat">{{ t.category }}</span>
</div>
<!-- Create new -->
<div v-if="showCreate" class="te-create-section">
<div class="te-create-row" @mousedown.prevent="createAndAdd">
<span class="te-create-plus">+</span>
<span>Créer « <strong>{{ query.trim() }}</strong> »</span>
</div>
<div class="te-palette" @mousedown.prevent>
<button v-for="c in PALETTE" :key="c" class="te-pal-dot"
:class="{ active: newColor === c }"
:style="'background:' + c"
@mousedown.prevent="newColor = c" />
</div>
</div>
</div>
<!-- Inline edit popover (contextmenu / dblclick) -->
<Teleport to="body">
<div v-if="editPop" class="te-edit-overlay" @mousedown="closeEdit">
<div class="te-edit-pop" :style="{ left: editPop.x + 'px', top: editPop.y + 'px' }"
@mousedown.stop @blur="onEditBlur">
<div class="te-edit-row">
<input class="te-edit-input" v-model="editPop.newLabel"
@keydown.enter="saveEdit" @keydown.esc="closeEdit" />
</div>
<div class="te-edit-row">
<span class="te-edit-label">Couleur</span>
<div class="te-palette te-palette-sm">
<button v-for="c in PALETTE" :key="c" class="te-pal-dot"
:class="{ active: editPop.color === c }"
:style="'background:' + c"
@click="editPop.color = c" />
</div>
</div>
<div v-if="showLevel" class="te-edit-row">
<span class="te-edit-label">{{ levelLabel }} <span class="te-edit-hint">{{ levelHint }}</span></span>
<div class="te-level-btns">
<button v-for="n in 5" :key="n" class="te-level-btn"
:class="{ active: _getLevel(editPop.label) === n, ['te-skill-' + n]: _getLevel(editPop.label) === n }"
@click="setLevel(editPop.label, n)">{{ n }}</button>
</div>
</div>
<div v-if="showRequired" class="te-edit-row">
<button class="te-req-toggle" :class="{ active: _isRequired(editPop.label) }"
@click="toggleRequired(editPop.label)">
<span class="te-req-pin">&#x1F4CC;</span>
{{ _isRequired(editPop.label) ? 'Requis' : 'Optionnel' }}
</button>
</div>
<div class="te-edit-actions">
<button class="te-btn te-btn-save" @click="saveEdit">Sauver</button>
<button class="te-btn te-btn-del" @click="deleteFromEdit">Supprimer</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.te-wrap {
display:flex; flex-wrap:wrap; gap:3px; align-items:center;
background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px;
padding:3px 6px; min-height:28px; position:relative; cursor:text;
transition: border-color 0.12s;
}
.te-wrap.te-focused { border-color:rgba(99,102,241,0.4); }
.te-wrap.te-compact { min-height:22px; padding:2px 4px; }
/* Chips */
.te-chip {
display:inline-flex; align-items:center; gap:2px;
font-size:0.6rem; font-weight:600; color:#fff;
padding:1px 6px; border-radius:10px; white-space:nowrap;
cursor:default; user-select:none; transition: filter 0.1s;
}
.te-compact .te-chip { font-size:0.52rem; padding:0 5px; }
.te-chip:hover { filter:brightness(1.15); }
.te-chip-label { }
.te-chip-level {
display:inline-flex; align-items:center; justify-content:center;
width:13px; height:13px; border-radius:50%;
background:rgba(0,0,0,0.3); font-size:0.5rem; font-weight:800;
margin-left:2px;
}
/* Skill coloring: 1=basic(grey) → 5=expert(green) */
.te-lvl-1 { background:rgba(100,116,139,0.6); }
.te-lvl-2 { background:rgba(59,130,246,0.5); }
.te-lvl-3 { background:rgba(245,158,11,0.5); }
.te-lvl-4 { background:rgba(99,102,241,0.55); }
.te-lvl-5 { background:rgba(16,185,129,0.6); }
.te-chip-rm {
background:none; border:none; color:rgba(255,255,255,0.55); cursor:pointer;
font-size:0.7rem; padding:0 1px; margin-left:1px; line-height:1;
}
.te-chip-rm:hover { color:#fff; }
/* Input */
.te-input {
flex:1; min-width:60px; background:none; border:none; outline:none;
color:#e2e4ef; font-size:0.72rem; padding:2px 0;
}
.te-input::placeholder { color:#7b80a0; }
/* Dropdown */
.te-dropdown {
position:absolute; top:100%; left:0; right:0; z-index:50;
background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px;
max-height:220px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.45);
margin-top:2px;
}
.te-dropdown::-webkit-scrollbar { width:3px; }
.te-dropdown::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.te-option {
display:flex; align-items:center; gap:6px;
padding:5px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef;
transition:background 0.1s;
}
.te-option:hover { background:rgba(99,102,241,0.12); }
.te-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.te-opt-label { flex:1; }
.te-opt-cat { font-size:0.55rem; color:#7b80a0; }
/* Create section */
.te-create-section { border-top:1px solid rgba(255,255,255,0.06); }
.te-create-row {
display:flex; align-items:center; gap:6px;
padding:5px 10px; cursor:pointer; font-size:0.72rem; color:#6366f1; font-weight:600;
transition:background 0.1s;
}
.te-create-row:hover { background:rgba(99,102,241,0.12); }
.te-create-plus {
width:18px; height:18px; border-radius:50%; background:rgba(99,102,241,0.2);
display:flex; align-items:center; justify-content:center;
font-size:0.75rem; font-weight:800; color:#6366f1; flex-shrink:0;
}
/* Palette */
.te-palette { display:flex; gap:3px; padding:4px 10px 6px; flex-wrap:wrap; }
.te-palette-sm { padding:2px 0 0; }
.te-pal-dot {
width:14px; height:14px; border-radius:50%; border:2px solid transparent;
cursor:pointer; transition:border-color 0.1s, transform 0.1s;
}
.te-pal-dot.active { border-color:#fff; transform:scale(1.15); }
.te-pal-dot:hover { transform:scale(1.1); }
/* Edit popover (Teleport to body) */
.te-edit-overlay {
position:fixed; inset:0; z-index:9999;
}
.te-edit-pop {
position:absolute; width:220px;
background:#1e2235; border:1px solid rgba(99,102,241,0.35); border-radius:8px;
box-shadow:0 12px 32px rgba(0,0,0,0.55); padding:8px;
}
.te-edit-row { margin-bottom:6px; }
.te-edit-label { font-size:0.6rem; color:#7b80a0; margin-bottom:2px; display:block; }
.te-edit-input {
width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.1);
border-radius:4px; color:#e2e4ef; font-size:0.75rem; padding:4px 6px; outline:none;
}
.te-edit-input:focus { border-color:rgba(99,102,241,0.5); }
/* Level buttons */
.te-level-btns { display:flex; gap:3px; }
.te-level-btn {
width:22px; height:22px; border-radius:4px; border:1px solid rgba(255,255,255,0.1);
background:#181c2e; color:#7b80a0; font-size:0.6rem; font-weight:700;
cursor:pointer; transition:all 0.1s;
}
.te-level-btn.active { background:rgba(99,102,241,0.25); color:#a5b4fc; border-color:rgba(99,102,241,0.4); }
.te-level-btn.active.te-skill-1 { background:rgba(100,116,139,0.3); color:#cbd5e1; border-color:rgba(100,116,139,0.5); }
.te-level-btn.active.te-skill-2 { background:rgba(59,130,246,0.25); color:#93c5fd; border-color:rgba(59,130,246,0.4); }
.te-level-btn.active.te-skill-3 { background:rgba(245,158,11,0.25); color:#fcd34d; border-color:rgba(245,158,11,0.4); }
.te-level-btn.active.te-skill-4 { background:rgba(99,102,241,0.25); color:#a5b4fc; border-color:rgba(99,102,241,0.4); }
.te-level-btn.active.te-skill-5 { background:rgba(16,185,129,0.3); color:#6ee7b7; border-color:rgba(16,185,129,0.5); }
.te-level-btn:hover { border-color:rgba(99,102,241,0.4); }
.te-edit-hint { font-size:0.5rem; color:#64748b; margin-left:4px; font-weight:400; }
/* Action buttons */
.te-edit-actions { display:flex; gap:6px; margin-top:6px; }
.te-btn {
flex:1; padding:4px 8px; border-radius:4px; border:none;
font-size:0.65rem; font-weight:600; cursor:pointer; transition:all 0.1s;
}
.te-btn-save { background:rgba(99,102,241,0.2); color:#a5b4fc; }
.te-btn-save:hover { background:rgba(99,102,241,0.35); }
.te-btn-del { background:rgba(239,68,68,0.12); color:#fca5a5; }
.te-btn-del:hover { background:rgba(239,68,68,0.25); }
/* Required pin on chips */
.te-pin { font-size:0.5rem; margin-right:1px; filter:saturate(0.7); }
.te-chip-required { outline:1.5px solid rgba(255,255,255,0.35); outline-offset:-1px; }
/* Required toggle button in popover */
.te-req-toggle {
display:flex; align-items:center; gap:4px; width:100%;
padding:5px 8px; border-radius:4px; border:1px solid rgba(255,255,255,0.1);
background:#181c2e; color:#7b80a0; font-size:0.65rem; font-weight:600;
cursor:pointer; transition:all 0.15s;
}
.te-req-toggle:hover { border-color:rgba(245,158,11,0.4); }
.te-req-toggle.active { background:rgba(245,158,11,0.15); color:#fcd34d; border-color:rgba(245,158,11,0.4); }
.te-req-pin { font-size:0.7rem; }
</style>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,122 @@
import { ref, computed } from 'vue'
import { getDoc, listDocs } from 'src/api/erp'
import { erpLink } from 'src/composables/useFormatters'
/**
* Composable for the slide-over detail modal.
* Handles state management, data fetching, and navigation for any ERPNext doctype.
*/
export function useDetailModal () {
const modalOpen = ref(false)
const modalLoading = ref(false)
const modalDoctype = ref('')
const modalDocName = ref('')
const modalTitle = ref('')
const modalDoc = ref(null)
const modalComments = ref([])
const modalComms = ref([])
const modalFiles = ref([])
const modalErpLink = computed(() => erpLink(modalDoctype.value, modalDocName.value))
const modalDocFields = computed(() => {
if (!modalDoc.value) return {}
const skip = new Set(['name', 'owner', 'creation', 'modified', 'modified_by', 'docstatus', 'idx', 'doctype', '_user_tags', '_comments', '_assign', '_liked_by'])
const out = {}
for (const [k, v] of Object.entries(modalDoc.value)) {
if (skip.has(k) || Array.isArray(v) || v === null || v === '' || typeof v === 'object') continue
out[k] = v
}
return out
})
/**
* Open the modal for any doctype.
* Fetches the document and optional related data (comments, communications, files).
*/
async function openModal (doctype, name, title) {
modalDoctype.value = doctype
modalDocName.value = name
modalTitle.value = title || name
modalDoc.value = null
modalComments.value = []
modalComms.value = []
modalFiles.value = []
modalOpen.value = true
modalLoading.value = true
try {
const promises = [getDoc(doctype, name)]
// Fetch comments for invoices (legacy notes imported as Comments)
if (doctype === 'Sales Invoice') {
promises.push(listDocs('Comment', {
filters: { reference_doctype: 'Sales Invoice', reference_name: name, comment_type: 'Comment' },
fields: ['name', 'content', 'comment_by', 'creation'],
limit: 50, orderBy: 'creation desc',
}))
}
// Fetch communications, files, and comments for Issues
if (doctype === 'Issue') {
promises.push(
listDocs('Communication', {
filters: { reference_doctype: 'Issue', reference_name: name },
fields: ['name', 'sender', 'owner', 'content', 'creation', 'communication_type', 'subject'],
limit: 50, orderBy: 'creation asc',
}).catch(() => []),
listDocs('File', {
filters: { attached_to_doctype: 'Issue', attached_to_name: name },
fields: ['name', 'file_name', 'file_url', 'file_size'],
limit: 20,
}).catch(() => []),
listDocs('Comment', {
filters: { reference_doctype: 'Issue', reference_name: name, comment_type: 'Comment' },
fields: ['name', 'content', 'comment_by', 'creation'],
limit: 200, orderBy: 'creation asc',
}).catch(() => []),
)
}
const results = await Promise.all(promises)
modalDoc.value = results[0]
if (doctype === 'Sales Invoice') {
modalComments.value = results[1] || []
} else if (doctype === 'Issue') {
modalComms.value = results[1] || []
modalFiles.value = results[2] || []
modalComments.value = results[3] || []
}
// Auto-derive title from doc if not provided
if (!title) {
const d = modalDoc.value
modalTitle.value = d.title || d.subject || d.item_name || d.customer_name || name
}
} catch (e) {
console.error('Failed to load', doctype, name, e)
}
modalLoading.value = false
}
function closeModal () {
modalOpen.value = false
}
return {
modalOpen,
modalLoading,
modalDoctype,
modalDocName,
modalTitle,
modalDoc,
modalComments,
modalComms,
modalFiles,
modalErpLink,
modalDocFields,
openModal,
closeModal,
}
}

View File

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

View File

@ -0,0 +1,60 @@
/**
* Shared formatting utilities for the ops app.
* Used in ClientDetailPage, TicketsPage, and future pages.
*/
const ERP_BASE = 'https://erp.gigafibre.ca'
/**
* Format a date string to fr-CA short format (e.g. "30 mars 2026")
* @param {string|null} d - Date string (ISO or YYYY-MM-DD)
* @returns {string}
*/
export function formatDate (d) {
if (!d) return '—'
return new Date(d).toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* Format a date string to DD/MM/YYYY
* @param {string|null} d - Date string
* @returns {string}
*/
export function formatDateShort (d) {
if (!d) return '—'
const str = String(d).slice(0, 10)
if (str.length < 10) return str
const [y, m, day] = str.split('-')
return `${day}/${m}/${y}`
}
/**
* Format a number as CAD currency
* @param {number} v
* @returns {string}
*/
export function formatMoney (v) {
return (v || 0).toLocaleString('fr-CA', { style: 'currency', currency: 'CAD' })
}
/**
* Build a link to the ERPNext desk for a doctype + name
* @param {string} doctype - e.g. 'Sales Invoice', 'Issue'
* @param {string} name - Document name/ID
* @returns {string}
*/
export function erpLink (doctype, name) {
if (!doctype || !name) return '#'
return ERP_BASE + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
}
/**
* Build a full URL for an ERPNext file attachment
* @param {string} url - Relative or absolute file URL
* @returns {string}
*/
export function erpFileUrl (url) {
if (!url) return '#'
if (url.startsWith('http')) return url
return ERP_BASE + url
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
/**
* Status badge CSS class mappings for all doctypes.
* Classes map to .ops-badge variants: active, inactive, open, closed, draft
*/
export function locStatusClass (s) {
return s === 'Active' ? 'active' : s === 'Inactive' ? 'inactive' : 'draft'
}
export function subStatusClass (s) {
return s === 'Active' ? 'active' : s === 'Cancelled' ? 'inactive' : 'draft'
}
export function eqStatusClass (s) {
return s === 'Actif' ? 'active' : s === 'Défectueux' || s === 'Perdu' ? 'inactive' : 'draft'
}
export function ticketStatusClass (s) {
if (s === 'Open') return 'open'
if (s === 'Closed' || s === 'Resolved') return 'closed'
if (s === 'Replied') return 'active'
return 'draft'
}
export function invStatusClass (s) {
return s === 'Paid' ? 'active' : s === 'Overdue' ? 'inactive' : s === 'Unpaid' ? 'open' : 'draft'
}
export function priorityClass (p) {
return p === 'Urgent' ? 'inactive' : p === 'High' ? 'open' : p === 'Medium' ? 'draft' : 'active'
}
export function deviceColorClass (status) {
if (status === 'Actif') return 'dev-green'
if (status === 'Défectueux' || status === 'Perdu') return 'dev-red'
return 'dev-grey'
}

View File

@ -0,0 +1,159 @@
import { reactive } from 'vue'
/**
* Section classification for subscription grouping.
* Maps item_group (legacy category) to display sections with icons.
*/
export const SECTION_MAP = {
'Internet': { match: ['Mensualités fibre', 'Mensualités sans fil', 'Internet camping', 'Adresse IP Fixe'], icon: 'language' },
'Téléphonie': { match: ['Téléphonie'], icon: 'phone' },
'Télévision': { match: ['Mensualités télévision'], icon: 'tv' },
'Équipement': { match: ['Installation et équipement fibre', 'Installation et équipement télé', 'Installation et équipement internet sans fil', 'Equipement internet fibre', 'Equipement internet sans fil', 'Location point à point', 'Quincaillerie'], icon: 'router' },
'Hébergement': { match: ['Hébergement', 'Nom de domaine', "Location d'espace", 'Location espace cloud'], icon: 'cloud' },
'Rabais': { match: ['Rabais'], icon: 'sell' },
}
const SECTION_ORDER = ['Internet', 'Téléphonie', 'Télévision', 'Équipement', 'Hébergement', 'Rabais', 'Autre']
/**
* Classify a subscription into a section key.
*/
export function subSection (sub) {
const price = parseFloat(sub.actual_price || 0)
const group = (sub.item_group || '').trim()
const sku = (sub.item_code || '').toUpperCase()
if (price < 0 || sku.startsWith('RAB')) return 'Rabais'
for (const [section, cfg] of Object.entries(SECTION_MAP)) {
if (section === 'Rabais') continue
if (cfg.match.some(m => group.includes(m))) return section
}
// SKU-based fallback
if (/^(FTTH|FTTB|TURBO|FIBRE|FORFERF|FORFBASE|FORFPERF|FORFPOP|FORFMES|SYM|VIP|ENT|COM|FIBCOM|HV)/.test(sku)) return 'Internet'
if (/^(TELEP|FAX|SERV911|SERVTEL|TELE_)/.test(sku)) return 'Téléphonie'
if (/^(TV|STB|RABTV)/.test(sku)) return 'Télévision'
if (/^(LOC|FTT_H|FTTH_LOC)/.test(sku)) return 'Équipement'
if (/^(HEB|DOM)/.test(sku)) return 'Hébergement'
return 'Autre'
}
export function isRebate (sub) {
return parseFloat(sub.actual_price || 0) < 0
}
export function subMainLabel (sub) {
if (sub.custom_description && sub.custom_description.trim()) {
return sub.custom_description
}
return sub.item_name || sub.item_code || sub.name
}
export function subSubLabel (sub) {
if (sub.custom_description && sub.custom_description.trim()) return ''
return ''
}
export function sectionTotal (items) {
return items.reduce((s, sub) => s + parseFloat(sub.actual_price || 0), 0)
}
export function annualPrice (sub) {
return parseFloat(sub.actual_price || 0) * 12
}
/**
* Composable for managing subscription grouping, section state, and location-level helpers.
* @param {import('vue').Ref<Array>} subscriptions - Reactive ref of all subscriptions
*/
export function useSubscriptionGroups (subscriptions) {
const subSections = reactive({}) // { "locName:freq": { sectionKey: [subs] } }
const openSections = reactive({}) // { "locName:sectionKey": true/false }
function locSubs (locName) {
return subscriptions.value.filter(s => s.service_location === locName)
}
function locSubsMonthly (locName) {
return locSubs(locName).filter(s => s.billing_frequency !== 'A')
}
function locSubsAnnual (locName) {
return locSubs(locName).filter(s => s.billing_frequency === 'A')
}
function locSubsMonthlyTotal (locName) {
return locSubsMonthly(locName).reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
}
function locSubsAnnualTotal (locName) {
return locSubsAnnual(locName).reduce((sum, s) => sum + annualPrice(s), 0)
}
function locSubsSections (locName, freq) {
const cacheKey = locName + ':' + freq
if (!subSections[cacheKey]) {
const subs = freq === 'A' ? locSubsAnnual(locName) : locSubsMonthly(locName)
const groups = {}
for (const sub of subs) {
const sec = subSection(sub)
if (!groups[sec]) groups[sec] = []
groups[sec].push(sub)
}
for (const items of Object.values(groups)) {
items.sort((a, b) => parseFloat(b.actual_price || 0) - parseFloat(a.actual_price || 0))
}
subSections[cacheKey] = groups
}
const result = []
for (const key of SECTION_ORDER) {
if (subSections[cacheKey][key]?.length) {
result.push({ key, label: key, icon: SECTION_MAP[key]?.icon || 'label', items: subSections[cacheKey][key] })
}
}
for (const [key, items] of Object.entries(subSections[cacheKey])) {
if (!SECTION_ORDER.includes(key) && items.length) {
result.push({ key, label: key, icon: SECTION_MAP[key]?.icon || 'label', items })
}
}
return result
}
function sectionOpen (locName, sectionKey) {
const k = locName + ':' + sectionKey
if (openSections[k] === undefined) openSections[k] = false
return openSections[k]
}
function toggleSection (locName, sectionKey) {
const k = locName + ':' + sectionKey
openSections[k] = !sectionOpen(locName, sectionKey)
}
function invalidateCache (locName) {
delete subSections[locName + ':A']
delete subSections[locName + ':M']
}
function invalidateAll () {
for (const k of Object.keys(subSections)) delete subSections[k]
}
return {
subSections,
openSections,
locSubs,
locSubsMonthly,
locSubsAnnual,
locSubsMonthlyTotal,
locSubsAnnualTotal,
locSubsSections,
sectionOpen,
toggleSection,
invalidateCache,
invalidateAll,
}
}

View File

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

View File

@ -0,0 +1,16 @@
export const BASE_URL = ''
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
export const TECH_COLORS = [
'#6366f1', // Indigo
'#10b981', // Emerald
'#f59e0b', // Amber
'#8b5cf6', // Violet
'#06b6d4', // Cyan
'#f43f5e', // Rose
'#f97316', // Orange
'#14b8a6', // Teal
'#d946ef', // Fuchsia
'#3b82f6', // Blue
]

90
apps/ops/src/css/app.scss Normal file
View File

@ -0,0 +1,90 @@
// Targo Ops Global styles
:root {
--ops-primary: #1e293b;
--ops-accent: #6366f1;
--ops-success: #10b981;
--ops-warning: #f59e0b;
--ops-danger: #ef4444;
--ops-bg: #f8fafc;
--ops-surface: #ffffff;
--ops-border: #e2e8f0;
--ops-text: #1e293b;
--ops-text-muted: #64748b;
}
body {
background: var(--ops-bg);
color: var(--ops-text);
}
// Sidebar
.ops-sidebar {
background: var(--ops-primary);
width: 220px;
.q-item {
color: rgba(255,255,255,0.7);
border-radius: 8px;
margin: 2px 8px;
&:hover { background: rgba(255,255,255,0.08); }
&.active-link {
color: #fff;
background: var(--ops-accent);
}
}
}
// Cards
.ops-card {
background: var(--ops-surface);
border: 1px solid var(--ops-border);
border-radius: 12px;
padding: 16px;
}
// Stat cards
.ops-stat {
text-align: center;
.ops-stat-value {
font-size: 1.8rem;
font-weight: 700;
line-height: 1.2;
}
.ops-stat-label {
font-size: 0.8rem;
color: var(--ops-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
// Data tables
.ops-table {
.q-table__top { padding: 8px 16px; }
th { font-weight: 600; color: var(--ops-text-muted); font-size: 0.75rem; text-transform: uppercase; }
td { font-size: 0.875rem; }
}
// Status badges
.ops-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
&.active { background: #d1fae5; color: #065f46; }
&.inactive { background: #fee2e2; color: #991b1b; }
&.draft { background: #e0e7ff; color: #3730a3; }
&.open { background: #fef3c7; color: #92400e; }
&.closed { background: #f1f5f9; color: #475569; }
}
// Search bar
.ops-search {
.q-field__control {
border-radius: 10px;
background: var(--ops-surface);
border: 1px solid var(--ops-border);
}
}

View File

@ -0,0 +1,589 @@
<template>
<q-layout view="lHh LpR fFf">
<!-- Sidebar -->
<q-drawer v-model="drawer" :width="220" :breakpoint="1024" bordered class="ops-sidebar">
<q-list>
<!-- Logo -->
<q-item class="q-py-md q-mb-sm" style="pointer-events:none">
<q-item-section avatar>
<q-icon name="hub" size="28px" color="white" />
</q-item-section>
<q-item-section>
<q-item-label style="color:#fff;font-size:1.1rem;font-weight:700">Targo Ops</q-item-label>
</q-item-section>
</q-item>
<q-separator dark class="q-mb-sm" />
<!-- Nav items -->
<q-item
v-for="nav in navItems" :key="nav.path"
clickable :to="nav.path"
:class="{ 'active-link': $route.path === nav.path }"
>
<q-item-section avatar>
<q-icon :name="nav.icon" size="22px" />
</q-item-section>
<q-item-section>
<q-item-label>{{ nav.label }}</q-item-label>
</q-item-section>
<q-item-section side v-if="nav.badge">
<q-badge color="red" :label="nav.badge" rounded />
</q-item-section>
</q-item>
</q-list>
<!-- Bottom: user -->
<template #mini><!-- prevent mini drawer --></template>
<div style="position:absolute;bottom:0;left:0;right:0;padding:12px">
<q-separator dark class="q-mb-sm" />
<q-item dense clickable @click="auth.doLogout()">
<q-item-section avatar>
<q-icon name="logout" size="20px" color="grey-6" />
</q-item-section>
<q-item-section>
<q-item-label style="color:rgba(255,255,255,0.5);font-size:0.8rem">
{{ auth.user || 'User' }}
</q-item-label>
</q-item-section>
</q-item>
</div>
</q-drawer>
<!-- Header (mobile) -->
<q-header v-if="$q.screen.lt.lg" class="bg-white text-dark" bordered>
<q-toolbar>
<q-btn flat round dense icon="menu" @click="drawer = !drawer" />
<q-toolbar-title class="text-weight-bold" style="font-size:1rem">
{{ currentNav?.label || 'Targo Ops' }}
</q-toolbar-title>
<q-space />
<q-btn flat round dense icon="search" @click="showMobileSearch = !showMobileSearch" />
</q-toolbar>
<!-- Mobile search dropdown -->
<div v-if="showMobileSearch" class="q-px-sm q-pb-sm bg-white">
<q-input
ref="mobileSearchRef"
v-model="globalSearch"
placeholder="Rechercher client, adresse..."
dense outlined autofocus
class="ops-search"
@update:model-value="onSearchInput"
@keyup.enter="goToFirstResult"
@blur="onSearchBlur"
>
<template #prepend><q-icon name="search" color="grey-6" /></template>
<template #append v-if="globalSearch">
<q-icon name="close" class="cursor-pointer" @click="clearSearch" />
</template>
</q-input>
<div v-if="searchResults.length" class="search-dropdown">
<div v-for="r in searchResults" :key="r.id" class="search-result" @mousedown="goToResult(r)">
<q-icon :name="r.icon" size="18px" color="grey-6" class="q-mr-sm" />
<div>
<div class="search-result-title">{{ r.title }}</div>
<div class="search-result-sub">{{ r.sub }}</div>
</div>
</div>
</div>
</div>
</q-header>
<!-- Main content -->
<q-page-container>
<!-- Top bar (desktop) -->
<div v-if="$q.screen.gt.md" class="row items-center q-px-lg q-py-sm" style="border-bottom:1px solid var(--ops-border);position:relative">
<div class="text-h6 text-weight-bold">{{ currentNav?.label || '' }}</div>
<q-space />
<div style="position:relative;width:440px">
<q-input
ref="desktopSearchRef"
v-model="globalSearch"
placeholder="Rechercher client, adresse, ticket..."
dense outlined
class="ops-search"
@update:model-value="onSearchInput"
@keyup.enter="goToFirstResult"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrev"
@keydown.escape="clearSearch"
@focus="onSearchFocus"
@blur="onSearchBlur"
>
<template #prepend>
<q-icon name="search" color="grey-6" />
</template>
<template #append>
<q-spinner v-if="searching" size="16px" color="grey-5" />
<q-icon v-else-if="globalSearch" name="close" class="cursor-pointer" @click="clearSearch" />
<q-icon
:name="showAdvanced ? 'expand_less' : 'tune'"
class="cursor-pointer q-ml-xs"
color="grey-6"
size="18px"
@click.stop="toggleAdvanced"
/>
</template>
</q-input>
<!-- Dropdown results -->
<div v-if="showDropdown && searchResults.length" class="search-dropdown">
<div
v-for="(r, i) in searchResults" :key="r.id"
class="search-result"
:class="{ highlighted: i === highlightIndex }"
@mousedown="goToResult(r)"
>
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
<div class="col">
<div class="search-result-title">{{ r.title }}</div>
<div class="search-result-sub">{{ r.sub }}</div>
</div>
<div class="search-result-type">{{ r.typeLabel }}</div>
</div>
</div>
<div v-else-if="showDropdown && globalSearch.length >= 2 && !searching" class="search-dropdown">
<div class="search-result text-grey-5" style="justify-content:center">Aucun résultat</div>
</div>
<!-- Advanced search panel -->
<div v-if="showAdvanced" class="advanced-search-panel" @mousedown.prevent>
<div class="text-weight-bold q-mb-sm" style="font-size:0.85rem;color:var(--ops-text)">Recherche avancée</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-input v-model="adv.customerName" label="Nom client" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.customerId" label="# Client (ID)" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.address" label="Adresse" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.city" label="Ville" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.postalCode" label="Code postal" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-select v-model="adv.territory" label="Territoire" dense outlined emit-value map-options :options="territoryOptions" clearable class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-select v-model="adv.status" label="Statut adresse" dense outlined emit-value map-options :options="statusOptions" clearable class="adv-input" />
</div>
<div class="col-6">
<q-select v-model="adv.customerType" label="Type client" dense outlined emit-value map-options :options="[{label:'Individu',value:'Individual'},{label:'Entreprise',value:'Company'}]" clearable class="adv-input" />
</div>
</div>
<div class="row q-mt-sm q-gutter-sm justify-end">
<q-btn flat dense label="Effacer" color="grey-7" size="sm" @click="clearAdvanced" />
<q-btn unelevated dense label="Rechercher" color="primary" size="sm" :loading="advSearching" @click="runAdvancedSearch" />
</div>
<!-- Advanced results -->
<div v-if="advResults.length" class="q-mt-sm" style="max-height:300px;overflow-y:auto">
<div
v-for="r in advResults" :key="r.id"
class="search-result"
@click="goToResult(r); showAdvanced = false"
>
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
<div class="col">
<div class="search-result-title">{{ r.title }}</div>
<div class="search-result-sub">{{ r.sub }}</div>
</div>
<div class="search-result-type">{{ r.typeLabel }}</div>
</div>
</div>
<div v-else-if="advSearched && !advSearching" class="text-grey-5 text-center q-py-sm" style="font-size:0.8rem">
Aucun résultat
</div>
</div>
</div>
</div>
<router-view />
</q-page-container>
</q-layout>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
import { listDocs } from 'src/api/erp'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const drawer = ref(true)
const showMobileSearch = ref(false)
const globalSearch = ref('')
const searchResults = ref([])
const searching = ref(false)
const showDropdown = ref(false)
const highlightIndex = ref(-1)
// Advanced search
const showAdvanced = ref(false)
const advSearching = ref(false)
const advSearched = ref(false)
const advResults = ref([])
const adv = reactive({
customerName: '',
customerId: '',
address: '',
city: '',
postalCode: '',
territory: null,
status: null,
customerType: null,
})
const territoryOptions = [
{ label: 'Gatineau', value: 'Gatineau' },
{ label: 'Ottawa', value: 'Ottawa' },
{ label: 'Aylmer', value: 'Aylmer' },
{ label: 'Hull', value: 'Hull' },
{ label: 'Buckingham', value: 'Buckingham' },
{ label: 'Masson-Angers', value: 'Masson-Angers' },
]
const statusOptions = [
{ label: 'Actif', value: 'Active' },
{ label: 'Inactif', value: 'Inactive' },
{ label: 'En attente', value: 'Pending' },
]
let searchTimer = null
const navItems = [
{ path: '/', icon: 'dashboard', label: 'Tableau de bord' },
{ path: '/clients', icon: 'people', label: 'Clients' },
{ path: '/dispatch', icon: 'local_shipping', label: 'Dispatch' },
{ path: '/tickets', icon: 'confirmation_number', label: 'Tickets' },
{ path: '/equipe', icon: 'groups', label: 'Équipe' },
{ path: '/rapports', icon: 'bar_chart', label: 'Rapports' },
]
const currentNav = computed(() => navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/'))
function onSearchInput (val) {
highlightIndex.value = -1
clearTimeout(searchTimer)
// Easter egg: @targo Authentik admin login
if (val && val.trim().toLowerCase() === '@targo') {
clearSearch()
window.location.href = 'https://auth.targo.ca/if/flow/default-authentication-flow/?next=' + encodeURIComponent(window.location.href)
return
}
if (!val || val.length < 2) {
searchResults.value = []
showDropdown.value = false
return
}
showDropdown.value = true
showAdvanced.value = false
searching.value = true
searchTimer = setTimeout(() => doSearch(val), 300)
}
async function doSearch (query) {
const q = query.trim()
if (q.length < 2) { searching.value = false; return }
try {
// Search customers by name AND by ID, plus locations by address, city, and postal code
const [custByName, custById, locByAddr, locByCity] = await Promise.all([
listDocs('Customer', {
filters: { customer_name: ['like', '%' + q + '%'] },
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'],
limit: 6,
orderBy: 'customer_name asc',
}).catch(() => []),
listDocs('Customer', {
filters: { name: ['like', '%' + q + '%'] },
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'],
limit: 4,
orderBy: 'name asc',
}).catch(() => []),
listDocs('Service Location', {
filters: { address_line: ['like', '%' + q + '%'] },
fields: ['name', 'address_line', 'city', 'customer', 'customer_name', 'status'],
limit: 6,
orderBy: 'address_line asc',
}).catch(() => []),
listDocs('Service Location', {
filters: { city: ['like', '%' + q + '%'] },
fields: ['name', 'address_line', 'city', 'customer', 'customer_name', 'status'],
limit: 4,
orderBy: 'city asc',
}).catch(() => []),
])
// Deduplicate
const seen = new Set()
const results = []
for (const c of [...custByName, ...custById]) {
if (seen.has(c.name)) continue
seen.add(c.name)
results.push({
id: 'c-' + c.name,
type: 'customer',
typeLabel: 'Client',
icon: 'person',
title: c.customer_name,
sub: c.name + (c.territory ? ' · ' + c.territory : '') + (c.disabled ? ' · Inactif' : ''),
route: '/clients/' + c.name,
})
}
for (const l of [...locByAddr, ...locByCity]) {
if (seen.has(l.name)) continue
seen.add(l.name)
results.push({
id: 'l-' + l.name,
type: 'location',
typeLabel: 'Adresse',
icon: 'location_on',
title: l.address_line + (l.city ? ', ' + l.city : ''),
sub: (l.customer_name || l.customer) + ' · ' + l.status,
route: '/clients/' + l.customer,
})
}
searchResults.value = results.slice(0, 12)
} catch {
searchResults.value = []
}
searching.value = false
}
function toggleAdvanced () {
showAdvanced.value = !showAdvanced.value
if (showAdvanced.value) {
showDropdown.value = false
}
}
async function runAdvancedSearch () {
advSearching.value = true
advSearched.value = false
advResults.value = []
try {
const promises = []
// Build customer filters
const custFilters = {}
if (adv.customerName) custFilters.customer_name = ['like', '%' + adv.customerName + '%']
if (adv.customerId) custFilters.name = ['like', '%' + adv.customerId + '%']
if (adv.customerType) custFilters.customer_type = adv.customerType
if (adv.territory) custFilters.territory = adv.territory
// Build location filters
const locFilters = {}
if (adv.address) locFilters.address_line = ['like', '%' + adv.address + '%']
if (adv.city) locFilters.city = ['like', '%' + adv.city + '%']
if (adv.postalCode) locFilters.postal_code = ['like', '%' + adv.postalCode + '%']
if (adv.status) locFilters.status = adv.status
const hasCustFilter = Object.keys(custFilters).length > 0
const hasLocFilter = Object.keys(locFilters).length > 0
if (hasCustFilter) {
promises.push(
listDocs('Customer', {
filters: custFilters,
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'],
limit: 20,
orderBy: 'customer_name asc',
}).catch(() => [])
)
} else {
promises.push(Promise.resolve([]))
}
if (hasLocFilter) {
promises.push(
listDocs('Service Location', {
filters: locFilters,
fields: ['name', 'address_line', 'city', 'customer', 'customer_name', 'status'],
limit: 20,
orderBy: 'address_line asc',
}).catch(() => [])
)
} else {
promises.push(Promise.resolve([]))
}
const [customers, locations] = await Promise.all(promises)
const results = []
for (const c of customers) {
results.push({
id: 'c-' + c.name,
type: 'customer',
typeLabel: 'Client',
icon: 'person',
title: c.customer_name,
sub: c.name + (c.territory ? ' · ' + c.territory : '') + (c.disabled ? ' · Inactif' : ''),
route: '/clients/' + c.name,
})
}
for (const l of locations) {
results.push({
id: 'l-' + l.name,
type: 'location',
typeLabel: 'Adresse',
icon: 'location_on',
title: l.address_line + (l.city ? ', ' + l.city : ''),
sub: (l.customer_name || l.customer) + ' · ' + l.status,
route: '/clients/' + l.customer,
})
}
advResults.value = results
} catch {
advResults.value = []
}
advSearching.value = false
advSearched.value = true
}
function clearAdvanced () {
Object.assign(adv, { customerName: '', customerId: '', address: '', city: '', postalCode: '', territory: null, status: null, customerType: null })
advResults.value = []
advSearched.value = false
}
function goToResult (r) {
router.push(r.route)
clearSearch()
}
function goToFirstResult () {
if (highlightIndex.value >= 0 && searchResults.value[highlightIndex.value]) {
goToResult(searchResults.value[highlightIndex.value])
} else if (searchResults.value.length) {
goToResult(searchResults.value[0])
} else if (globalSearch.value.trim()) {
router.push({ path: '/clients', query: { q: globalSearch.value.trim() } })
clearSearch()
}
}
function highlightNext () {
if (searchResults.value.length) {
highlightIndex.value = (highlightIndex.value + 1) % searchResults.value.length
}
}
function highlightPrev () {
if (searchResults.value.length) {
highlightIndex.value = highlightIndex.value <= 0 ? searchResults.value.length - 1 : highlightIndex.value - 1
}
}
function onSearchFocus () {
if (globalSearch.value.length >= 2 && searchResults.value.length) {
showDropdown.value = true
}
}
function onSearchBlur () {
setTimeout(() => {
showDropdown.value = false
if (!showAdvanced.value) return
}, 200)
}
function clearSearch () {
globalSearch.value = ''
searchResults.value = []
showDropdown.value = false
highlightIndex.value = -1
showMobileSearch.value = false
}
</script>
<style lang="scss" scoped>
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 9999;
background: #fff;
border: 1px solid var(--ops-border);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
max-height: 400px;
overflow-y: auto;
}
.search-result {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s;
&:hover, &.highlighted {
background: #f1f5f9;
}
&:not(:last-child) {
border-bottom: 1px solid #f1f5f9;
}
}
.search-result-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--ops-text);
line-height: 1.2;
}
.search-result-sub {
font-size: 0.75rem;
color: var(--ops-text-muted);
}
.search-result-type {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ops-text-muted);
margin-left: auto;
white-space: nowrap;
}
.advanced-search-panel {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 9999;
background: #fff;
border: 1px solid var(--ops-border);
border-top: none;
border-radius: 0 0 12px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 16px;
width: 520px;
right: auto;
}
.adv-input :deep(.q-field__label) {
font-size: 0.8rem;
}
.adv-input :deep(.q-field__native),
.adv-input :deep(.q-field__input) {
font-size: 0.85rem;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,125 @@
<template>
<q-page padding>
<!-- Search + filters -->
<div class="row items-center q-mb-md q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
v-model="search" dense outlined placeholder="Nom, téléphone, adresse..."
class="ops-search" @update:model-value="onSearchInput" @keyup.enter="doSearch" autofocus
>
<template #prepend><q-icon name="search" /></template>
<template #append>
<q-spinner v-if="loading" size="16px" color="grey-5" />
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; doSearch()" />
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn-toggle v-model="statusFilter" no-caps dense unelevated
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
:options="[
{ label: 'Tous', value: 'all' },
{ label: 'Actifs', value: 'active' },
{ label: 'Inactifs', value: 'disabled' },
]"
@update:model-value="doSearch"
/>
</div>
<q-space />
<div class="text-caption text-grey-6">{{ total.toLocaleString() }} clients</div>
</div>
<!-- Client table -->
<q-table
:rows="clients" :columns="columns" row-key="name"
flat bordered class="ops-table"
:loading="loading"
:pagination="pagination"
@request="onRequest"
@row-click="(_, row) => $router.push('/clients/' + row.name)"
style="cursor:pointer"
>
<template #body-cell-status="props">
<q-td :props="props">
<span class="ops-badge" :class="props.row.disabled ? 'inactive' : 'active'">
{{ props.row.disabled ? 'Inactif' : 'Actif' }}
</span>
</q-td>
</template>
<template #body-cell-customer_name="props">
<q-td :props="props">
<div class="text-weight-medium">{{ props.row.customer_name }}</div>
<div class="text-caption text-grey-6">{{ props.row.name }}</div>
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { listDocs, countDocs } from 'src/api/erp'
const route = useRoute()
const search = ref(route.query.q || '')
const statusFilter = ref('active')
const clients = ref([])
const loading = ref(false)
const total = ref(0)
const pagination = ref({ page: 1, rowsPerPage: 25, rowsNumber: 0 })
let searchTimer = null
const columns = [
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'customer_type', label: 'Type', field: 'customer_type', align: 'left' },
{ name: 'customer_group', label: 'Groupe', field: 'customer_group', align: 'left' },
{ name: 'territory', label: 'Territoire', field: 'territory', align: 'left' },
{ name: 'status', label: 'Statut', align: 'center' },
]
function onSearchInput () {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
pagination.value.page = 1
doSearch()
}, 300)
}
async function doSearch () {
loading.value = true
const filters = {}
if (statusFilter.value === 'active') filters.disabled = 0
else if (statusFilter.value === 'disabled') filters.disabled = 1
if (search.value.trim()) {
filters.customer_name = ['like', '%' + search.value.trim() + '%']
}
const [data, count] = await Promise.all([
listDocs('Customer', {
filters,
fields: ['name', 'customer_name', 'customer_type', 'customer_group', 'territory', 'disabled'],
limit: pagination.value.rowsPerPage,
offset: (pagination.value.page - 1) * pagination.value.rowsPerPage,
orderBy: 'customer_name asc',
}),
countDocs('Customer', filters),
])
clients.value = data
total.value = count
pagination.value.rowsNumber = count
loading.value = false
}
function onRequest (props) {
pagination.value.page = props.pagination.page
pagination.value.rowsPerPage = props.pagination.rowsPerPage
doSearch()
}
onMounted(doSearch)
watch(() => route.query.q, (q) => { if (q) { search.value = q; doSearch() } })
</script>

View File

@ -0,0 +1,204 @@
<template>
<q-page padding>
<!-- KPI cards -->
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
<div class="ops-card ops-stat">
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
<!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Planificateur</div>
<div class="row items-center q-gutter-md">
<q-chip :color="schedulerEnabled ? 'positive' : 'negative'" text-color="white" icon="schedule">
{{ schedulerEnabled ? 'Actif' : 'Désactivé' }}
</q-chip>
<q-btn
:label="schedulerEnabled ? 'Désactiver' : 'Activer'"
:color="schedulerEnabled ? 'negative' : 'positive'"
:icon="schedulerEnabled ? 'pause' : 'play_arrow'"
:loading="togglingScheduler"
dense no-caps
@click="toggleScheduler"
/>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Facturation récurrente</div>
<div class="row items-center q-gutter-md">
<q-btn
label="Générer les factures"
color="primary"
icon="receipt_long"
:loading="runningBilling"
dense no-caps
@click="runBilling"
/>
<span v-if="billingResult" class="text-caption" :class="billingResult.ok ? 'text-positive' : 'text-negative'">
{{ billingResult.message }}
</span>
</div>
<div class="text-caption text-grey-6 q-mt-xs">
Déclenche manuellement la création des factures récurrentes dues
</div>
</div>
</div>
</div>
<!-- Recent activity -->
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div>
<q-list separator>
<q-item v-for="t in openTickets" :key="t.name" clickable>
<q-item-section>
<q-item-label>{{ t.subject }}</q-item-label>
<q-item-label caption>{{ t.customer_name || t.customer }}</q-item-label>
</q-item-section>
<q-item-section side>
<span class="ops-badge open">{{ t.priority }}</span>
</q-item-section>
</q-item>
<q-item v-if="!openTickets.length">
<q-item-section>
<q-item-label caption>Aucun ticket ouvert</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Dispatch aujourd'hui</div>
<q-list separator>
<q-item v-for="j in todayJobs" :key="j.name" clickable>
<q-item-section>
<q-item-label>{{ j.subject || j.name }}</q-item-label>
<q-item-label caption>{{ j.customer_name }}</q-item-label>
</q-item-section>
<q-item-section side>
<span class="ops-badge" :class="j.status === 'Completed' ? 'closed' : 'active'">{{ j.status }}</span>
</q-item-section>
</q-item>
<q-item v-if="!todayJobs.length">
<q-item-section>
<q-item-label caption>Aucune tâche aujourd'hui</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)' },
{ label: 'Abonnements', value: '...', color: 'var(--ops-success)' },
{ label: 'Locations', value: '...', color: '#6b7280' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)' },
])
const openTickets = ref([])
const todayJobs = ref([])
// Scheduler controls
const schedulerEnabled = ref(false)
const togglingScheduler = ref(false)
async function fetchSchedulerStatus () {
try {
const res = await authFetch(BASE_URL + '/api/method/scheduler_status')
if (res.ok) {
const data = await res.json()
schedulerEnabled.value = data.message?.status === 'enabled'
}
} catch {}
}
async function toggleScheduler () {
togglingScheduler.value = true
try {
const res = await authFetch(BASE_URL + '/api/method/toggle_scheduler', { method: 'POST' })
if (res.ok) {
const data = await res.json()
schedulerEnabled.value = data.message?.status === 'enabled'
}
} catch {}
togglingScheduler.value = false
}
// Manual billing
const runningBilling = ref(false)
const billingResult = ref(null)
async function runBilling () {
runningBilling.value = true
billingResult.value = null
try {
const res = await authFetch(BASE_URL + '/api/method/erpnext.accounts.doctype.subscription.subscription.process_all', {
method: 'POST',
})
if (res.ok) {
billingResult.value = { ok: true, message: 'Factures générées avec succès' }
} else {
billingResult.value = { ok: false, message: 'Erreur ' + res.status }
}
} catch (e) {
billingResult.value = { ok: false, message: e.message }
}
runningBilling.value = false
}
onMounted(async () => {
fetchSchedulerStatus()
const [clients, tickets, subs, locations] = await Promise.all([
countDocs('Customer', { disabled: 0 }),
countDocs('Issue', { status: 'Open' }),
countDocs('Service Subscription', { status: 'Actif' }),
countDocs('Service Location', { status: 'Active' }),
])
// Abonnés = unique customers with active subscriptions (via server script)
let abonnes = 0
try {
const res = await authFetch(BASE_URL + '/api/method/subscriber_count')
if (res.ok) {
const data = await res.json()
abonnes = data.message?.count || 0
}
} catch {
abonnes = clients
}
stats.value[0].value = abonnes.toLocaleString()
stats.value[1].value = clients.toLocaleString()
stats.value[2].value = subs.toLocaleString()
stats.value[3].value = locations.toLocaleString()
stats.value[4].value = tickets.toLocaleString()
openTickets.value = await listDocs('Issue', {
filters: { status: 'Open' },
fields: ['name', 'subject', 'customer', 'priority', 'opening_date'],
limit: 10,
orderBy: 'opening_date desc',
})
})
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,69 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<div class="text-h6 text-weight-bold">Équipe</div>
<q-space />
<div class="text-caption text-grey-6">Module en développement</div>
</div>
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md-3" v-for="stat in stats" :key="stat.label">
<div class="ops-card ops-stat">
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Techniciens</div>
<q-table
:rows="techs" :columns="columns" row-key="name"
flat dense class="ops-table"
:loading="loading"
:pagination="{ rowsPerPage: 20 }"
/>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listDocs } from 'src/api/erp'
const loading = ref(false)
const techs = ref([])
const stats = ref([
{ label: 'Techniciens', value: '...', color: 'var(--ops-accent)' },
{ label: 'En service', value: '...', color: 'var(--ops-success)' },
{ label: 'Congé', value: '...', color: 'var(--ops-warning)' },
{ label: 'Tâches aujourd\'hui', value: '...', color: 'var(--ops-primary)' },
])
const columns = [
{ name: 'employee_name', label: 'Nom', field: 'employee_name', align: 'left' },
{ name: 'designation', label: 'Poste', field: 'designation', align: 'left' },
{ name: 'department', label: 'Département', field: 'department', align: 'left' },
{ name: 'cell_number', label: 'Téléphone', field: 'cell_number', align: 'left' },
{ name: 'office_extension', label: 'Ext.', field: 'office_extension', align: 'center' },
{ name: 'company_email', label: 'Courriel', field: 'company_email', align: 'left' },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
onMounted(async () => {
loading.value = true
try {
techs.value = await listDocs('Employee', {
filters: { status: 'Active' },
fields: ['name', 'employee_name', 'designation', 'department', 'status', 'cell_number', 'company_email', 'office_extension'],
limit: 50,
orderBy: 'employee_name asc',
})
stats.value[0].value = techs.value.length.toString()
stats.value[1].value = techs.value.filter(t => t.status === 'Active').length.toString()
} catch {
techs.value = []
}
loading.value = false
})
</script>

View File

@ -0,0 +1,64 @@
<template>
<q-page padding>
<div class="text-h6 text-weight-bold q-mb-md">Rapports</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4" v-for="report in reports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px" @click="report.action">
<div class="row items-center q-mb-sm">
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
</div>
<div class="text-caption text-grey-6">{{ report.description }}</div>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
const reports = [
{
title: 'Clients par territoire',
description: 'Répartition des clients actifs par zone géographique.',
icon: 'map',
color: 'indigo-6',
action: () => {},
},
{
title: 'Revenus mensuels',
description: 'Évolution des revenus d\'abonnement sur les 12 derniers mois.',
icon: 'trending_up',
color: 'green-6',
action: () => {},
},
{
title: 'Tickets par priorité',
description: 'Analyse des tickets ouverts et temps de résolution moyen.',
icon: 'bug_report',
color: 'orange-6',
action: () => {},
},
{
title: 'Équipements déployés',
description: 'Inventaire des ONT, routeurs et modems par statut.',
icon: 'router',
color: 'blue-6',
action: () => {},
},
{
title: 'Abonnements actifs',
description: 'Liste des abonnements avec plan, montant et ancienneté.',
icon: 'subscriptions',
color: 'purple-6',
action: () => {},
},
{
title: 'Dispatch performance',
description: 'Taux de complétion des tâches et temps moyen par technicien.',
icon: 'speed',
color: 'red-6',
action: () => {},
},
]
</script>

View File

@ -0,0 +1,319 @@
<template>
<q-page padding>
<!-- Filters row -->
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-12 col-md-3">
<div class="filter-label">Recherche</div>
<q-input v-model="search" dense outlined placeholder="Sujet, client, #ticket..." class="ops-search"
@keyup.enter="loadTickets" clearable @clear="loadTickets">
<template #prepend><q-icon name="search" /></template>
</q-input>
</div>
<div class="col-6 col-md-2">
<div class="filter-label">Statut</div>
<q-select v-model="statusFilter" dense outlined emit-value map-options
:options="statusOptions" @update:model-value="resetAndLoad" />
</div>
<div class="col-6 col-md-2">
<div class="filter-label">Type / Département</div>
<q-select v-model="typeFilter" dense outlined emit-value map-options clearable
:options="issueTypes" @update:model-value="resetAndLoad" placeholder="Tous" />
</div>
<div class="col-6 col-md-2">
<div class="filter-label">Priorité</div>
<q-select v-model="priorityFilter" dense outlined emit-value map-options clearable
:options="priorityOptions" @update:model-value="resetAndLoad" placeholder="Toutes" />
</div>
<div class="col-6 col-md-2">
<div class="filter-label">Mes tickets</div>
<q-btn-toggle v-model="ownerFilter" no-caps dense unelevated
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
:options="[
{ label: 'Tous', value: 'all', icon: 'groups' },
{ label: 'Mes tickets', value: 'mine', icon: 'person' },
]"
@update:model-value="resetAndLoad"
/>
</div>
<div class="col-auto">
<div class="text-caption text-grey-6 q-mt-sm">{{ total.toLocaleString() }} tickets</div>
</div>
</div>
<!-- Table -->
<q-table
:rows="tickets" :columns="columns" row-key="name"
flat bordered class="ops-table clickable-table"
:loading="loading"
v-model:pagination="pagination"
@request="onRequest"
@row-click="(_, row) => openTicketModal(row)"
>
<template #body-cell-important="props">
<q-td :props="props" style="padding:0 4px">
<q-icon v-if="props.row.is_important" name="star" color="amber-7" size="18px">
<q-tooltip>Ticket important</q-tooltip>
</q-icon>
</q-td>
</template>
<template #body-cell-legacy_id="props">
<q-td :props="props">
<span v-if="props.row.legacy_ticket_id" class="text-caption text-weight-medium text-indigo-6">#{{ props.row.legacy_ticket_id }}</span>
</q-td>
</template>
<template #body-cell-subject="props">
<q-td :props="props">
<div class="text-weight-medium">{{ props.row.subject }}</div>
<div class="text-caption text-grey-6">{{ props.row.name }}</div>
</q-td>
</template>
<template #body-cell-customer_name="props">
<q-td :props="props">
<router-link v-if="props.row.customer" :to="'/clients/' + props.row.customer" class="erp-link" @click.stop>
{{ props.row.customer_name || props.row.customer }}
</router-link>
<span v-else class="text-grey-5"></span>
</q-td>
</template>
<template #body-cell-opening_date="props">
<q-td :props="props">
{{ formatDate(props.row.opening_date) }}
</q-td>
</template>
<template #body-cell-status="props">
<q-td :props="props">
<span class="ops-badge" :class="statusClass(props.row.status)">{{ props.row.status }}</span>
</q-td>
</template>
<template #body-cell-priority="props">
<q-td :props="props">
<span class="ops-badge" :class="priorityClass(props.row.priority)">{{ props.row.priority }}</span>
</q-td>
</template>
<template #body-cell-issue_type="props">
<q-td :props="props">
<q-chip v-if="props.row.issue_type" dense size="sm" color="grey-3" text-color="grey-8">
{{ props.row.issue_type }}
</q-chip>
</q-td>
</template>
</q-table>
<!-- TICKET DETAIL MODAL -->
<DetailModal
v-model:open="modalOpen"
:loading="modalLoading"
doctype="Issue"
:doc-name="modalTicket?.name"
:title="modalTicket?.subject"
:doc="modalDoc"
:comments="modalComments"
:comms="modalComms"
:files="modalFiles"
@navigate="(dt, name) => loadModalTicket(name)"
>
<template #title-prefix>
<q-icon v-if="modalTicket?.is_important" name="star" color="amber-7" size="18px" class="q-mr-xs" />
</template>
<template #title-suffix>
<template v-if="modalTicket?.legacy_ticket_id"> &middot; <span class="text-indigo-6">#{{ modalTicket.legacy_ticket_id }}</span></template>
<template v-if="modalTicket?.customer_name"> &middot; {{ modalTicket.customer_name }}</template>
</template>
<template #header-actions>
<q-btn v-if="modalTicket?.customer" flat dense round icon="person" class="q-mr-xs"
@click="$router.push('/clients/' + modalTicket.customer); modalOpen = false">
<q-tooltip>Voir le client</q-tooltip>
</q-btn>
</template>
</DetailModal>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listDocs, countDocs } from 'src/api/erp'
import { formatDate } from 'src/composables/useFormatters'
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal'
import DetailModal from 'src/components/shared/DetailModal.vue'
const search = ref('')
const statusFilter = ref('all')
const typeFilter = ref(null)
const priorityFilter = ref(null)
const ownerFilter = ref('all')
const tickets = ref([])
const loading = ref(false)
const total = ref(0)
const pagination = ref({ page: 1, rowsPerPage: 25, rowsNumber: 0, sortBy: 'creation', descending: true })
// Modal state (shared composable)
const { modalOpen, modalLoading, modalDoc, modalComments, modalComms, modalFiles, openModal } = useDetailModal()
const modalTicket = ref(null)
const statusOptions = [
{ label: 'Tous', value: 'all' },
{ label: 'Non fermés', value: 'not_closed' },
{ label: 'Ouverts', value: 'Open' },
{ label: 'Répondus', value: 'Replied' },
{ label: 'Résolus', value: 'Resolved' },
{ label: 'Fermés', value: 'Closed' },
]
const priorityOptions = [
{ label: 'Urgent', value: 'Urgent' },
{ label: 'Haute', value: 'High' },
{ label: 'Moyenne', value: 'Medium' },
{ label: 'Basse', value: 'Low' },
]
// Will be populated from ERPNext
const issueTypes = ref([])
const columns = [
{ name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:30px;padding:0' },
{ name: 'legacy_id', label: '#', field: 'legacy_ticket_id', align: 'left', sortable: true, style: 'width:70px' },
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'issue_type', label: 'Type', field: 'issue_type', align: 'left' },
{ name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true },
{ name: 'priority', label: 'Priorité', field: 'priority', align: 'center', sortable: true },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
// openExternal used for any link in new tab
function openExternal (url) {
window.open(url, '_blank')
}
function buildFilters () {
const filters = {}
if (statusFilter.value === 'not_closed') {
filters.status = ['!=', 'Closed']
} else if (statusFilter.value !== 'all') {
filters.status = statusFilter.value
}
if (typeFilter.value) filters.issue_type = typeFilter.value
if (priorityFilter.value) filters.priority = priorityFilter.value
if (search.value.trim()) {
const q = search.value.trim()
// If search is a number, search by legacy ticket ID
if (/^\d+$/.test(q)) {
filters.legacy_ticket_id = parseInt(q)
} else {
filters.subject = ['like', '%' + q + '%']
}
}
if (ownerFilter.value === 'mine') {
filters.owner = ['like', '%']
}
return filters
}
function resetAndLoad () {
pagination.value.page = 1
loadTickets()
}
// Map column names to actual sortable fields
function getSortField (col) {
if (col === 'opening_date') return 'creation'
if (col === 'legacy_id') return 'legacy_ticket_id'
return col || 'creation'
}
async function loadTickets () {
loading.value = true
const filters = buildFilters()
const limit = Math.min(pagination.value.rowsPerPage, 100)
try {
const [data, count] = await Promise.all([
listDocs('Issue', {
filters,
fields: ['name', 'subject', 'customer_name', 'customer', 'opening_date', 'priority', 'status', 'issue_type', 'owner', 'creation', 'legacy_ticket_id', 'is_important'],
limit,
offset: (pagination.value.page - 1) * limit,
orderBy: 'is_important desc, ' + getSortField(pagination.value.sortBy) + (pagination.value.descending ? ' desc' : ' asc'),
}),
countDocs('Issue', filters),
])
tickets.value = data
total.value = count
pagination.value.rowsNumber = count
} catch (e) {
console.error('Failed to load tickets', e)
tickets.value = []
total.value = 0
pagination.value.rowsNumber = 0
}
loading.value = false
}
function onRequest (props) {
pagination.value.page = props.pagination.page
pagination.value.rowsPerPage = Math.min(props.pagination.rowsPerPage, 100)
pagination.value.sortBy = props.pagination.sortBy
pagination.value.descending = props.pagination.descending
loadTickets()
}
async function openTicketModal (row) {
modalTicket.value = row
await openModal('Issue', row.name, row.subject)
// Sync full doc back to modalTicket for header display
if (modalDoc.value) modalTicket.value = { ...modalTicket.value, ...modalDoc.value }
}
async function loadModalTicket (ticketName) {
await openModal('Issue', ticketName)
if (modalDoc.value) modalTicket.value = { ...modalTicket.value, ...modalDoc.value }
}
async function loadIssueTypes () {
try {
const types = await listDocs('Issue Type', {
fields: ['name'],
limit: 100,
orderBy: 'name asc',
})
issueTypes.value = types.map(t => ({ label: t.name, value: t.name }))
} catch {
issueTypes.value = []
}
}
onMounted(async () => {
await loadIssueTypes()
await loadTickets()
})
</script>
<style scoped>
.filter-label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.erp-link {
color: #6366f1;
text-decoration: none;
cursor: pointer;
}
.erp-link:hover {
text-decoration: underline;
}
.clickable-table :deep(tbody tr) {
cursor: pointer;
}
.clickable-table :deep(tbody tr:hover td) {
background: #eef2ff !important;
}
/* Modal styles are in DetailModal.vue */
</style>

View File

@ -0,0 +1,26 @@
import { route } from 'quasar/wrappers'
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('src/layouts/MainLayout.vue'),
children: [
{ path: '', component: () => import('src/pages/DashboardPage.vue') },
{ path: 'clients', component: () => import('src/pages/ClientsPage.vue') },
{ path: 'clients/:id', component: () => import('src/pages/ClientDetailPage.vue'), props: true },
{ path: 'tickets', component: () => import('src/pages/TicketsPage.vue') },
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
],
},
// Dispatch V2 — full-screen immersive UI with its own header/sidebar
{ path: '/dispatch', component: () => import('src/pages/DispatchPage.vue') },
]
export default route(function () {
return createRouter({
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
routes,
})
})

View File

@ -0,0 +1,21 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getLoggedUser, logout } from 'src/api/auth'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const loading = ref(true)
async function checkSession () {
loading.value = true
try {
user.value = await getLoggedUser()
} catch {
user.value = 'authenticated'
} finally {
loading.value = false
}
}
return { user, loading, checkSession, doLogout: logout }
})

View File

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