gigafibre-fsm/apps/ops/src/composables/useUnifiedCreate.js
louispaulb df23f232b3 feat(planif): native OPS job creation (#17) — Dispatch deprecated, create from Planification
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>
2026-07-03 09:05:32 -04:00

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()
}