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