Update project documentation in 10-stack.mdc with JIRA prefix details, add new routes for Radar functionality in Routes.php, and include Radar link in the sidebar menu of men_list.php.

This commit is contained in:
2026-08-20 17:09:59 -04:00
parent d41bdad079
commit 30998edad5
10 changed files with 496 additions and 1 deletions

View File

@ -0,0 +1,14 @@
---
description: Radar mail — proto CRM v4 (IDEE-4 / IDEE-5)
alwaysApply: true
---
# Radar mail (CRM)
Épique **MSOP-1**. Idée : **IDEE-4**. Proto écran : **MSOP-2**.
- UI : CI4 / Raven `/v4/radar` — pas SmartAdmin.
- Périmètre : tout `@ms1timing.com` moins `radar_exceptions` (v0 : `confirmation@`). Pas de palier `info@`.
- Moteur (plus tard) : Gmail Push + Gemini. Lécran lit `radar_threads`.
- Un fil = N skills. Vérité = CRM (on ninvente pas un client).
- Pas dans MS1 Inscription.

View File

@ -6,6 +6,6 @@ alwaysApply: true
# Projet CRM MS1 — stack
- Stack : PHP applicatif CRM (`application/`, `v4/`, `v4_ci4/`, etc.).
- Préfixe JIRA : confirmer avec l'utilisateur (souvent ticket CRM / MS1 selon le contexte).
- Préfixe JIRA développement : **`MSOP`** (espace MS1 Ops). Idées = `IDEE` (pas de code). Inscription = `MSIN`.
- Workspace local typique : `C:\dev_v4\crm-ms1`.
- Ne pas committer `credentials.json` ni secrets.

View File

129
sql/IDEE-4-radar-proto.sql Normal file
View File

@ -0,0 +1,129 @@
-- IDEE-4 / IDEE-5 — Radar mail proto (CRM)
-- Tables pour lécran /v4/radar. Moteur Gmail Push + Gemini : story suivante.
-- Exécuter sur la BD CRM de DEV (devcrmms1_dev), pas en prod.
CREATE TABLE IF NOT EXISTS radar_exceptions (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
kind ENUM('mailbox', 'sender') NOT NULL DEFAULT 'mailbox',
value VARCHAR(190) NOT NULL,
note VARCHAR(255) NOT NULL DEFAULT '',
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_exc (kind, value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS radar_threads (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
gmail_thread_id VARCHAR(64) NOT NULL,
mailbox VARCHAR(190) NOT NULL DEFAULT '',
subject VARCHAR(255) NOT NULL DEFAULT '',
from_email VARCHAR(190) NOT NULL DEFAULT '',
from_name VARCHAR(190) NOT NULL DEFAULT '',
gmail_url VARCHAR(500) NOT NULL DEFAULT '',
client_id INT UNSIGNED NULL DEFAULT NULL,
projet_id INT UNSIGNED NULL DEFAULT NULL,
proposed_client_no VARCHAR(32) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'a_classer',
summary VARCHAR(500) NOT NULL DEFAULT '',
confidence TINYINT UNSIGNED NOT NULL DEFAULT 0,
is_example TINYINT(1) NOT NULL DEFAULT 0,
received_at DATETIME NULL DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_thread (gmail_thread_id),
KEY idx_radar_status (status),
KEY idx_radar_received (received_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS radar_thread_skills (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
thread_id INT UNSIGNED NOT NULL,
skill VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_skill (thread_id, skill),
KEY idx_radar_skill (skill)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS radar_rules (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
match_type ENUM('email', 'domain') NOT NULL DEFAULT 'email',
match_value VARCHAR(190) NOT NULL,
client_id INT UNSIGNED NULL DEFAULT NULL,
projet_id INT UNSIGNED NULL DEFAULT NULL,
skill VARCHAR(32) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT 'human',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_rule (match_type, match_value, skill)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO radar_exceptions (kind, value, note, active)
SELECT 'mailbox', 'confirmation@ms1timing.com', 'Mails transactionnels inscription — ne pas ouvrir', 1
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM radar_exceptions
WHERE kind = 'mailbox' AND value = 'confirmation@ms1timing.com'
);
INSERT INTO radar_threads (
gmail_thread_id, mailbox, subject, from_email, from_name, gmail_url,
proposed_client_no, status, summary, confidence, is_example, received_at
)
SELECT
'proto-ex-1',
'desjardins.lp@ms1timing.com',
'Logos dossards + fichiers départ site',
'marie@course-exemple.ca',
'Marie Exemple',
'',
'1842',
'a_classer',
'Deux sujets dans le même fil : logos pour limprimeur, et fichiers à déposer pour le site dinscription.',
72,
1,
NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM radar_threads WHERE gmail_thread_id = 'proto-ex-1');
INSERT INTO radar_thread_skills (thread_id, skill)
SELECT t.id, 'dossards' FROM radar_threads t
WHERE t.gmail_thread_id = 'proto-ex-1'
AND NOT EXISTS (
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'dossards'
);
INSERT INTO radar_thread_skills (thread_id, skill)
SELECT t.id, 'site' FROM radar_threads t
WHERE t.gmail_thread_id = 'proto-ex-1'
AND NOT EXISTS (
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'site'
);
INSERT INTO radar_threads (
gmail_thread_id, mailbox, subject, from_email, from_name, gmail_url,
proposed_client_no, status, summary, confidence, is_example, received_at
)
SELECT
'proto-ex-2',
'info@ms1timing.com',
'Question impression dossards',
'nouveau@inconnu.example',
'Contact inconnu',
'',
'',
'a_mettre_dans_crm',
'Adresse absente du CRM. Créer le contact / projet, puis relancer — le fil doit se recoller tout seul.',
40,
1,
DATE_SUB(NOW(), INTERVAL 25 MINUTE)
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM radar_threads WHERE gmail_thread_id = 'proto-ex-2');
INSERT INTO radar_thread_skills (thread_id, skill)
SELECT t.id, 'dossards' FROM radar_threads t
WHERE t.gmail_thread_id = 'proto-ex-2'
AND NOT EXISTS (
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'dossards'
);

View File

@ -7,6 +7,10 @@ use CodeIgniter\Router\RouteCollection;
*/
$routes->get('/', 'Home::index');
// IDEE-5 — proto Radar (écran CRM v4)
$routes->get('radar', 'Radar::index');
$routes->get('radar/feed', 'Radar::feed');
$routes->get('inventaire/redirect/(:segment)', 'Inventaire::redirect/$1');
$routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1');

View File

@ -0,0 +1,41 @@
<?php
namespace App\Controllers;
use App\Models\RadarModel;
/**
* IDEE-5 — proto écran Radar (CI4 / Raven).
*/
class Radar extends BaseController
{
public function index()
{
$model = new RadarModel();
$ready = $model->tablesExist();
return view('radar/index', [
'ready' => $ready,
'exceptions' => $ready ? $model->listExceptions() : [],
'threads' => $ready ? $model->listThreads() : [],
'feedUrl' => site_url('v4/radar/feed'),
]);
}
public function feed()
{
$model = new RadarModel();
if (!$model->tablesExist()) {
return $this->response->setJSON([
'ok' => false,
'threads' => [],
]);
}
return $this->response->setJSON([
'ok' => true,
'at' => date('c'),
'threads' => $model->listThreads(),
]);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Config\Database;
/**
* IDEE-5 — lecture proto Radar (exceptions + fils + skills).
* Le moteur Gmail/Gemini nécrit pas encore ici.
*/
class RadarModel
{
private function db()
{
return Database::connect();
}
public function tablesExist(): bool
{
$row = $this->db()->query("SHOW TABLES LIKE 'radar_threads'")->getRowArray();
return !empty($row);
}
public function listExceptions(): array
{
return $this->db()->table('radar_exceptions')
->where('active', 1)
->orderBy('kind', 'ASC')
->orderBy('value', 'ASC')
->get()
->getResultArray();
}
public function listThreads(): array
{
$rows = $this->db()->table('radar_threads')
->orderBy('received_at', 'DESC')
->orderBy('id', 'DESC')
->get()
->getResultArray();
if ($rows === []) {
return [];
}
$ids = array_column($rows, 'id');
$skillRows = $this->db()->table('radar_thread_skills')
->whereIn('thread_id', $ids)
->get()
->getResultArray();
$byThread = [];
foreach ($skillRows as $s) {
$byThread[(int) $s['thread_id']][] = $s['skill'];
}
foreach ($rows as &$row) {
$id = (int) $row['id'];
$row['skills'] = $byThread[$id] ?? [];
$row['client_url'] = !empty($row['client_id'])
? '/index.php/clients/form/' . (int) $row['client_id']
: '';
$row['projet_url'] = !empty($row['projet_id'])
? '/index.php/projets/form/' . (int) $row['projet_id']
: '';
}
unset($row);
return $rows;
}
}

View File

@ -20,6 +20,12 @@
<div class="flex flex-col h-full overflow-y-auto" data-simplebar>
<div class="flex flex-col py-3 px-3">
<nav class="flex flex-col space-y-0.5" id="SidebarCollapseGroup">
<a href="/v4/radar" class="sidebar-link">
<span class="sidebar-icon">
<span class="icon-[lucide--mail]"></span>
</span>
<span class="sidebar-text">Radar mail</span>
</a>
<a href="/v4/dynamic/list/inventaire" class="sidebar-link ">
<span class="sidebar-icon">
<span class="icon-[lucide--file-chart-column]"></span>

View File

@ -0,0 +1,229 @@
<?php
$title = 'Radar mail';
$menu = 'menus/men_list';
$header = 'headers/hea_default';
$this->setVar('menu', $menu);
$this->setVar('header', $header);
$this->setVar('title', $title);
$ready = $ready ?? false;
$exceptions = $exceptions ?? [];
$threads = $threads ?? [];
$feedUrl = $feedUrl ?? site_url('v4/radar/feed');
$statusLabel = static function (string $status): string {
return match ($status) {
'classe' => 'Classé',
'a_classer' => 'À classer',
'a_mettre_dans_crm' => 'À mettre dans le CRM',
'ignore' => 'Ignoré',
default => $status,
};
};
$statusClass = static function (string $status): string {
return match ($status) {
'classe' => 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200',
'a_mettre_dans_crm' => 'bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200',
'ignore' => 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
default => 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200',
};
};
?>
<?= $this->extend('layouts/default') ?>
<?= $this->section('content') ?>
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-zinc-500">CRM v4 · IDEE-5</p>
<h1 class="text-2xl font-semibold text-zinc-900 dark:text-white">Radar mail</h1>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Tout <span class="font-medium">@ms1timing.com</span> moins les exceptions.
Un fil peut porter plusieurs sujets. Le CRM est la vérité — on ninvente pas un client.
</p>
</div>
<div class="text-right">
<p class="text-xs text-zinc-500">Fil mis à jour</p>
<p id="radar-clock" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">—</p>
</div>
</div>
<?php if (!$ready): ?>
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
Tables absentes. Exécuter <code class="font-mono">sql/IDEE-4-radar-proto.sql</code> sur la BD CRM de <strong>dev</strong>, puis recharger.
</div>
<?php else: ?>
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
Moteur Gmail Push + Gemini : pas encore branché. Cet écran lit la table
<code class="font-mono">radar_threads</code> (deux exemples proto pour valider la saveur).
Périmètre cible : domaine entier, pas une seule boîte.
</div>
<?php endif; ?>
<div class="grid gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 lg:col-span-1">
<h2 class="text-sm font-semibold text-zinc-900 dark:text-white">Exceptions</h2>
<p class="mt-1 text-xs text-zinc-500">On exclut, on ne tient pas de liste blanche.</p>
<ul class="mt-3 space-y-2 text-sm" id="radar-exceptions">
<?php if ($exceptions === []): ?>
<li class="text-zinc-400">Aucune</li>
<?php else: ?>
<?php foreach ($exceptions as $ex): ?>
<li class="flex flex-col rounded-lg bg-zinc-50 px-3 py-2 dark:bg-zinc-800/60">
<span class="font-mono text-xs text-zinc-500"><?= esc($ex['kind']) ?></span>
<span class="font-medium"><?= esc($ex['value']) ?></span>
<?php if ($ex['note'] !== ''): ?>
<span class="text-xs text-zinc-500"><?= esc($ex['note']) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 lg:col-span-2">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-zinc-900 dark:text-white">Signaux</h2>
<span class="text-xs text-zinc-400" id="radar-count"><?= count($threads) ?> fil(s)</span>
</div>
<div id="radar-feed" class="space-y-3">
<?php if ($threads === []): ?>
<p class="text-sm text-zinc-400">Aucun signal pour linstant.</p>
<?php else: ?>
<?php foreach ($threads as $t): ?>
<article class="rounded-lg border border-zinc-100 p-3 dark:border-zinc-800">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="font-medium text-zinc-900 dark:text-white"><?= esc($t['subject'] ?: '(sans objet)') ?></p>
<p class="text-xs text-zinc-500">
<?= esc($t['from_name'] !== '' ? $t['from_name'] . ' · ' : '') ?><?= esc($t['from_email']) ?>
· <?= esc($t['mailbox']) ?>
</p>
</div>
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium <?= $statusClass($t['status']) ?>">
<?= esc($statusLabel($t['status'])) ?>
</span>
</div>
<div class="mt-2 flex flex-wrap gap-1">
<?php foreach ($t['skills'] as $sk): ?>
<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"><?= esc($sk) ?></span>
<?php endforeach; ?>
<?php if (!empty($t['proposed_client_no'])): ?>
<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-mono text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">#<?= esc($t['proposed_client_no']) ?></span>
<?php endif; ?>
<?php if (!empty($t['is_example'])): ?>
<span class="rounded-md border border-dashed border-zinc-300 px-2 py-0.5 text-xs text-zinc-500">exemple proto</span>
<?php endif; ?>
</div>
<?php if ($t['summary'] !== ''): ?>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400"><?= esc($t['summary']) ?></p>
<?php endif; ?>
<div class="mt-2 flex flex-wrap gap-3 text-xs">
<?php if (!empty($t['projet_url'])): ?>
<a class="font-medium text-primary hover:underline" href="<?= esc($t['projet_url']) ?>">Ouvrir le projet CRM</a>
<?php elseif ($t['status'] === 'a_mettre_dans_crm'): ?>
<a class="font-medium text-amber-700 hover:underline dark:text-amber-300" href="/index.php/clients">Créer dans le CRM, puis relancer</a>
<?php endif; ?>
<?php if (!empty($t['client_url'])): ?>
<a class="text-zinc-500 hover:underline" href="<?= esc($t['client_url']) ?>">Fiche client</a>
<?php endif; ?>
<?php if (!empty($t['gmail_url'])): ?>
<a class="text-zinc-500 hover:underline" href="<?= esc($t['gmail_url']) ?>" target="_blank" rel="noopener">Gmail</a>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
(function () {
const feedUrl = <?= json_encode($feedUrl) ?>;
const root = document.getElementById('radar-feed');
const clock = document.getElementById('radar-clock');
const countEl = document.getElementById('radar-count');
if (!root || !feedUrl) return;
const statusLabel = {
classe: 'Classé',
a_classer: 'À classer',
a_mettre_dans_crm: 'À mettre dans le CRM',
ignore: 'Ignoré'
};
const statusClass = {
classe: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200',
a_mettre_dans_crm: 'bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200',
ignore: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
a_classer: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200'
};
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function render(threads) {
countEl.textContent = (threads.length || 0) + ' fil(s)';
if (!threads.length) {
root.innerHTML = '<p class="text-sm text-zinc-400">Aucun signal pour linstant.</p>';
return;
}
root.innerHTML = threads.map(function (t) {
const st = t.status || 'a_classer';
const skills = (t.skills || []).map(function (sk) {
return '<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">' + esc(sk) + '</span>';
}).join('');
const num = t.proposed_client_no
? '<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-mono text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">#' + esc(t.proposed_client_no) + '</span>'
: '';
const ex = t.is_example && t.is_example !== '0'
? '<span class="rounded-md border border-dashed border-zinc-300 px-2 py-0.5 text-xs text-zinc-500">exemple proto</span>'
: '';
let links = '';
if (t.projet_url) {
links += '<a class="font-medium text-primary hover:underline" href="' + esc(t.projet_url) + '">Ouvrir le projet CRM</a>';
} else if (st === 'a_mettre_dans_crm') {
links += '<a class="font-medium text-amber-700 hover:underline dark:text-amber-300" href="/index.php/clients">Créer dans le CRM, puis relancer</a>';
}
if (t.client_url) {
links += '<a class="text-zinc-500 hover:underline" href="' + esc(t.client_url) + '">Fiche client</a>';
}
if (t.gmail_url) {
links += '<a class="text-zinc-500 hover:underline" href="' + esc(t.gmail_url) + '" target="_blank" rel="noopener">Gmail</a>';
}
return '<article class="rounded-lg border border-zinc-100 p-3 dark:border-zinc-800">'
+ '<div class="flex flex-wrap items-start justify-between gap-2"><div>'
+ '<p class="font-medium text-zinc-900 dark:text-white">' + esc(t.subject || '(sans objet)') + '</p>'
+ '<p class="text-xs text-zinc-500">' + esc((t.from_name ? t.from_name + ' · ' : '') + t.from_email) + ' · ' + esc(t.mailbox) + '</p>'
+ '</div><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium ' + (statusClass[st] || statusClass.a_classer) + '">'
+ esc(statusLabel[st] || st) + '</span></div>'
+ '<div class="mt-2 flex flex-wrap gap-1">' + skills + num + ex + '</div>'
+ (t.summary ? '<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">' + esc(t.summary) + '</p>' : '')
+ '<div class="mt-2 flex flex-wrap gap-3 text-xs">' + links + '</div></article>';
}).join('');
}
async function tick() {
try {
const res = await fetch(feedUrl, { credentials: 'same-origin' });
const data = await res.json();
if (clock) clock.textContent = new Date().toLocaleTimeString('fr-CA');
if (data && data.ok) render(data.threads || []);
} catch (e) {
if (clock) clock.textContent = 'hors ligne';
}
}
tick();
setInterval(tick, 4000);
})();
</script>
<?= $this->endSection() ?>