From 3d65b994e5e5c14d7b7e45dae1841d071471d1cb Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sun, 7 Jun 2026 22:40:26 -0400 Subject: [PATCH] feat(dispatch): fermer ticket (unitaire + lot), tri ASC/DESC, batch PHP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/ops/src/api/roster.js | 2 + apps/ops/src/pages/PlanificationPage.vue | 22 +++++++++- services/legacy-bridge/ops_reassign.php | 41 +++++++++++++------ .../targo-hub/lib/legacy-dispatch-sync.js | 14 +++++++ 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index d35effb..b32e0fb 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -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 diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index f6fc74c..54c4b63 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -544,6 +544,7 @@ + {{ assignSortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}
{{ t.k }} {{ t.n }} @@ -587,7 +588,7 @@
- +
Redimensionner
@@ -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 diff --git a/services/legacy-bridge/ops_reassign.php b/services/legacy-bridge/ops_reassign.php index fb5e5a1..8ea0861 100644 --- a/services/legacy-bridge/ops_reassign.php +++ b/services/legacy-bridge/ops_reassign.php @@ -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('

CHANGE LOG (Ops):

' . $msg . '

'); $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'"); diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 7b975ee..c953919 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -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())