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>
151
CONTEXT.md
Normal 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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
1
apps/ops/.quasar/artifacts.json
Normal 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"]}
|
||||
160
apps/ops/.quasar/client-entry.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
116
apps/ops/.quasar/client-prefetch.js
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
21
apps/ops/.quasar/quasar-user-options.js
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||
47
apps/ops/infra/docker-compose.yaml
Normal 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
|
||||
102
apps/ops/infra/migrate-to-erpnext.sh
Executable 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
|
|
@ -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
37
apps/ops/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
apps/ops/public/icons/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/ops/public/icons/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/ops/public/icons/apple-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/ops/public/icons/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/ops/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/ops/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/ops/public/icons/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/ops/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/ops/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/ops/public/icons/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
apps/ops/public/icons/safari-pinned-tab.svg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
66
apps/ops/quasar.config.js
Normal 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 = '.'
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
26
apps/ops/src-pwa/custom-service-worker.js
Normal 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$/] }
|
||||
)
|
||||
)
|
||||
}
|
||||
32
apps/ops/src-pwa/manifest.json
Normal 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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
20
apps/ops/src-pwa/register-service-worker.js
Normal 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
|
|
@ -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
|
|
@ -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/'
|
||||
}
|
||||
142
apps/ops/src/api/dispatch.js
Normal 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
|
|
@ -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
|
||||
}
|
||||
260
apps/ops/src/api/service-request.js
Normal 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)
|
||||
}
|
||||
}
|
||||
94
apps/ops/src/api/traccar.js
Normal 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 }
|
||||
6
apps/ops/src/boot/pinia.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { boot } from 'quasar/wrappers'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export default boot(({ app }) => {
|
||||
app.use(createPinia())
|
||||
})
|
||||
44
apps/ops/src/components/customer/BillingKPIs.vue
Normal 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 dû</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>
|
||||
49
apps/ops/src/components/customer/ContactCard.vue
Normal 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>
|
||||
46
apps/ops/src/components/customer/CustomerHeader.vue
Normal 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>· Legacy: {{ customer.legacy_customer_id }}</span></template>
|
||||
<span>·</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>·</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>·</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>
|
||||
64
apps/ops/src/components/customer/CustomerInfoCard.vue
Normal 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>
|
||||
418
apps/ops/src/components/shared/DetailModal.vue
Normal 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' }} · {{ 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 || '?' }} → {{ 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>
|
||||
408
apps/ops/src/components/shared/TagEditor.vue
Normal 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">📌</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">📌</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>
|
||||
137
apps/ops/src/components/shared/TagInput.vue
Normal 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>
|
||||
140
apps/ops/src/composables/useAutoDispatch.js
Normal 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 }
|
||||
}
|
||||
120
apps/ops/src/composables/useBottomPanel.js
Normal 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,
|
||||
}
|
||||
}
|
||||
122
apps/ops/src/composables/useDetailModal.js
Normal 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,
|
||||
}
|
||||
}
|
||||
242
apps/ops/src/composables/useDragDrop.js
Normal 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,
|
||||
}
|
||||
}
|
||||
60
apps/ops/src/composables/useFormatters.js
Normal 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
|
||||
}
|
||||
162
apps/ops/src/composables/useHelpers.js
Normal 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
|
||||
}
|
||||
413
apps/ops/src/composables/useMap.js
Normal 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,
|
||||
}
|
||||
}
|
||||
209
apps/ops/src/composables/useScheduler.js
Normal 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,
|
||||
}
|
||||
}
|
||||
172
apps/ops/src/composables/useSelection.js
Normal 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,
|
||||
}
|
||||
}
|
||||
37
apps/ops/src/composables/useStatusClasses.js
Normal 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'
|
||||
}
|
||||
159
apps/ops/src/composables/useSubscriptionGroups.js
Normal 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,
|
||||
}
|
||||
}
|
||||
78
apps/ops/src/composables/useUndo.js
Normal 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 }
|
||||
}
|
||||
16
apps/ops/src/config/erpnext.js
Normal 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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
589
apps/ops/src/layouts/MainLayout.vue
Normal 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>
|
||||
194
apps/ops/src/modules/dispatch/components/BottomPanel.vue
Normal 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>
|
||||
84
apps/ops/src/modules/dispatch/components/JobEditModal.vue
Normal 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>
|
||||
50
apps/ops/src/modules/dispatch/components/MapPanel.vue
Normal 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>
|
||||
72
apps/ops/src/modules/dispatch/components/MonthCalendar.vue
Normal 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>
|
||||
108
apps/ops/src/modules/dispatch/components/RightPanel.vue
Normal 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>
|
||||
129
apps/ops/src/modules/dispatch/components/TimelineRow.vue
Normal 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>
|
||||
112
apps/ops/src/modules/dispatch/components/WeekCalendar.vue
Normal 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>
|
||||
97
apps/ops/src/modules/dispatch/components/WoCreateModal.vue
Normal 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>
|
||||
1553
apps/ops/src/pages/ClientDetailPage.vue
Normal file
125
apps/ops/src/pages/ClientsPage.vue
Normal 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>
|
||||
204
apps/ops/src/pages/DashboardPage.vue
Normal 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>
|
||||
1668
apps/ops/src/pages/DispatchPage.vue
Normal file
69
apps/ops/src/pages/EquipePage.vue
Normal 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>
|
||||
64
apps/ops/src/pages/RapportsPage.vue
Normal 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>
|
||||
319
apps/ops/src/pages/TicketsPage.vue
Normal 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"> · <span class="text-indigo-6">#{{ modalTicket.legacy_ticket_id }}</span></template>
|
||||
<template v-if="modalTicket?.customer_name"> · {{ 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>
|
||||
26
apps/ops/src/router/index.js
Normal 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,
|
||||
})
|
||||
})
|
||||
21
apps/ops/src/stores/auth.js
Normal 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 }
|
||||
})
|
||||
419
apps/ops/src/stores/dispatch.js
Normal 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,
|
||||
}
|
||||
})
|
||||