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:
louispaulb 2026-03-24 16:08:56 -04:00
parent b90db4673a
commit 632e4ae0d1
10 changed files with 1304 additions and 1972 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.git
*.md

View File

@ -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
View 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

View 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:

View 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,
}
}

View 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,
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff