gigafibre-fsm/apps/ops/src/components/shared/AddToTaskDialog.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

124 lines
6.7 KiB
Vue

<template>
<!-- RÉUTILISABLE : ajoute le fil/objet courant (conversation, ticket, projet) à une TÂCHE ERPNext
existante ou nouvelle. Passé un `source` = { type, name, title, customer, url }. -->
<q-dialog v-model="model" @hide="reset">
<q-card style="min-width:440px;max-width:92vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="add_task" color="primary" size="22px" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Ajouter à une tâche</span>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-sm">
Lier <b>{{ source.title || source.name || 'cet élément' }}</b>
<span v-if="source.type" class="text-grey-5">({{ srcLabel }})</span> à une tâche.
</div>
<q-btn-toggle v-model="tab" spread no-caps dense unelevated toggle-color="primary" color="grey-2" text-color="grey-8" class="q-mb-md"
:options="[{ label: 'Tâche existante', value: 'existing' }, { label: 'Nouvelle tâche', value: 'new' }]" />
<!-- Tâche existante -->
<template v-if="tab === 'existing'">
<q-select dense outlined v-model="picked" use-input input-debounce="300" :options="taskOptions" @filter="searchTasks"
option-label="label" option-value="value" emit-value map-options clearable :loading="searching"
label="Chercher une tâche (sujet ou #)">
<template #no-option><q-item><q-item-section class="text-grey-6 text-caption">Tape pour chercher — ou crée une nouvelle tâche.</q-item-section></q-item></template>
</q-select>
</template>
<!-- Nouvelle tâche -->
<template v-else>
<q-input dense outlined v-model="newTask.subject" label="Sujet de la tâche" autofocus />
<div class="row q-col-gutter-sm q-mt-xs">
<q-select class="col-6 col-sm" dense outlined v-model="newTask.priority" :options="['Low', 'Medium', 'High', 'Urgent']" label="Priorité" emit-value map-options />
<q-input class="col-6 col-sm" dense outlined v-model="newTask.exp_end_date" type="date" label="Échéance" />
</div>
<q-input dense outlined v-model="newTask.description" label="Détails (optionnel)" type="textarea" autogrow class="q-mt-xs" />
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="primary" :icon="tab === 'new' ? 'add_task' : 'link'"
:label="tab === 'new' ? 'Créer + lier' : 'Lier à la tâche'" :loading="busy"
:disable="tab === 'existing' ? !picked : !newTask.subject.trim()" @click="confirm" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, reactive, watch } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs, getDoc, createDoc, updateDoc } from 'src/api/erp'
const props = defineProps({
modelValue: { type: Boolean, default: false },
// Objet/fil courant à lier : { type:'Issue'|'Conversation'|'Customer'|'Project'…, name, title, customer, url }
source: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:modelValue', 'linked'])
const $q = useQuasar()
const model = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const SRC_LABEL = { Issue: 'ticket', Conversation: 'conversation', Customer: 'client', Project: 'projet', Task: 'tâche', 'Dispatch Job': 'travail' }
const srcLabel = computed(() => SRC_LABEL[props.source.type] || props.source.type)
const tab = ref('existing')
const busy = ref(false)
const searching = ref(false)
const taskOptions = ref([])
const picked = ref(null)
const newTask = reactive({ subject: '', priority: 'Medium', exp_end_date: '', description: '' })
// Pré-remplit le sujet de la nouvelle tâche depuis le fil courant
watch(model, (open) => { if (open) { newTask.subject = props.source.title || ''; loadRecentTasks() } })
// Ligne de référence ajoutée à la tâche (lien retour vers le fil) — réutilisable multi-objets : chaque ajout empile une ligne.
function refLine () {
const s = props.source
const bits = ['↪ ' + (s.title || s.name || 'élément')]
if (s.type && s.name) bits.push('[' + (srcLabel.value || s.type) + ' ' + s.name + ']')
if (s.customer) bits.push('· client ' + s.customer)
if (s.url) bits.push('· ' + s.url)
return bits.join(' ')
}
async function loadRecentTasks () {
searching.value = true
try {
const rows = await listDocs('Task', { filters: { status: ['not in', ['Completed', 'Cancelled']] }, fields: ['name', 'subject', 'status'], limit: 15, orderBy: 'modified desc' })
taskOptions.value = rows.map(t => ({ label: (t.subject || t.name) + ' · ' + t.status, value: t.name }))
} catch { taskOptions.value = [] } finally { searching.value = false }
}
function searchTasks (query, done) {
const q = (query || '').trim()
if (!q) { loadRecentTasks().then(() => done(() => {})); return }
searching.value = true
listDocs('Task', { or_filters: { subject: ['like', '%' + q + '%'], name: ['like', '%' + q + '%'] }, fields: ['name', 'subject', 'status'], limit: 15, orderBy: 'modified desc' })
.then(rows => { done(() => { taskOptions.value = rows.map(t => ({ label: (t.subject || t.name) + ' · ' + t.status, value: t.name })) }) })
.catch(() => done(() => { taskOptions.value = [] }))
.finally(() => { searching.value = false })
}
async function confirm () {
busy.value = true
try {
if (tab.value === 'new') {
const data = { subject: newTask.subject.trim(), status: 'Open', priority: newTask.priority, description: [newTask.description, refLine()].filter(Boolean).join('\n\n') }
if (newTask.exp_end_date) data.exp_end_date = newTask.exp_end_date
const r = await createDoc('Task', data)
$q.notify({ type: 'positive', icon: 'add_task', message: 'Tâche créée et liée' })
emit('linked', { task: r?.name, created: true })
} else {
const cur = await getDoc('Task', picked.value)
const desc = [(cur && cur.description) || '', refLine()].filter(Boolean).join('\n\n')
await updateDoc('Task', picked.value, { description: desc })
$q.notify({ type: 'positive', icon: 'link', message: 'Lié à la tâche ' + picked.value })
emit('linked', { task: picked.value, created: false })
}
model.value = false
} catch (e) { $q.notify({ type: 'negative', message: 'Échec : ' + (e.message || e) }) } finally { busy.value = false }
}
function reset () { tab.value = 'existing'; picked.value = null; newTask.subject = ''; newTask.priority = 'Medium'; newTask.exp_end_date = ''; newTask.description = '' }
</script>