gigafibre-fsm/apps/ops/src/components/shared/TaskNode.vue
louispaulb 76f2ea3b6c fix(ops/TaskNode): drop credentials:'include' on job-delete fetch
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>
2026-04-23 10:57:57 -04:00

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>