Job/installation creation now lives in Planification (Dispatch board is
being retired). Reuses the shared UnifiedCreateModal (work-order mode:
subject, job_type, address+RQA geocode, duration, skills/tags, tech,
date) so it's the same proven form.
- "Créer un job" button (desktop toolbar + mobile ⋮ menu) → modal →
createJob → @created refreshes the pool ("À assigner") + occupancy.
- "Créneau" button → SuggestSlotsDialog (P1-C, re-homed from Dispatch) →
pick tech+date+time → prefills the create modal.
- useUnifiedCreate: work-order flow now carries start_time + prefilled
coords (from a booked slot) through resetForm → payload.
Sync F transitoire: OPS-native jobs have NO legacy_ticket_id, so the F→
OPS sync and purgeStaleOrphans (which only touch legacy_ticket_id jobs)
never clobber them — they coexist. No F push for now (billing stays via
the sales path).
Verified: modal opens in Planification with all 9 work-order fields;
button in toolbar (gt-sm) + mobile menu.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
import { reactive, ref, computed } from 'vue'
|
|
import { Notify } from 'quasar'
|
|
import { createDoc, listDocs } from 'src/api/erp'
|
|
import { createJob, fetchTags, createTag, updateTag, renameTag, deleteTag } from 'src/api/dispatch'
|
|
|
|
import { HUB_URL } from 'src/config/hub'
|
|
|
|
/**
|
|
* Composable for unified ticket/task/work-order creation.
|
|
*
|
|
* @param {import('vue').Ref<string>} mode - 'ticket' | 'task' | 'work-order'
|
|
* @param {Object} opts - Optional: { technicians, allTags, getTagColor }
|
|
*/
|
|
function nextWeekday () {
|
|
const d = new Date()
|
|
const day = d.getDay()
|
|
if (day === 6) d.setDate(d.getDate() + 2) // Sat → Mon
|
|
else if (day === 0) d.setDate(d.getDate() + 1) // Sun → Mon
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
// Numéro de job standardisé YYYYMMDD-XXX, frappé côté hub (compteur par jour). Repli local si le hub ne répond pas.
|
|
async function fetchNextRef () {
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/dispatch/next-ref`)
|
|
if (r.ok) { const d = await r.json(); if (d && d.ref) return d.ref }
|
|
} catch (e) { /* repli ci-dessous */ }
|
|
return 'DJ-' + Date.now().toString(36).toUpperCase()
|
|
}
|
|
|
|
export function useUnifiedCreate (mode, opts = {}) {
|
|
// ── Form state ────────────────────────────────────────────────────────────
|
|
const form = reactive({
|
|
subject: '',
|
|
priority: 'medium',
|
|
description: '',
|
|
issue_type: null,
|
|
service_location: null,
|
|
job_type: 'Autre',
|
|
duration_h: 1,
|
|
address: '',
|
|
assigned_tech: null,
|
|
scheduled_date: '',
|
|
start_time: '', // heure du créneau (réservation « Trouver un créneau ») — optionnel
|
|
depends_on: null, // parent dependency (ticket/task/job name)
|
|
tags: [], // [{ tag, level?, required? }] or ['label', ...]
|
|
_latitude: null,
|
|
_longitude: null,
|
|
_customer: '',
|
|
_source_issue: '',
|
|
_subscription: '',
|
|
})
|
|
|
|
const submitting = ref(false)
|
|
|
|
// ── Field visibility by mode ──────────────────────────────────────────────
|
|
const fields = computed(() => {
|
|
const m = typeof mode === 'string' ? mode : mode.value
|
|
return {
|
|
showIssueType: m === 'ticket',
|
|
showServiceLocation: m === 'ticket',
|
|
showJobType: m === 'work-order',
|
|
showAddress: m === 'work-order',
|
|
showDuration: m === 'work-order' || m === 'task',
|
|
showTags: m === 'work-order' || m === 'task',
|
|
showTech: m === 'work-order',
|
|
showScheduledDate: m === 'work-order' || m === 'task',
|
|
showParent: true,
|
|
}
|
|
})
|
|
|
|
// ── Tags (standalone mode — used when not injected from dispatch) ─────────
|
|
const internalTags = ref([])
|
|
const tagsLoaded = ref(false)
|
|
|
|
async function loadTags () {
|
|
if (tagsLoaded.value) return
|
|
try {
|
|
internalTags.value = await fetchTags()
|
|
tagsLoaded.value = true
|
|
} catch { /* tags optional */ }
|
|
}
|
|
|
|
function allTags () {
|
|
return opts.allTags?.value || opts.allTags || internalTags.value
|
|
}
|
|
|
|
function getTagColor (label) {
|
|
if (opts.getTagColor) return opts.getTagColor(label)
|
|
const t = allTags().find(t => t.label === label || t.name === label)
|
|
return t?.color || '#6b7280'
|
|
}
|
|
|
|
async function onCreateTag ({ label, color }) {
|
|
try {
|
|
const doc = await createTag(label, 'Custom', color)
|
|
internalTags.value.push({ name: doc.name || label, label, color, category: 'Custom' })
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur création tag: ' + e.message })
|
|
}
|
|
}
|
|
|
|
async function onUpdateTag ({ name, color }) {
|
|
try {
|
|
await updateTag(name, { color })
|
|
const t = internalTags.value.find(t => t.name === name || t.label === name)
|
|
if (t) t.color = color
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
async function onRenameTag ({ oldName, newName }) {
|
|
try {
|
|
await renameTag(oldName, newName)
|
|
const t = internalTags.value.find(t => t.name === oldName || t.label === oldName)
|
|
if (t) { t.name = newName; t.label = newName }
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
async function onDeleteTag (label) {
|
|
try {
|
|
await deleteTag(label)
|
|
internalTags.value = internalTags.value.filter(t => t.label !== label && t.name !== label)
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
// ── Parent dependency search ──────────────────────────────────────────────
|
|
const parentOptions = ref([])
|
|
let parentDebounce = null
|
|
let recentLoaded = false
|
|
|
|
// Load recent tickets on first open (no query needed)
|
|
async function loadRecentParents () {
|
|
if (recentLoaded && parentOptions.value.length) return
|
|
try {
|
|
const issues = await listDocs('Issue', {
|
|
fields: ['name', 'subject', 'status'],
|
|
limit: 15,
|
|
orderBy: 'creation desc',
|
|
})
|
|
parentOptions.value = issues.map(i => ({
|
|
label: `${i.name}: ${i.subject || ''}`,
|
|
value: i.name,
|
|
type: 'Issue',
|
|
status: i.status,
|
|
}))
|
|
recentLoaded = true
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
function searchParent (query, done) {
|
|
clearTimeout(parentDebounce)
|
|
// No query → show recent tickets
|
|
if (!query || query.length < 2) {
|
|
loadRecentParents().then(() => done?.(parentOptions.value))
|
|
return
|
|
}
|
|
parentDebounce = setTimeout(async () => {
|
|
try {
|
|
const [issues, tasks] = await Promise.all([
|
|
listDocs('Issue', {
|
|
or_filters: { subject: ['like', `%${query}%`], name: ['like', `%${query}%`] },
|
|
fields: ['name', 'subject', 'status'],
|
|
limit: 10,
|
|
orderBy: 'creation desc',
|
|
}),
|
|
listDocs('Task', {
|
|
or_filters: { subject: ['like', `%${query}%`], name: ['like', `%${query}%`] },
|
|
fields: ['name', 'subject', 'status'],
|
|
limit: 10,
|
|
orderBy: 'creation desc',
|
|
}),
|
|
])
|
|
const results = [
|
|
...issues.map(i => ({ label: `${i.name}: ${i.subject || ''}`, value: i.name, type: 'Issue', status: i.status })),
|
|
...tasks.map(t => ({ label: `${t.name}: ${t.subject || ''}`, value: t.name, type: 'Task', status: t.status })),
|
|
]
|
|
parentOptions.value = results
|
|
done?.(results)
|
|
} catch { done?.([]) }
|
|
}, 300)
|
|
}
|
|
|
|
// ── Best tech ranking ─────────────────────────────────────────────────────
|
|
const findingBest = ref(false)
|
|
const ranking = ref([])
|
|
|
|
async function findBestTech () {
|
|
findingBest.value = true
|
|
try {
|
|
const res = await fetch(HUB_URL + '/dispatch/best-tech', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
latitude: form._latitude,
|
|
longitude: form._longitude,
|
|
duration_h: form.duration_h,
|
|
date: form.scheduled_date || new Date().toISOString().slice(0, 10),
|
|
}),
|
|
})
|
|
const data = await res.json()
|
|
ranking.value = data.ranking || []
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur calcul optimal' })
|
|
} finally {
|
|
findingBest.value = false
|
|
}
|
|
}
|
|
|
|
// ── Reset form from context ───────────────────────────────────────────────
|
|
function resetForm (ctx = {}) {
|
|
form.subject = ctx.subject || ''
|
|
form.priority = ctx.priority || 'medium'
|
|
form.description = ctx.description || ''
|
|
form.issue_type = ctx.issue_type || null
|
|
form.service_location = ctx.service_location || null
|
|
form.job_type = ctx.job_type || 'Autre'
|
|
form.duration_h = ctx.duration_h || 1
|
|
form.address = ctx.address || ''
|
|
form.assigned_tech = ctx.assigned_tech || null
|
|
form.scheduled_date = ctx.scheduled_date || ''
|
|
form.start_time = ctx.start_time || ''
|
|
form._latitude = ctx._latitude != null ? ctx._latitude : (ctx.latitude != null ? ctx.latitude : null) // coords pré-remplies (réservation par créneau)
|
|
form._longitude = ctx._longitude != null ? ctx._longitude : (ctx.longitude != null ? ctx.longitude : null)
|
|
form.depends_on = ctx.depends_on || null
|
|
form.tags = ctx.tags || []
|
|
form._latitude = ctx.latitude || null
|
|
form._longitude = ctx.longitude || null
|
|
form._customer = ctx.customer || ''
|
|
form._source_issue = ctx.source_issue || ''
|
|
form._subscription = ctx.subscription || ''
|
|
ranking.value = []
|
|
}
|
|
|
|
// ── Submit — routes to correct backend ────────────────────────────────────
|
|
async function submit () {
|
|
if (!form.subject?.trim()) {
|
|
Notify.create({ type: 'warning', message: 'Le sujet est requis' })
|
|
return null
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const m = typeof mode === 'string' ? mode : mode.value
|
|
let result
|
|
|
|
if (m === 'ticket') {
|
|
const data = {
|
|
customer: form._customer,
|
|
subject: form.subject.trim(),
|
|
priority: capitalize(form.priority),
|
|
status: 'Open',
|
|
description: form.description || '',
|
|
}
|
|
if (form.issue_type) data.issue_type = form.issue_type
|
|
if (form.service_location) data.service_location = form.service_location
|
|
if (form.depends_on) data.depends_on = form.depends_on
|
|
result = await createDoc('Issue', data)
|
|
Notify.create({ type: 'positive', message: 'Ticket créé' })
|
|
|
|
} else if (m === 'task') {
|
|
const data = {
|
|
subject: form.subject.trim(),
|
|
priority: capitalize(form.priority),
|
|
status: 'Open',
|
|
description: form.description || '',
|
|
}
|
|
if (form.duration_h) data.expected_time = form.duration_h
|
|
if (form.scheduled_date) data.exp_start_date = form.scheduled_date
|
|
if (form.depends_on) data.parent_task = form.depends_on
|
|
result = await createDoc('Task', data)
|
|
Notify.create({ type: 'positive', message: 'Tâche créée' })
|
|
|
|
} else if (m === 'work-order') {
|
|
const ticketId = await fetchNextRef() // numéro standardisé YYYYMMDD-XXX (hub)
|
|
const payload = {
|
|
ticket_id: ticketId,
|
|
subject: form.subject.trim(),
|
|
address: form.address,
|
|
duration_h: form.duration_h,
|
|
priority: form.priority,
|
|
status: form.assigned_tech ? 'assigned' : 'open',
|
|
job_type: form.job_type,
|
|
source_issue: form._source_issue,
|
|
customer: form._customer,
|
|
service_location: form.service_location || '',
|
|
depends_on: form.depends_on || '',
|
|
scheduled_date: form.scheduled_date || (form.assigned_tech ? nextWeekday() : ''),
|
|
start_time: form.start_time || '', // heure du créneau réservé (sinon vide = non planifié à l'heure)
|
|
notes: form.description || '',
|
|
assigned_tech: form.assigned_tech || '',
|
|
latitude: form._latitude || '',
|
|
longitude: form._longitude || '',
|
|
tags: form.tags.map(t => typeof t === 'string' ? t : t.tag).join(','),
|
|
}
|
|
result = await createJob(payload)
|
|
Notify.create({ type: 'positive', message: form.assigned_tech ? 'Travail créé et assigné' : 'Travail créé' })
|
|
}
|
|
|
|
return result
|
|
} catch (err) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
|
|
return null
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
form,
|
|
fields,
|
|
submitting,
|
|
resetForm,
|
|
submit,
|
|
// Tags
|
|
loadTags,
|
|
allTagsList: computed(() => allTags()),
|
|
getTagColor,
|
|
onCreateTag,
|
|
onUpdateTag,
|
|
onRenameTag,
|
|
onDeleteTag,
|
|
// Parent
|
|
parentOptions,
|
|
searchParent,
|
|
// Best tech
|
|
findingBest,
|
|
ranking,
|
|
findBestTech,
|
|
}
|
|
}
|
|
|
|
function capitalize (s) {
|
|
if (!s) return s
|
|
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
|
|
}
|