feat(dispatch): fermer ticket (unitaire + lot), tri ASC/DESC, batch PHP
- PHP ops_reassign.php : action `close` (single) + `batch_close` (N tickets en 1 connexion → efficace) ; close = status=closed + date_closed + closed_by=acteur Authentik + réouverture enfants waiting_for + log. - Hub : closeTicketLegacy + batchCloseLegacy + routes POST close-ticket / batch-close (marque Dispatch Job Completed). - Ops : bouton « Fermer ce ticket » (fil du panneau) ; « Fermer (N) » sur la sélection (fermeture en lot) ; tri ASC/DESC du panneau (toggle, surtout pour la date). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4abce6fd66
commit
3d65b994e5
|
|
@ -98,6 +98,8 @@ export const pushLegacyApply = () => jpost('/dispatch/legacy-sync/push-assignmen
|
|||
export const ticketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
||||
// Fermer un ticket dans le legacy (status=closed + date_closed + closed_by=acteur + réouverture enfants)
|
||||
export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-ticket?ticket=' + encodeURIComponent(ticket), {})
|
||||
// Fermeture EN LOT (1 appel pour N tickets) — efficace
|
||||
export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {})
|
||||
// Assigner un job à un tech (date = case déposée)
|
||||
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
|
||||
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
|
||||
|
|
|
|||
|
|
@ -544,6 +544,7 @@
|
|||
<option value="city">Ville</option>
|
||||
<option value="priority">Priorité</option>
|
||||
</select>
|
||||
<q-btn dense flat size="sm" :icon="assignSortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="assignSortDir = assignSortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ assignSortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div v-if="assignTypes.length > 1" class="assign-chips" @mousedown.stop>
|
||||
<span v-for="t in assignTypes" :key="t.k" class="assign-chip-f" :style="assignTypeFilter.includes(t.k) ? { background: getTagColor(t.k), color: '#fff', borderColor: getTagColor(t.k) } : { borderColor: getTagColor(t.k) }" @click="toggleAssignType(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
|
||||
|
|
@ -587,7 +588,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div v-if="assignPanel.jobs.length" class="assign-foot">
|
||||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b> — glisse la sélection sur un tech</template>
|
||||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b> — glisse sur un tech<q-btn dense unelevated size="sm" color="negative" icon="block" :label="'Fermer (' + selectedNames.length + ')'" class="q-ml-sm" @click="bulkCloseSelected"><q-tooltip>Fermer les tickets sélectionnés dans le legacy (au lieu de réassigner)</q-tooltip></q-btn></template>
|
||||
<template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template>
|
||||
</div>
|
||||
<div class="assign-resize" @mousedown.stop="panelResizeDown"><q-tooltip>Redimensionner</q-tooltip></div>
|
||||
|
|
@ -1027,6 +1028,7 @@ async function removeFromPush (s) {
|
|||
} catch (e) { err(e) } finally { legacyPush.applying = false }
|
||||
}
|
||||
const assignSort = ref('group') // group (parent-enfant) | skill | date | city | priority
|
||||
const assignSortDir = ref('asc') // sens du tri (asc/desc) — surtout pour la date
|
||||
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
function jobCity (j) {
|
||||
const a = String(j.location_label || j.service_location || '')
|
||||
|
|
@ -1052,7 +1054,8 @@ const assignGroups = computed(() => {
|
|||
: (j.scheduled_date || 'Sans date')
|
||||
const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : k
|
||||
const g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
|
||||
const keys = Object.keys(g).sort((a, b) => assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b))
|
||||
const dir = assignSortDir.value === 'desc' ? -1 : 1
|
||||
const keys = Object.keys(g).sort((a, b) => (assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir)
|
||||
return keys.map(k => ({ key: k, label: labelOf(k), jobs: g[k] }))
|
||||
})
|
||||
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
|
||||
|
|
@ -1305,6 +1308,21 @@ async function closeLegacyTicket (j) {
|
|||
else $q.notify({ type: 'warning', message: 'Échec fermeture : ' + ((r && r.error) || '?') })
|
||||
} catch (e) { err(e) }
|
||||
}
|
||||
// Fermeture EN LOT des jobs cochés (1 seul appel) — option « fermer » au lieu de « réassigner ».
|
||||
async function bulkCloseSelected () {
|
||||
const sel = assignPanel.jobs.filter(j => selectedJobs[j.name] && j.legacy_ticket_id)
|
||||
if (!sel.length) { $q.notify({ type: 'warning', message: 'Aucun ticket legacy coché' }); return }
|
||||
if (!window.confirm('Fermer ' + sel.length + ' ticket(s) dans le legacy ?\n→ ils sortent du dispatch (Completed).')) return
|
||||
try {
|
||||
const r = await roster.batchCloseLegacy(sel.map(j => j.legacy_ticket_id))
|
||||
if (r && r.ok) {
|
||||
const done = new Set((r.results || []).filter(x => x.ok).map(x => String(x.t)))
|
||||
assignPanel.jobs = assignPanel.jobs.filter(j => !done.has(String(j.legacy_ticket_id)))
|
||||
for (const k in selectedJobs) delete selectedJobs[k]
|
||||
$q.notify({ type: 'positive', message: r.closed + ' ticket(s) fermé(s)' + (r.locked ? ' · ' + r.locked + ' verrouillé(s)' : '') + (r.skipped ? ' · ' + r.skipped + ' déjà fermé(s)' : '') })
|
||||
} else $q.notify({ type: 'warning', message: 'Échec : ' + ((r && r.error) || '?') })
|
||||
} catch (e) { err(e) }
|
||||
}
|
||||
function fmtDT (iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' } }
|
||||
async function initDayMap () {
|
||||
if (!MAPBOX_TOKEN || !dayMapEl.value || _dayMap) return
|
||||
|
|
|
|||
|
|
@ -30,31 +30,46 @@ $tok = $_SERVER['HTTP_X_OPS_TOKEN'] ?? ($_POST['token'] ?? '');
|
|||
if (!is_string($tok) || !hash_equals($OPS_TOKEN, $tok)) out(['ok' => false, 'error' => 'forbidden'], 403);
|
||||
|
||||
$action = $_POST['action'] ?? 'reassign';
|
||||
$ticket = (int)($_POST['ticket_id'] ?? 0);
|
||||
if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400);
|
||||
// Auteur réel de l'action = staff legacy du répartiteur Ops (mappé depuis son email Authentik côté hub) ; repli = Tech Targo.
|
||||
// Auteur réel = staff legacy du répartiteur Ops (mappé depuis son email Authentik côté hub) ; repli = Tech Targo.
|
||||
$actor = (int)($_POST['actor_staff_id'] ?? 0);
|
||||
$logStaff = $actor > 0 ? $actor : $OPS_STAFF;
|
||||
|
||||
// ─── connexion (droits write hérités de l'hôte app) ───
|
||||
$db = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||
if ($db->connect_errno) out(['ok' => false, 'error' => 'db_connect: ' . $db->connect_error], 500);
|
||||
$db->set_charset('utf8');
|
||||
|
||||
// ─── ticket courant ───
|
||||
$res = $db->query("SELECT id, status, assign_to, lock_name, followed_by FROM ticket WHERE id = $ticket LIMIT 1");
|
||||
$t = $res ? $res->fetch_assoc() : null;
|
||||
if (!$t) out(['ok' => false, 'error' => 'ticket introuvable'], 404);
|
||||
if ($t['status'] === 'closed') out(['ok' => false, 'error' => 'ticket déjà fermé', 'status' => 'closed']);
|
||||
// LOCK : un staff édite le ticket dans le legacy → on NE clobbe PAS (le répartiteur réessaie)
|
||||
if (trim((string)$t['lock_name']) !== '') out(['ok' => false, 'error' => 'verrouillé', 'locked_by' => $t['lock_name']]);
|
||||
$now = time();
|
||||
$logStaff = $actor > 0 ? $actor : $OPS_STAFF;
|
||||
|
||||
function ops_log($db, $ticket, $staff, $msg) {
|
||||
$m = $db->real_escape_string('<hr><p>CHANGE LOG (Ops):</p><p>' . $msg . '</p>');
|
||||
$db->query("INSERT INTO ticket_msg (ticket_id, staff_id, msg, date_orig, public) VALUES ($ticket, $staff, '$m', " . time() . ", 0)");
|
||||
}
|
||||
|
||||
$now = time();
|
||||
// ─── FERMETURE EN LOT : 1 connexion, N tickets (efficace — évite N allers-retours HTTPS + mysqli) ───
|
||||
if ($action === 'batch_close') {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', explode(',', (string)($_POST['ticket_ids'] ?? ''))))));
|
||||
$closed = 0; $skipped = 0; $lockedN = 0; $results = [];
|
||||
foreach ($ids as $tid) {
|
||||
$r = $db->query("SELECT status, lock_name FROM ticket WHERE id = $tid LIMIT 1"); $row = $r ? $r->fetch_assoc() : null;
|
||||
if (!$row || $row['status'] === 'closed') { $skipped++; $results[] = ['t' => $tid, 'skip' => true]; continue; }
|
||||
if (trim((string)$row['lock_name']) !== '') { $lockedN++; $results[] = ['t' => $tid, 'locked' => $row['lock_name']]; continue; }
|
||||
$db->query("UPDATE ticket SET status='closed', date_closed=$now, closed_by=$logStaff, last_update=$now WHERE id=$tid AND status<>'closed'");
|
||||
$db->query("UPDATE ticket SET status='open', last_update=$now WHERE waiting_for=$tid AND status<>'closed'");
|
||||
ops_log($db, $tid, $logStaff, 'Ticket fermé via Ops (lot).');
|
||||
$closed++; $results[] = ['t' => $tid, 'ok' => true];
|
||||
}
|
||||
out(['ok' => true, 'action' => 'batch_close', 'closed' => $closed, 'skipped' => $skipped, 'locked' => $lockedN, 'results' => $results]);
|
||||
}
|
||||
|
||||
// ─── actions sur 1 ticket ───
|
||||
$ticket = (int)($_POST['ticket_id'] ?? 0);
|
||||
if ($ticket <= 0) out(['ok' => false, 'error' => 'ticket_id requis'], 400);
|
||||
$res = $db->query("SELECT id, status, assign_to, lock_name, followed_by FROM ticket WHERE id = $ticket LIMIT 1");
|
||||
$t = $res ? $res->fetch_assoc() : null;
|
||||
if (!$t) out(['ok' => false, 'error' => 'ticket introuvable'], 404);
|
||||
if ($t['status'] === 'closed') out(['ok' => false, 'error' => 'ticket déjà fermé', 'status' => 'closed']);
|
||||
// LOCK : un staff édite le ticket dans le legacy → on NE clobbe PAS (le répartiteur réessaie)
|
||||
if (trim((string)$t['lock_name']) !== '') out(['ok' => false, 'error' => 'verrouillé', 'locked_by' => $t['lock_name']]);
|
||||
|
||||
if ($action === 'close') {
|
||||
$db->query("UPDATE ticket SET status='closed', date_closed=$now, closed_by=$logStaff, last_update=$now WHERE id=$ticket AND status<>'closed'");
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ async function closeTicketLegacy (ticketId, actorEmail) {
|
|||
const w = await legacyWrite({ action: 'close', ticket_id: ticketId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
return (w && w.data) || { ok: false, error: 'no response' }
|
||||
}
|
||||
// Fermeture EN LOT : 1 seul appel PHP (1 connexion) pour N tickets → bien plus rapide que N appels.
|
||||
async function batchCloseLegacy (ticketIds, actorEmail) {
|
||||
let actorStaff = 0
|
||||
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } } catch (e) {}
|
||||
const w = await legacyWrite({ action: 'batch_close', ticket_ids: ticketIds.join(','), actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||
return (w && w.data) || { ok: false, error: 'no response' }
|
||||
}
|
||||
|
||||
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc
|
||||
// FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
|
||||
|
|
@ -714,6 +721,13 @@ async function handle (req, res, method, path) {
|
|||
if (r && r.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(id)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} }
|
||||
return json(res, 200, r)
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/batch-close' && method === 'POST') { // ferme N tickets legacy en 1 appel + marque les Dispatch Jobs Completed
|
||||
const ids = (new URL(req.url, 'http://localhost').searchParams.get('tickets') || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (!ids.length) return json(res, 400, { ok: false, error: 'tickets requis' })
|
||||
const r = await batchCloseLegacy(ids, req.headers['x-authentik-email'] || '')
|
||||
if (r && r.ok && Array.isArray(r.results)) { for (const it of r.results) { if (it.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(it.t)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} } } }
|
||||
return json(res, 200, r)
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'GET') return json(res, 200, await purgeStaleOrphans({ dryRun: true })) // aperçu purge orphelins TT- périmés
|
||||
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'POST') return json(res, 200, await purgeStaleOrphans({ dryRun: false })) // supprime
|
||||
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user