The hub responds with `Access-Control-Allow-Origin: *`, and the CORS spec forbids the wildcard + credentials combination. Firefox rejects the preflight before any response reaches JS, surfacing as "NetworkError when attempting to fetch resource" when a user clicks "Supprimer cette tâche" on an already-completed step (or any step). Every other HUB_URL call in the ops SPA already omits credentials — aligning TaskNode with the rest of the codebase is the simplest fix. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
469 lines
17 KiB
Vue
469 lines
17 KiB
Vue
<template>
|
|
<div class="task-node" :style="{ marginLeft: depth * 20 + 'px' }">
|
|
<div class="task-row" :class="['task-' + (job.status || 'open'), { 'task-expanded': isExpanded }]"
|
|
@click="isExpanded = !isExpanded">
|
|
<div class="row items-center no-wrap q-gutter-x-sm">
|
|
<!-- Expand/collapse for nodes with children -->
|
|
<q-btn v-if="children.length" flat round dense size="xs"
|
|
:icon="treeExpanded ? 'expand_more' : 'chevron_right'" color="grey-6"
|
|
@click.stop="treeExpanded = !treeExpanded" style="margin:-4px" />
|
|
<div v-else style="width:24px" />
|
|
|
|
<q-icon :name="statusIcon" :color="statusColor" size="18px" />
|
|
<div class="col" style="min-width:0">
|
|
<div class="row items-center no-wrap q-gutter-x-xs">
|
|
<code class="task-id">{{ job.name }}</code>
|
|
<span v-if="job.step_order" class="task-step-badge">{{ job.step_order }}</span>
|
|
<span class="text-weight-medium ellipsis-text" style="font-size:0.85rem">{{ job.subject }}</span>
|
|
</div>
|
|
<div class="text-caption text-grey-6 row items-center q-gutter-x-sm">
|
|
<span v-if="job.job_type">{{ job.job_type }}</span>
|
|
<span v-if="job.assigned_tech" class="text-indigo-6"><q-icon name="person" size="12px" /> {{ job.assigned_tech }}</span>
|
|
<span v-if="job.scheduled_date"><q-icon name="event" size="12px" /> {{ job.scheduled_date }}</span>
|
|
<span v-if="job.depends_on" class="text-orange-8">
|
|
<q-icon name="link" size="12px" /> après {{ job.depends_on }}
|
|
</span>
|
|
<span v-if="job.on_open_webhook || job.on_close_webhook" class="text-blue-6">
|
|
<q-icon name="webhook" size="12px" /> n8n
|
|
</span>
|
|
<span v-if="children.length" class="text-grey-5">
|
|
<q-icon name="account_tree" size="12px" /> {{ children.length }}
|
|
</span>
|
|
<!-- Tag chips inline -->
|
|
<span v-for="tag in jobTags" :key="tag" class="task-tag-chip">{{ tag }}</span>
|
|
</div>
|
|
</div>
|
|
<span class="ops-badge" :class="badgeClass" style="font-size:10px;padding:1px 6px;flex-shrink:0">
|
|
{{ job.status || 'open' }}
|
|
</span>
|
|
<q-icon name="expand_more" size="16px" color="grey-4"
|
|
:style="{ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)', transition: 'transform 0.2s' }" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══ Expanded edit panel ═══ -->
|
|
<div v-if="isExpanded" class="task-edit-panel" @click.stop>
|
|
<div class="row q-col-gutter-sm q-mb-sm">
|
|
<div class="col-12">
|
|
<q-input v-model="editSubject" dense outlined label="Sujet"
|
|
:input-style="{ fontSize: '0.8rem' }" @blur="saveField('subject', editSubject)"
|
|
@keydown.enter="saveField('subject', editSubject)" />
|
|
</div>
|
|
</div>
|
|
<div class="row q-col-gutter-sm q-mb-sm">
|
|
<div class="col-4">
|
|
<q-select v-model="editStatus" dense outlined emit-value map-options label="Statut"
|
|
:options="statusOptions" @update:model-value="saveField('status', $event)" />
|
|
</div>
|
|
<div class="col-4">
|
|
<q-select v-model="editPriority" dense outlined emit-value map-options label="Priorité"
|
|
:options="priorityOptions" @update:model-value="saveField('priority', $event)" />
|
|
</div>
|
|
<div class="col-4">
|
|
<q-select v-model="editJobType" dense outlined emit-value map-options label="Type"
|
|
:options="jobTypeOptions" @update:model-value="saveField('job_type', $event)" />
|
|
</div>
|
|
</div>
|
|
<div class="row q-col-gutter-sm q-mb-sm">
|
|
<div class="col-6">
|
|
<q-select v-model="editTech" dense outlined emit-value map-options label="Technicien"
|
|
:options="techOptions" clearable @update:model-value="assignTech"
|
|
:loading="loadingTechs" @focus="loadTechsOnce">
|
|
<template #prepend><q-icon name="person" size="16px" /></template>
|
|
</q-select>
|
|
</div>
|
|
<div class="col-3">
|
|
<q-input v-model="editDate" dense outlined type="date" label="Date"
|
|
@update:model-value="saveField('scheduled_date', $event || '')" />
|
|
</div>
|
|
<div class="col-3">
|
|
<q-input v-model.number="editDuration" dense outlined type="number" label="Durée (h)"
|
|
@blur="saveField('duration_h', editDuration)" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags row -->
|
|
<div class="q-mb-sm">
|
|
<div class="text-caption text-grey-6 q-mb-xs" style="font-weight:600">
|
|
<q-icon name="sell" size="14px" /> Tags (pour dispatch timeline)
|
|
</div>
|
|
<div class="task-tags-row">
|
|
<q-chip v-for="tag in jobTags" :key="tag" removable dense size="sm"
|
|
color="indigo-1" text-color="indigo-8" icon="sell"
|
|
@remove="removeTag(tag)">
|
|
{{ tag }}
|
|
</q-chip>
|
|
<q-select v-model="newTag" dense outlined emit-value map-options
|
|
:options="availableTagOptions" label="+ Tag" style="min-width:140px;max-width:200px"
|
|
clearable @update:model-value="addTag" @focus="loadTagsOnce"
|
|
:loading="loadingTags" :input-style="{ fontSize: '0.75rem' }" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action buttons -->
|
|
<div class="row items-center q-gutter-x-sm">
|
|
<q-btn flat dense size="sm" icon="open_in_new" label="Dispatch" color="indigo-6" no-caps
|
|
@click="goToDispatch">
|
|
<q-tooltip>Ouvrir dans le tableau dispatch</q-tooltip>
|
|
</q-btn>
|
|
<q-space />
|
|
<q-btn flat dense size="sm" icon="delete" label="Supprimer" color="red-5" no-caps
|
|
@click="confirmDelete">
|
|
<q-tooltip>Supprimer cette tâche</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Children (recursive) -->
|
|
<template v-if="treeExpanded && children.length">
|
|
<TaskNode
|
|
v-for="child in children" :key="child.name"
|
|
:job="child" :all-jobs="allJobs" :depth="depth + 1"
|
|
@fire-webhook="(url, j) => $emit('fire-webhook', url, j)"
|
|
@deleted="(name) => $emit('deleted', name)"
|
|
@updated="(name, data) => $emit('updated', name, data)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
import { Notify, Dialog } from 'quasar'
|
|
import { updateJob } from 'src/api/dispatch'
|
|
import { deleteDoc, listDocs } from 'src/api/erp'
|
|
import { fetchTags, fetchTechnicians } from 'src/api/dispatch'
|
|
import { HUB_URL } from 'src/config/hub'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
const props = defineProps({
|
|
job: { type: Object, required: true },
|
|
allJobs: { type: Array, default: () => [] },
|
|
depth: { type: Number, default: 0 },
|
|
})
|
|
|
|
const emit = defineEmits(['fire-webhook', 'deleted', 'updated'])
|
|
const router = useRouter()
|
|
|
|
const isExpanded = ref(false)
|
|
const treeExpanded = ref(true)
|
|
|
|
// ── Edit state (synced from job prop) ──
|
|
const editSubject = ref(props.job.subject || '')
|
|
const editStatus = ref(props.job.status || 'open')
|
|
const editPriority = ref(props.job.priority || 'medium')
|
|
const editJobType = ref(props.job.job_type || 'Autre')
|
|
const editTech = ref(props.job.assigned_tech || null)
|
|
const editDate = ref(props.job.scheduled_date || '')
|
|
const editDuration = ref(props.job.duration_h || 1)
|
|
|
|
// Sync when job prop changes
|
|
watch(() => props.job, (j) => {
|
|
editSubject.value = j.subject || ''
|
|
editStatus.value = j.status || 'open'
|
|
editPriority.value = j.priority || 'medium'
|
|
editJobType.value = j.job_type || 'Autre'
|
|
editTech.value = j.assigned_tech || null
|
|
editDate.value = j.scheduled_date || ''
|
|
editDuration.value = j.duration_h || 1
|
|
}, { deep: true })
|
|
|
|
// ── Options ──
|
|
const statusOptions = [
|
|
{ label: 'Ouvert', value: 'open' },
|
|
{ label: 'Assigné', value: 'assigned' },
|
|
{ label: 'En route', value: 'in-route' },
|
|
{ label: 'En cours', value: 'in-progress' },
|
|
{ label: 'Complété', value: 'completed' },
|
|
{ label: 'Annulé', value: 'cancelled' },
|
|
]
|
|
const priorityOptions = [
|
|
{ label: 'Basse', value: 'low' },
|
|
{ label: 'Moyenne', value: 'medium' },
|
|
{ label: 'Haute', value: 'high' },
|
|
]
|
|
const jobTypeOptions = [
|
|
{ label: 'Installation', value: 'Installation' },
|
|
{ label: 'Réparation', value: 'Réparation' },
|
|
{ label: 'Maintenance', value: 'Maintenance' },
|
|
{ label: 'Retrait', value: 'Retrait' },
|
|
{ label: 'Dépannage', value: 'Dépannage' },
|
|
{ label: 'Autre', value: 'Autre' },
|
|
]
|
|
|
|
// ── Tech loading (lazy) ──
|
|
const techOptions = ref([])
|
|
const loadingTechs = ref(false)
|
|
let techsLoaded = false
|
|
|
|
async function loadTechsOnce () {
|
|
if (techsLoaded) return
|
|
loadingTechs.value = true
|
|
try {
|
|
const techs = await fetchTechnicians()
|
|
techOptions.value = techs.map(t => ({
|
|
label: t.full_name || t.name,
|
|
value: t.technician_id || t.name,
|
|
}))
|
|
techsLoaded = true
|
|
} catch { /* best effort */ }
|
|
loadingTechs.value = false
|
|
}
|
|
|
|
// ── Tags (lazy load, light-themed) ──
|
|
const allTagsList = ref([])
|
|
const loadingTags = ref(false)
|
|
let tagsLoaded = false
|
|
const newTag = ref(null)
|
|
|
|
const jobTags = computed(() => {
|
|
// Parse tags from the job — they might be in job.tags child table
|
|
if (Array.isArray(props.job.tags)) {
|
|
return props.job.tags.map(t => typeof t === 'string' ? t : t.tag).filter(Boolean)
|
|
}
|
|
return []
|
|
})
|
|
|
|
const availableTagOptions = computed(() => {
|
|
const current = new Set(jobTags.value)
|
|
return allTagsList.value
|
|
.filter(t => !current.has(t.label) && !current.has(t.name))
|
|
.map(t => ({ label: t.label || t.name, value: t.label || t.name }))
|
|
})
|
|
|
|
async function loadTagsOnce () {
|
|
if (tagsLoaded) return
|
|
loadingTags.value = true
|
|
try {
|
|
allTagsList.value = await fetchTags()
|
|
tagsLoaded = true
|
|
} catch { /* best effort */ }
|
|
loadingTags.value = false
|
|
}
|
|
|
|
function addTag (tagLabel) {
|
|
if (!tagLabel) return
|
|
const currentTags = jobTags.value.map(t => ({ tag: t }))
|
|
currentTags.push({ tag: tagLabel })
|
|
newTag.value = null
|
|
updateJob(props.job.name, { tags: currentTags }).then(() => {
|
|
// Update local state
|
|
if (!props.job.tags) props.job.tags = []
|
|
props.job.tags.push({ tag: tagLabel })
|
|
emit('updated', props.job.name, { tags: props.job.tags })
|
|
}).catch(() => Notify.create({ type: 'negative', message: 'Erreur ajout tag' }))
|
|
}
|
|
|
|
function removeTag (tagLabel) {
|
|
const newTags = jobTags.value.filter(t => t !== tagLabel).map(t => ({ tag: t }))
|
|
updateJob(props.job.name, { tags: newTags }).then(() => {
|
|
props.job.tags = newTags
|
|
emit('updated', props.job.name, { tags: newTags })
|
|
}).catch(() => Notify.create({ type: 'negative', message: 'Erreur suppression tag' }))
|
|
}
|
|
|
|
// ── Save field ──
|
|
async function saveField (field, value) {
|
|
try {
|
|
const payload = { [field]: value }
|
|
// If status changes, also fire webhook
|
|
if (field === 'status') {
|
|
const prev = props.job.status
|
|
props.job.status = value
|
|
if (value === 'completed' && props.job.on_close_webhook) {
|
|
emit('fire-webhook', props.job.on_close_webhook, props.job)
|
|
} else if (value === 'assigned' && prev === 'open' && props.job.on_open_webhook) {
|
|
emit('fire-webhook', props.job.on_open_webhook, props.job)
|
|
}
|
|
}
|
|
await updateJob(props.job.name, payload)
|
|
// Update local
|
|
props.job[field] = value
|
|
emit('updated', props.job.name, payload)
|
|
} catch (err) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
|
|
}
|
|
}
|
|
|
|
// ── Assign tech ──
|
|
async function assignTech (techId) {
|
|
try {
|
|
const payload = { assigned_tech: techId || '' }
|
|
if (techId && editStatus.value === 'open') {
|
|
payload.status = 'assigned'
|
|
editStatus.value = 'assigned'
|
|
} else if (!techId && editStatus.value === 'assigned') {
|
|
payload.status = 'open'
|
|
editStatus.value = 'open'
|
|
}
|
|
await updateJob(props.job.name, payload)
|
|
props.job.assigned_tech = techId || null
|
|
if (payload.status) props.job.status = payload.status
|
|
emit('updated', props.job.name, payload)
|
|
if (techId) {
|
|
Notify.create({ type: 'info', message: `Assigné à ${techId}`, timeout: 2000 })
|
|
}
|
|
} catch (err) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
|
|
}
|
|
}
|
|
|
|
// ── Delete ──
|
|
// Use the targo-hub /dispatch/job-delete endpoint instead of the raw ERPNext
|
|
// DELETE. The endpoint rewires any child job's `depends_on` (and re-parents
|
|
// descendants if the victim was a chain root) before deleting, so the chain
|
|
// stays intact and ERPNext's LinkExistsError doesn't fire.
|
|
function confirmDelete () {
|
|
// Warn the user when the task has dependents — the chain will be rewired
|
|
// (successors move up to the victim's parent) rather than broken.
|
|
const children = props.allJobs.filter(j => j.depends_on === props.job.name)
|
|
const warning = children.length
|
|
? ` <br><span style="color:#f59e0b;font-size:0.85em;">⚠ ${children.length} étape(s) dépendent de celle-ci — elles seront reliées à l'étape précédente.</span>`
|
|
: ''
|
|
Dialog.create({
|
|
title: 'Supprimer cette tâche ?',
|
|
message: `${props.job.name}: ${props.job.subject}${warning}`,
|
|
html: true,
|
|
cancel: { flat: true, label: 'Annuler' },
|
|
ok: { color: 'red', label: 'Supprimer', unelevated: true },
|
|
persistent: true,
|
|
}).onOk(async () => {
|
|
try {
|
|
// NB: no `credentials: 'include'` — the hub responds with
|
|
// `Access-Control-Allow-Origin: *`, and that combination is forbidden
|
|
// by the CORS spec (browsers reject the preflight as NetworkError).
|
|
// Every other hub call in this SPA follows the same pattern.
|
|
const res = await fetch(`${HUB_URL}/dispatch/job-delete`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ job: props.job.name }),
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`)
|
|
const msg = body.rewired && body.rewired.length
|
|
? `Tâche supprimée (${body.rewired.length} dépendance${body.rewired.length > 1 ? 's' : ''} recâblée${body.rewired.length > 1 ? 's' : ''})`
|
|
: 'Tâche supprimée'
|
|
Notify.create({ type: 'positive', message: msg, timeout: 2500 })
|
|
emit('deleted', props.job.name)
|
|
} catch (err) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── Navigate to dispatch ──
|
|
function goToDispatch () {
|
|
router.push('/dispatch')
|
|
}
|
|
|
|
// ── Visual computed ──
|
|
const children = computed(() =>
|
|
props.allJobs
|
|
.filter(j => j.parent_job === props.job.name)
|
|
.sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
|
|
)
|
|
|
|
const statusIcon = computed(() => {
|
|
const s = (props.job.status || '').toLowerCase()
|
|
if (s === 'completed') return 'check_circle'
|
|
if (s === 'assigned') return 'person'
|
|
if (s === 'in progress' || s === 'in-progress') return 'play_circle'
|
|
if (s === 'in-route') return 'directions_car'
|
|
if (s === 'cancelled') return 'cancel'
|
|
return 'radio_button_unchecked'
|
|
})
|
|
|
|
const statusColor = computed(() => {
|
|
const s = (props.job.status || '').toLowerCase()
|
|
if (s === 'completed') return 'green-6'
|
|
if (s === 'assigned') return 'blue-6'
|
|
if (s === 'in progress' || s === 'in-progress') return 'orange-6'
|
|
if (s === 'in-route') return 'amber-8'
|
|
if (s === 'cancelled') return 'grey-5'
|
|
return 'grey-5'
|
|
})
|
|
|
|
const badgeClass = computed(() => {
|
|
const s = (props.job.status || '').toLowerCase()
|
|
if (s === 'completed') return 'active'
|
|
if (s === 'assigned') return 'draft'
|
|
if (s === 'in progress' || s === 'in-progress') return 'open'
|
|
if (s === 'cancelled') return 'inactive'
|
|
return 'closed'
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.task-node { transition: margin 0.15s; }
|
|
.task-row {
|
|
background: #fff;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
padding: 6px 10px;
|
|
transition: all 0.15s;
|
|
cursor: pointer;
|
|
}
|
|
.task-row:hover { border-color: #6366f1; background: #fafafe; }
|
|
.task-row.task-expanded { border-color: #6366f1; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
|
|
.task-row.task-completed { border-left: 3px solid #10b981; }
|
|
.task-row.task-assigned { border-left: 3px solid #6366f1; }
|
|
.task-row.task-open { border-left: 3px solid #94a3b8; }
|
|
.task-row.task-cancelled { border-left: 3px solid #ef4444; opacity: 0.6; }
|
|
.task-row.task-in-progress { border-left: 3px solid #f59e0b; }
|
|
.task-row.task-in-route { border-left: 3px solid #f97316; }
|
|
|
|
.task-edit-panel {
|
|
background: #f8fafc;
|
|
border: 1px solid #6366f1;
|
|
border-top: none;
|
|
border-radius: 0 0 6px 6px;
|
|
padding: 10px 12px;
|
|
animation: slideDown 0.15s ease;
|
|
}
|
|
@keyframes slideDown {
|
|
from { opacity: 0; max-height: 0; }
|
|
to { opacity: 1; max-height: 500px; }
|
|
}
|
|
|
|
.task-id {
|
|
font-size: 0.65rem;
|
|
color: #6366f1;
|
|
background: #eef2ff;
|
|
padding: 1px 4px;
|
|
border-radius: 3px;
|
|
white-space: nowrap;
|
|
}
|
|
.task-step-badge {
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
background: #6366f1;
|
|
width: 16px; height: 16px;
|
|
border-radius: 50%;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.task-tag-chip {
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
color: #4338ca;
|
|
background: #e0e7ff;
|
|
padding: 0 5px;
|
|
border-radius: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
.task-tags-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.ellipsis-text {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|