Refactor: modular architecture — extract composables & components
- Extract useDragDrop.js (drag/drop, block move, resize) - Extract useSelection.js (lasso, multi-select, hover linking) - Extract WeekCalendar.vue, MonthCalendar.vue, RightPanel.vue - DispatchV2Page.vue: 3018 → 1438 lines (orchestration only) - Remove <style scoped> — styles cascade to child components - Add .dockerignore (build context 214MB → 112KB) - Add infra/ with docker-compose reference and .env.example Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b90db4673a
commit
632e4ae0d1
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
161
ARCHITECTURE.md
161
ARCHITECTURE.md
|
|
@ -1,161 +0,0 @@
|
||||||
# OSS/BSS Field Dispatch — Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Field service dispatch & scheduling PWA integrated with ERPNext (Frappe) on PostgreSQL.
|
|
||||||
Manages technicians, work orders, route optimization, and team scheduling.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Layer | Technology | Notes |
|
|
||||||
|-------|-----------|-------|
|
|
||||||
| **Frontend** | Vue 3 + Quasar (PWA) | `<script setup>`, Composition API, custom dark UI |
|
|
||||||
| **State** | Pinia | Single `dispatch` store |
|
|
||||||
| **Backend** | ERPNext / Frappe | REST API, Server Scripts, Doctypes |
|
|
||||||
| **Database** | PostgreSQL | Native (no MariaDB), `pg_trgm` + `unaccent` for address search |
|
|
||||||
| **Maps** | Mapbox GL JS | Route directions, optimization API, dark style |
|
|
||||||
| **Deploy** | Docker | Frappe containers, PWA served as static assets |
|
|
||||||
| **Git** | Gitea | `git.targo.ca/louis/OSS-BSS-Field-Dispatch` |
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── api/ # ERPNext REST calls
|
|
||||||
│ ├── dispatch.js # Jobs, techs, tags CRUD + createTag
|
|
||||||
│ ├── auth.js # Login, CSRF token
|
|
||||||
│ ├── service-request.js # Open service requests
|
|
||||||
│ ├── booking.js # Booking API
|
|
||||||
│ ├── contractor.js # Contractor API
|
|
||||||
│ └── settings.js # Dispatch Settings (single doctype)
|
|
||||||
│
|
|
||||||
├── components/ # Reusable UI widgets
|
|
||||||
│ └── TagInput.vue # Auto-suggest tag input with inline creation
|
|
||||||
│
|
|
||||||
├── composables/ # Shared logic (no UI)
|
|
||||||
│ ├── useHelpers.js # Pure utils: dates, formatting, icons, colors
|
|
||||||
│ ├── useScheduler.js # Timeline algorithm: pinned anchors + auto-flow
|
|
||||||
│ ├── useMap.js # Mapbox: init, markers, routes, geo-fix, drag
|
|
||||||
│ ├── useBottomPanel.js # Unassigned list: grouping, multi-select, criteria
|
|
||||||
│ └── useUndo.js # Undo stack (30 actions max)
|
|
||||||
│
|
|
||||||
├── modules/
|
|
||||||
│ └── dispatch/
|
|
||||||
│ └── components/ # Dispatch-specific components
|
|
||||||
│ ├── TimelineRow.vue # Single tech row with job blocks
|
|
||||||
│ ├── BottomPanel.vue # Unassigned jobs table (date-grouped)
|
|
||||||
│ ├── JobEditModal.vue # Edit job modal (double-click)
|
|
||||||
│ └── WoCreateModal.vue # Create work order modal
|
|
||||||
│
|
|
||||||
├── pages/ # Route-level pages
|
|
||||||
│ ├── DispatchV2Page.vue # Main dispatch board (active)
|
|
||||||
│ ├── DispatchPage.vue # Legacy v1 (deprecated)
|
|
||||||
│ ├── MobilePage.vue # Mobile tech view
|
|
||||||
│ ├── AdminPage.vue # Admin settings
|
|
||||||
│ ├── BookingPage.vue # Customer booking
|
|
||||||
│ ├── TechBidPage.vue # Tech bidding
|
|
||||||
│ └── ContractorPage.vue # Contractor view
|
|
||||||
│
|
|
||||||
├── stores/
|
|
||||||
│ ├── dispatch.js # Jobs, technicians, tags state
|
|
||||||
│ └── auth.js # Auth state
|
|
||||||
│
|
|
||||||
├── config/
|
|
||||||
│ └── erpnext.js # MAPBOX_TOKEN, TECH_COLORS, BASE_URL
|
|
||||||
│
|
|
||||||
└── router/index.js # Vue Router (hash mode)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Routes
|
|
||||||
|
|
||||||
| Path | Page | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| `/#/dispatch2` | DispatchV2Page | **Main app** — scheduling board |
|
|
||||||
| `/#/` | DispatchPage | Legacy v1 |
|
|
||||||
| `/#/mobile` | MobilePage | Mobile tech interface |
|
|
||||||
| `/#/admin` | AdminPage | Settings |
|
|
||||||
| `/#/booking` | BookingPage | Customer booking |
|
|
||||||
| `/#/bid` | TechBidPage | Tech bidding |
|
|
||||||
| `/#/contractor` | ContractorPage | Contractor portal |
|
|
||||||
|
|
||||||
## ERPNext Doctypes
|
|
||||||
|
|
||||||
| Doctype | Type | Fields |
|
|
||||||
|---------|------|--------|
|
|
||||||
| **Dispatch Job** | Document | subject, address, lat/lng, priority, duration_h, status, assigned_tech, scheduled_date, start_time, end_date, note |
|
|
||||||
| **Dispatch Technician** | Document | technician_id, full_name, status, user, lat/lng |
|
|
||||||
| **Dispatch Tag** | Document | label, color, category (Skill/Service/Region/Equipment/Custom) |
|
|
||||||
| **Dispatch Job Assistant** | Child Table (of Job) | tech_id, tech_name, duration_h, note, pinned |
|
|
||||||
| **Dispatch Tag Link** | Child Table | tag (Link → Dispatch Tag) |
|
|
||||||
| **Dispatch Settings** | Single | API keys, Mapbox token |
|
|
||||||
|
|
||||||
## PostgreSQL Extensions
|
|
||||||
|
|
||||||
| Extension | Usage |
|
|
||||||
|-----------|-------|
|
|
||||||
| `pg_trgm` | Trigram fuzzy search on `rqa_addresses.search_text` |
|
|
||||||
| `unaccent` | Accent-insensitive search (riviere = rivière) |
|
|
||||||
|
|
||||||
**Address table:** `rqa_addresses` — 5.2M Quebec civic addresses with pre-computed `search_text` column, GIN trigram index.
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### Scheduling
|
|
||||||
- **Day view**: Timeline with pinned time anchors + auto-flow for unpinned jobs
|
|
||||||
- **Week view**: Calendar grid with job chips, lasso multi-select
|
|
||||||
- **Month view**: Overview with tech avatars per day
|
|
||||||
- **Travel time**: Mapbox Directions API, displayed between job blocks
|
|
||||||
|
|
||||||
### Dispatch
|
|
||||||
- **Auto-distribute**: Ordered criteria (urgency → load balance → proximity → skills)
|
|
||||||
- **Multi-drag**: Select multiple jobs in bottom panel, drag to tech or batch-assign
|
|
||||||
- **Route optimization**: Nearest-neighbor + Mapbox Optimization API (TSP)
|
|
||||||
|
|
||||||
### Job Management
|
|
||||||
- **Assistants**: Multi-tech jobs with floating/pinned modes
|
|
||||||
- **Tags/Skills**: Auto-suggest input with inline tag creation
|
|
||||||
- **Address autocomplete**: Server Script → PostgreSQL with pg_trgm
|
|
||||||
- **Undo**: Ctrl+Z for assign/unassign/optimize/distribute (30-deep stack)
|
|
||||||
|
|
||||||
### UI
|
|
||||||
- **Sidebar**: Icon strip with flyout panels on hover
|
|
||||||
- **Single click**: Right panel with job details
|
|
||||||
- **Double click**: Edit modal (title, address, note, duration, priority, tags)
|
|
||||||
- **Context menu**: Right-click on jobs and techs
|
|
||||||
- **Lasso**: Rectangle selection in day and week views
|
|
||||||
- **Map**: Mapbox dark style, route visualization, geo-fix by click
|
|
||||||
|
|
||||||
## Component Communication
|
|
||||||
|
|
||||||
```
|
|
||||||
DispatchV2Page (orchestrator)
|
|
||||||
│
|
|
||||||
├── provide/inject ──→ shared state (store, colors, functions)
|
|
||||||
│
|
|
||||||
├── TimelineRow ←──── events (job-click, drag, resize, ctx)
|
|
||||||
├── BottomPanel ←──── events (select, batch-assign, drag)
|
|
||||||
├── JobEditModal ←─── v-model + confirm event
|
|
||||||
├── WoCreateModal ←── v-model + confirm event
|
|
||||||
└── TagInput ←──────── v-model + create event
|
|
||||||
```
|
|
||||||
|
|
||||||
## Planned Modules
|
|
||||||
|
|
||||||
| Module | Directory | Status |
|
|
||||||
|--------|-----------|--------|
|
|
||||||
| Dispatch | `modules/dispatch/` | Active |
|
|
||||||
| Timesheet | `modules/timesheet/` | Planned |
|
|
||||||
| HRMS | `modules/hrms/` | Planned |
|
|
||||||
| ERP | `modules/erp/` | Planned |
|
|
||||||
| OSS/BSS | `modules/oss-bss/` | Planned |
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build + deploy to ERPNext Docker
|
|
||||||
./deploy.sh
|
|
||||||
|
|
||||||
# Dev server (hot reload, proxies API to ERPNext)
|
|
||||||
npx @quasar/cli dev -m pwa
|
|
||||||
# → http://localhost:9000/#/dispatch2
|
|
||||||
```
|
|
||||||
8
infra/.env.example
Normal file
8
infra/.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# ERPNext
|
||||||
|
ERPNEXT_VERSION=v15.49.2
|
||||||
|
DB_ROOT_PASSWORD=admin
|
||||||
|
|
||||||
|
# PostgreSQL (address autocomplete)
|
||||||
|
PG_DB=dispatch
|
||||||
|
PG_USER=dispatch
|
||||||
|
PG_PASSWORD=dispatch
|
||||||
138
infra/docker-compose.erpnext.yaml
Normal file
138
infra/docker-compose.erpnext.yaml
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# ERPNext Docker Compose — reference config for rebuilding infrastructure
|
||||||
|
# Based on frappe_docker: https://github.com/frappe/frappe_docker
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cp .env.example .env (edit vars)
|
||||||
|
# docker compose -f docker-compose.erpnext.yaml up -d
|
||||||
|
#
|
||||||
|
# After ERPNext is running, deploy the dispatch PWA:
|
||||||
|
# cd ../dispatch-app && bash deploy.sh
|
||||||
|
|
||||||
|
x-customizable-image: &customizable_image
|
||||||
|
image: ${CUSTOM_IMAGE:-frappe/erpnext}:${CUSTOM_TAG:-v15.49.2}
|
||||||
|
pull_policy: ${PULL_POLICY:-always}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
x-depends-on-configurator: &depends_on_configurator
|
||||||
|
depends_on:
|
||||||
|
configurator:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
x-backend-defaults: &backend_defaults
|
||||||
|
<<: [*depends_on_configurator, *customizable_image]
|
||||||
|
volumes:
|
||||||
|
- sites:/home/frappe/frappe-bench/sites
|
||||||
|
|
||||||
|
services:
|
||||||
|
configurator:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
entrypoint: ["bash", "-c"]
|
||||||
|
command:
|
||||||
|
- >
|
||||||
|
ls -1 apps > sites/apps.txt;
|
||||||
|
bench set-config -g db_host $$DB_HOST;
|
||||||
|
bench set-config -gp db_port $$DB_PORT;
|
||||||
|
bench set-config -g redis_cache "redis://$$REDIS_CACHE";
|
||||||
|
bench set-config -g redis_queue "redis://$$REDIS_QUEUE";
|
||||||
|
bench set-config -g redis_socketio "redis://$$REDIS_QUEUE";
|
||||||
|
bench set-config -gp socketio_port $$SOCKETIO_PORT;
|
||||||
|
environment:
|
||||||
|
DB_HOST: ${DB_HOST:-db}
|
||||||
|
DB_PORT: ${DB_PORT:-3306}
|
||||||
|
REDIS_CACHE: ${REDIS_CACHE:-redis-cache:6379}
|
||||||
|
REDIS_QUEUE: ${REDIS_QUEUE:-redis-queue:6379}
|
||||||
|
SOCKETIO_PORT: 9000
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis-cache:
|
||||||
|
condition: service_started
|
||||||
|
redis-queue:
|
||||||
|
condition: service_started
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
backend:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
<<: *customizable_image
|
||||||
|
platform: linux/amd64
|
||||||
|
command: ["nginx-entrypoint.sh"]
|
||||||
|
environment:
|
||||||
|
BACKEND: backend:8000
|
||||||
|
SOCKETIO: websocket:9000
|
||||||
|
FRAPPE_SITE_NAME_HEADER: $$host
|
||||||
|
PROXY_READ_TIMEOUT: 120
|
||||||
|
CLIENT_MAX_BODY_SIZE: 50m
|
||||||
|
volumes:
|
||||||
|
- sites:/home/frappe/frappe-bench/sites
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- websocket
|
||||||
|
|
||||||
|
websocket:
|
||||||
|
<<: [*depends_on_configurator, *customizable_image]
|
||||||
|
platform: linux/amd64
|
||||||
|
command: ["node", "/home/frappe/frappe-bench/apps/frappe/socketio.js"]
|
||||||
|
volumes:
|
||||||
|
- sites:/home/frappe/frappe-bench/sites
|
||||||
|
|
||||||
|
queue-short:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
command: bench worker --queue short,default
|
||||||
|
|
||||||
|
queue-long:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
command: bench worker --queue long,default,short
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
<<: *backend_defaults
|
||||||
|
platform: linux/amd64
|
||||||
|
command: bench schedule
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci', '--skip-character-set-client-handshake']
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-admin}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: mysqladmin ping -h localhost --password=$$MYSQL_ROOT_PASSWORD
|
||||||
|
interval: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis-cache:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis-queue:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# PostgreSQL for address autocomplete (rqa_addresses table)
|
||||||
|
postgres:
|
||||||
|
image: postgres:14-alpine
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PG_DB:-dispatch}
|
||||||
|
POSTGRES_USER: ${PG_USER:-dispatch}
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-dispatch}
|
||||||
|
volumes:
|
||||||
|
- pg-data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sites:
|
||||||
|
db-data:
|
||||||
|
pg-data:
|
||||||
229
src/composables/useDragDrop.js
Normal file
229
src/composables/useDragDrop.js
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
// ── Drag & Drop composable: job drag, tech drag, block move, block resize, batch drag ──
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { snapH, hToTime, fmtDur, localDateStr, SNAP } from './useHelpers'
|
||||||
|
import { updateJob } from 'src/api/dispatch'
|
||||||
|
|
||||||
|
export function useDragDrop (deps) {
|
||||||
|
const {
|
||||||
|
store, pxPerHr, dayW, periodStart, periodDays, H_START,
|
||||||
|
getJobDate, bottomSelected,
|
||||||
|
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) {
|
||||||
|
dragBatchIds.value.forEach(jobId => {
|
||||||
|
const j = store.jobs.find(x => x.id === jobId)
|
||||||
|
if (j && !j.assignedTech) {
|
||||||
|
pushUndo({ type: 'unassignJob', jobId: j.id, techId: j.assignedTech, routeOrder: j.routeOrder, scheduledDate: j.scheduledDate, assistants: [...(j.assistants || [])] })
|
||||||
|
smartAssign(j, tech.id, dateStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bottomSelected.value = new Set()
|
||||||
|
dragBatchIds.value = null
|
||||||
|
} 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: job.assistants.map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 })),
|
||||||
|
}).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,
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/composables/useSelection.js
Normal file
147
src/composables/useSelection.js
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// ── 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 ─────────────────────────────────────────────────────────────────
|
||||||
|
function batchUnassign () {
|
||||||
|
if (!multiSelect.value.length) return
|
||||||
|
multiSelect.value.forEach(s => {
|
||||||
|
if (s.isAssist && s.assistTechId) store.removeAssistant(s.job.id, s.assistTechId)
|
||||||
|
else fullUnassign(s.job)
|
||||||
|
})
|
||||||
|
multiSelect.value = []; selectedJob.value = null
|
||||||
|
invalidateRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchMoveTo (techId) {
|
||||||
|
if (!multiSelect.value.length) return
|
||||||
|
const dayStr = localDateStr(periodStart.value)
|
||||||
|
multiSelect.value.filter(s => !s.isAssist).forEach(s => smartAssign(s.job, techId, dayStr))
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/modules/dispatch/components/MonthCalendar.vue
Normal file
72
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>
|
||||||
104
src/modules/dispatch/components/RightPanel.vue
Normal file
104
src/modules/dispatch/components/RightPanel.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { fmtDur, prioLabel, prioClass, ICON } from 'src/composables/useHelpers'
|
||||||
|
import TagInput from 'src/components/TagInput.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')
|
||||||
|
</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>
|
||||||
|
<TagInput 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" />
|
||||||
|
</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>
|
||||||
112
src/modules/dispatch/components/WeekCalendar.vue
Normal file
112
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>
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user