Refonte design V1

This commit is contained in:
mrKamoo
2026-10-02 13:06:14 +02:00
parent 429dd9de5e
commit 83e1ce00a9
20 changed files with 1403 additions and 531 deletions
+10
View File
@@ -83,6 +83,16 @@ obligatoire, lien facultatif, période en heure de Paris, priorité, groupes AD
- Aucun cache à purger : l'add-in interroge l'API à chaque nouveau message, la bannière apparaît et disparaît
donc à l'heure. Les messages déjà envoyés la conservent tant que l'image reste dans la bibliothèque.
## Interface du back-office
Aucune dépendance externe ni étape de compilation : tout est servi depuis `backend/public`.
- `css/app.css` : feuille unique. Les couleurs, rayons et polices sont des variables en tête de fichier (`:root`).
- `js/app.js` : notifications, boîte de confirmation (`data-confirm` sur un formulaire), zone de dépôt (`data-dropzone`),
copie (`data-copy`), bouton occupé (`data-busy`). Sans JavaScript, les formulaires restent utilisables.
- `fonts/` : Atkinson Hyperlegible Next et Mono (licence OFL, fichier joint), hébergées localement.
- `resources/views/components/icon.blade.php` : icônes Lucide (licence ISC) intégrées, `<x-icon name="plus" />`.
## Syntaxe des templates
| Syntaxe | Effet |
+416
View File
@@ -0,0 +1,416 @@
/*
* Back-office et portail « Signatures ».
* Feuille unique, sans préprocesseur ni CDN : modifiez les variables ci-dessous pour adapter les couleurs.
*/
@font-face {
font-family: "Atkinson Hyperlegible Next";
src: url("../fonts/atkinson-next.woff2") format("woff2");
font-weight: 200 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Atkinson Hyperlegible Mono";
src: url("../fonts/atkinson-mono.woff2") format("woff2");
font-weight: 200 800;
font-style: normal;
font-display: swap;
}
:root {
/* Texte et surfaces */
--ink: #17242e;
--muted: #586a77;
--line: #dce3e8;
--line-strong: #bfcbd4;
--bg: #f2f5f7;
--surface: #ffffff;
--surface-2: #f7f9fa;
/* Bleu nuit de la barre latérale, bleu canal des actions, jaune soleil en repère */
--night: #0f3554;
--night-2: #1a4871;
--night-ink: #c9dbe9;
--accent: #16649a;
--accent-strong: #0f4f7d;
--accent-soft: #e4f0f8;
--sun: #f2b544;
/* États */
--ok: #1c7440;
--ok-soft: #e2f3e8;
--warn: #8a5300;
--warn-soft: #fff1d2;
--danger: #b3261e;
--danger-soft: #fdeceb;
--r-s: 6px;
--r-m: 10px;
--r-l: 14px;
--sidebar: 248px;
--focus: 0 0 0 3px rgba(22, 100, 154, .28);
--font: "Atkinson Hyperlegible Next", "Segoe UI", system-ui, sans-serif;
--mono: "Atkinson Hyperlegible Mono", Consolas, "Cascadia Mono", monospace;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--font);
font-size: 15px;
line-height: 1.45;
color: var(--ink);
background: var(--bg);
}
h1 { font-size: 26px; line-height: 1.15; font-weight: 700; letter-spacing: -.01em; margin: 0; }
h2 { font-size: 16px; line-height: 1.3; font-weight: 700; margin: 0 0 12px; }
p { margin: 0 0 12px; }
a { color: var(--accent); text-underline-offset: 2px; }
a:hover { color: var(--accent-strong); }
code { font-family: var(--mono); font-size: .9em; background: var(--surface-2); border: 1px solid var(--line); border-radius: 4px; padding: 0 4px; }
strong { font-weight: 700; }
:focus-visible { outline: none; box-shadow: var(--focus); border-radius: var(--r-s); }
.icon { flex: none; vertical-align: -3px; }
.muted { color: var(--muted); font-size: 13px; }
.spacer { flex: 1; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.nowrap { white-space: nowrap; }
.right { text-align: right; }
[hidden] { display: none !important; }
.skip { position: absolute; left: -9999px; }
.skip:focus { left: 12px; top: 12px; z-index: 50; background: var(--surface); padding: 8px 12px; border-radius: var(--r-s); }
/* ---------------------------------------------------------------- Structure */
.app { min-height: 100vh; }
.app--nav { display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); }
.sidebar {
position: sticky; top: 0; height: 100vh; overflow-y: auto;
display: flex; flex-direction: column;
background: var(--night); color: var(--night-ink);
padding: 20px 14px 14px;
}
.brand { display: block; color: #fff; text-decoration: none; padding: 0 8px 18px; }
.brand:hover { color: #fff; }
.brand-name { display: block; font-size: 21px; font-weight: 700; letter-spacing: -.01em; line-height: 1.1; }
.brand-stroke { display: block; width: 104px; height: 12px; margin: 3px 0 6px -2px; color: var(--sun); }
.brand-org { display: block; font-size: 12.5px; color: var(--night-ink); }
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-group { font-size: 12px; color: #9db6ca; margin: 16px 0 4px; padding: 0 10px; }
.nav-link {
position: relative;
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--r-s);
color: var(--night-ink); text-decoration: none; font-weight: 500;
}
.nav-link:hover { background: var(--night-2); color: #fff; }
.nav-link.active { background: var(--night-2); color: #fff; font-weight: 700; }
.nav-link.active::before { content: ""; position: absolute; left: -14px; top: 7px; bottom: 7px; width: 4px; border-radius: 0 4px 4px 0; background: var(--sun); }
.nav-link:focus-visible { box-shadow: 0 0 0 3px rgba(242, 181, 68, .55); }
.sidebar-foot { margin-top: auto; padding-top: 16px; display: flex; flex-direction: column; gap: 10px; }
.user { display: flex; align-items: center; gap: 10px; padding: 10px 8px 2px; border-top: 1px solid rgba(255, 255, 255, .14); }
.user-name { display: block; color: #fff; font-weight: 600; font-size: 14px; line-height: 1.2; overflow-wrap: anywhere; }
.user-role { display: block; font-size: 12.5px; color: #9db6ca; }
.user form { margin-left: auto; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: var(--r-s);
background: transparent; border: 0; color: inherit; cursor: pointer;
}
.icon-btn:hover { background: rgba(127, 148, 165, .22); }
.sidebar .icon-btn { color: var(--night-ink); }
.sidebar .icon-btn:hover { background: var(--night-2); color: #fff; }
.avatar {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent-strong);
font-size: 12.5px; font-weight: 700;
}
.sidebar .avatar { background: var(--night-2); color: #fff; }
.content { min-width: 0; }
main { max-width: 1180px; margin: 0 auto; padding: 28px 28px 56px; }
/* Portail agent : pas de barre latérale, simple bandeau */
.topbar {
display: flex; align-items: center; gap: 12px;
background: var(--night); color: var(--night-ink); padding: 10px 20px;
}
.topbar .brand { padding: 0; }
.topbar .brand-stroke { display: none; }
.topbar .brand-name { font-size: 18px; }
.topbar .user { border: 0; padding: 0; margin-left: auto; }
.topbar .icon-btn { color: var(--night-ink); }
.topbar .icon-btn:hover { background: var(--night-2); color: #fff; }
.topbar .avatar { background: var(--night-2); color: #fff; }
@media (max-width: 900px) {
.app--nav { display: block; }
.sidebar { position: static; height: auto; padding: 12px 12px 8px; }
.brand { padding: 0 6px 8px; }
.brand-stroke, .brand-org, .nav-group { display: none; }
.nav { flex-direction: row; overflow-x: auto; gap: 4px; padding-bottom: 6px; scrollbar-width: thin; }
.nav-link { white-space: nowrap; }
.nav-link.active::before { left: 10px; right: 10px; top: auto; bottom: 2px; width: auto; height: 3px; border-radius: 3px; }
.sidebar-foot { flex-direction: row; align-items: center; padding-top: 6px; }
.sidebar-foot .user { border: 0; padding: 0; margin-left: auto; }
.user-role { display: none; }
main { padding: 18px 14px 40px; }
h1 { font-size: 22px; }
}
/* ---------------------------------------------------------------- En-tête de page */
.page-head { display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
.page-head > div:first-child { flex: 1; min-width: 240px; }
.page-head .lead { color: var(--muted); max-width: 78ch; margin: 8px 0 0; }
.page-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.page-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.back { display: inline-flex; align-items: center; gap: 4px; font-size: 13.5px; text-decoration: none; margin-bottom: 6px; color: var(--muted); }
.back:hover { color: var(--accent-strong); }
/* ---------------------------------------------------------------- Cartes */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-m); padding: 20px; margin-bottom: 16px; }
.card-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.card-head h2 { margin: 0; }
.card--flush { padding: 0; overflow: hidden; }
.card--flush > .card-head { padding: 16px 20px 0; }
.card-foot { padding: 12px 20px; border-top: 1px solid var(--line); background: var(--surface-2); }
.card--flush > .card-foot:last-child { border-radius: 0 0 var(--r-m) var(--r-m); }
.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
@media (max-width: 1000px) { .split { grid-template-columns: 1fr; } }
/* Indicateurs du tableau de bord */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin-bottom: 16px; }
.tile {
display: flex; align-items: center; gap: 12px;
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-m);
padding: 14px 16px; color: inherit; text-decoration: none;
}
a.tile:hover { border-color: var(--accent); color: inherit; }
.tile-icon { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: var(--r-s); background: var(--accent-soft); color: var(--accent-strong); }
.tile-value { display: block; font-size: 24px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
.tile-label { display: block; color: var(--muted); font-size: 13px; }
/* ---------------------------------------------------------------- Tableaux */
.table-wrap { overflow-x: auto; }
table.list { width: 100%; border-collapse: collapse; }
table.list th, table.list td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
table.list th { font-size: 12.5px; font-weight: 600; color: var(--muted); background: var(--surface-2); white-space: nowrap; }
table.list th:first-child, table.list td:first-child { padding-left: 20px; }
table.list th:last-child, table.list td:last-child { padding-right: 20px; }
table.list tbody tr:last-child td { border-bottom: 0; }
table.list tbody tr:hover td { background: #fafcfd; }
table.list .num { font-variant-numeric: tabular-nums; }
.cell-title { font-weight: 600; text-decoration: none; }
.cell-title:hover { text-decoration: underline; }
.cell-actions { text-align: right; white-space: nowrap; }
.cell-actions > * { margin: 2px 0 2px 4px; }
.btn.icon-only { padding: 6px 8px; }
.person { display: flex; align-items: center; gap: 10px; }
/* Fiche descriptive (libellé / valeur) */
dl.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0; margin: 0; }
dl.facts dt, dl.facts dd { padding: 9px 0; border-bottom: 1px solid var(--line); margin: 0; }
dl.facts dt { color: var(--muted); font-size: 13px; padding-right: 20px; }
dl.facts dt:last-of-type, dl.facts dd:last-of-type { border-bottom: 0; }
.empty { text-align: center; padding: 36px 20px; color: var(--muted); }
.empty .icon { color: var(--line-strong); margin-bottom: 8px; }
.empty p { margin: 0 auto 12px; max-width: 46ch; }
.empty p:last-child { margin-bottom: 0; }
.empty strong { display: block; color: var(--ink); font-size: 16px; margin-bottom: 4px; }
/* ---------------------------------------------------------------- Formulaires */
label { display: block; font-weight: 600; font-size: 13.5px; margin: 14px 0 5px; }
.card > label:first-child, .card > h2 + label { margin-top: 0; }
.hint { color: var(--muted); font-size: 13px; margin: 5px 0 0; }
input[type=text], input[type=number], input[type=search], input[type=url], input[type=datetime-local], select, textarea {
width: 100%; padding: 8px 10px;
border: 1px solid var(--line-strong); border-radius: var(--r-s);
font: inherit; color: var(--ink); background: var(--surface);
}
input:hover, select:hover, textarea:hover { border-color: #93a5b2; }
input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--accent); box-shadow: var(--focus); }
input::placeholder { color: #8797a3; }
input.small { width: 96px; }
select { padding-right: 28px; }
textarea.code { font-family: var(--mono); font-size: 13px; line-height: 1.5; min-height: 280px; resize: vertical; tab-size: 2; background: var(--surface-2); }
fieldset { border: 1px solid var(--line); border-radius: var(--r-s); margin: 14px 0 0; padding: 8px 14px 12px; min-width: 0; }
fieldset.bare { border: 0; margin: 0; padding: 0; }
legend { font-weight: 600; font-size: 13.5px; padding: 0 6px; }
.scroll { max-height: 230px; overflow: auto; }
.field-row { display: flex; gap: 12px; flex-wrap: wrap; }
.field-row > * { flex: 1; min-width: 190px; }
/* Champ de recherche avec icône */
.search { position: relative; flex: 1; max-width: 380px; }
.search .icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--muted); }
.search input { padding-left: 34px; }
/* Cases et boutons radio */
label.check { display: flex; align-items: flex-start; gap: 8px; font-weight: 400; font-size: 14.5px; margin: 6px 0; cursor: pointer; }
label.check input { flex: none; width: 17px; height: 17px; margin: 2px 0 0; accent-color: var(--accent); }
/* Interrupteur : une case à cocher habillée */
.switch { display: inline-flex; align-items: center; gap: 10px; font-weight: 400; font-size: 14.5px; margin: 0; cursor: pointer; }
.switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.switch-track { flex: none; position: relative; width: 38px; height: 22px; border-radius: 11px; background: var(--line-strong); transition: background .15s; }
.switch-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
.switch input:checked + .switch-track { background: var(--accent); }
.switch input:checked + .switch-track::after { transform: translateX(16px); }
.switch input:focus-visible + .switch-track { box-shadow: var(--focus); }
.switch input:disabled + .switch-track { opacity: .5; }
/* Même interrupteur sous forme de bouton : il envoie son formulaire au clic (activation d'une campagne) */
button.switch-btn { position: relative; width: 38px; height: 22px; padding: 0; border: 0; border-radius: 11px; background: var(--line-strong); cursor: pointer; vertical-align: middle; transition: background .15s; }
button.switch-btn::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: transform .15s; }
button.switch-btn[aria-checked=true] { background: var(--accent); }
button.switch-btn[aria-checked=true]::after { transform: translateX(16px); }
/* Choix exclusifs présentés en liste de cartes (portail) */
.option { display: flex; align-items: flex-start; gap: 10px; padding: 9px 12px; margin: 0; border: 1px solid var(--line); border-radius: var(--r-s); font-weight: 400; font-size: 14.5px; cursor: pointer; }
.option + .option { margin-top: 6px; }
fieldset > .option:first-of-type { margin-top: 4px; }
.option input[type=radio] { flex: none; width: 17px; height: 17px; margin: 2px 0 0; accent-color: var(--accent); }
.option > span { min-width: 0; }
.option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
/* Zone de dépôt de fichier */
.dropzone {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
min-height: 112px; padding: 16px; margin: 0;
border: 1.5px dashed var(--line-strong); border-radius: var(--r-m);
background: var(--surface-2); color: var(--muted);
font-weight: 400; font-size: 14px; text-align: center; cursor: pointer;
}
.dropzone:hover, .dropzone.is-over { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
.dropzone:focus-within { border-color: var(--accent); box-shadow: var(--focus); }
.dropzone input[type=file] { position: absolute; opacity: 0; width: 1px; height: 1px; }
.dropzone-name { color: var(--ink); font-weight: 600; overflow-wrap: anywhere; }
.dropzone u { color: var(--accent); }
html:not(.js) .dropzone input[type=file] { position: static; opacity: 1; width: auto; height: auto; }
/* ---------------------------------------------------------------- Boutons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 8px 14px; border-radius: var(--r-s);
border: 1px solid var(--accent); background: var(--accent); color: #fff;
font: inherit; font-size: 14.5px; font-weight: 600; line-height: 1.2;
text-decoration: none; cursor: pointer; white-space: nowrap;
}
.btn:hover { background: var(--accent-strong); border-color: var(--accent-strong); color: #fff; }
.btn.secondary { background: var(--surface); color: var(--accent-strong); border-color: var(--line-strong); }
.btn.secondary:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn.danger { background: var(--surface); color: var(--danger); border-color: #e3b5b1; }
.btn.danger:hover { background: var(--danger-soft); border-color: var(--danger); }
.btn.danger.solid { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn.danger.solid:hover { background: #8f1d17; }
.btn.small { padding: 5px 10px; font-size: 13.5px; }
.btn:disabled, .btn[aria-disabled=true] { opacity: .5; cursor: default; pointer-events: none; }
.btn .icon { vertical-align: 0; }
.btn.is-busy .icon { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
button.link { background: none; border: 0; color: var(--accent); cursor: pointer; font: inherit; padding: 0; }
form.inline { display: inline; }
/* ---------------------------------------------------------------- Étiquettes et messages */
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; font-size: 12.5px; font-weight: 600; background: var(--accent-soft); color: var(--accent-strong); white-space: nowrap; }
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.off { background: #edf1f4; color: var(--muted); }
.badge.plain::before { display: none; }
.alert { display: flex; gap: 10px; align-items: flex-start; padding: 11px 14px; border-radius: var(--r-s); margin-bottom: 16px; border: 1px solid; }
.alert .icon { margin-top: 1px; }
.alert > div { min-width: 0; }
.notice { background: var(--warn-soft); color: var(--warn); border-color: #ecd28f; }
.errors { background: var(--danger-soft); color: var(--danger); border-color: #f0c0bb; }
.flash { background: var(--ok-soft); color: var(--ok); border-color: #b7dfc5; }
/* Notification de confirmation (message de session) */
.toasts { position: fixed; bottom: 20px; right: 20px; z-index: 40; display: flex; flex-direction: column; gap: 8px; max-width: min(460px, calc(100vw - 32px)); }
.toast {
display: flex; gap: 10px; align-items: flex-start;
background: var(--ink); color: #fff; padding: 12px 12px 12px 14px;
border-radius: var(--r-m); box-shadow: 0 10px 30px rgba(15, 53, 84, .28);
overflow-wrap: anywhere;
}
.toast > .icon { color: #7fd9a0; margin-top: 1px; }
.toast .icon-btn { width: 26px; height: 26px; margin: -3px -3px 0 auto; color: #c9d5de; }
.toast.is-leaving { opacity: 0; transform: translateY(6px); transition: opacity .2s, transform .2s; }
html:not(.js) .toasts { position: static; max-width: none; margin-bottom: 16px; }
html:not(.js) .toast .icon-btn { display: none; }
/* Boîte de confirmation */
dialog.dialog { border: 0; border-radius: var(--r-l); padding: 0; width: min(440px, calc(100vw - 32px)); color: var(--ink); box-shadow: 0 24px 60px rgba(15, 53, 84, .35); }
dialog.dialog::backdrop { background: rgba(15, 35, 50, .45); }
.dialog-body { padding: 22px 22px 6px; }
.dialog-body h2 { font-size: 18px; margin-bottom: 8px; }
.dialog-body p { color: var(--muted); margin: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 18px 22px 20px; }
/* ---------------------------------------------------------------- Écrans */
/* Variables cliquables de l'éditeur de template */
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips button { background: var(--accent-soft); color: var(--accent-strong); border: 1px solid transparent; border-radius: var(--r-s); padding: 3px 9px; font: 13px var(--mono); cursor: pointer; }
.chips button:hover { border-color: var(--accent); }
iframe.sig { display: block; width: 100%; min-height: 270px; border: 1px solid var(--line); border-radius: var(--r-s); background: #fff; resize: vertical; }
iframe.sig.short { min-height: 150px; }
/* Bibliothèque d'images */
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
.media { display: flex; flex-direction: column; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-m); overflow: hidden; }
.media-thumb {
display: flex; align-items: center; justify-content: center; height: 130px; padding: 12px;
/* damier : rend visibles les zones transparentes d'un logo */
background: #fff repeating-conic-gradient(#eef2f5 0% 25%, #fff 0% 50%) 0 0 / 16px 16px;
border-bottom: 1px solid var(--line);
}
.media-thumb img { max-width: 100%; max-height: 100%; }
.media-body { padding: 12px 14px; flex: 1; }
.media-name { font-weight: 600; overflow-wrap: anywhere; }
.media-foot { display: flex; gap: 6px; padding: 10px 14px; border-top: 1px solid var(--line); background: var(--surface-2); }
.thumb { display: block; max-width: 88px; max-height: 56px; border: 1px solid var(--line); border-radius: 4px; background: #fff; }
.preview-img { display: block; max-width: 100%; max-height: 160px; margin-top: 10px; border: 1px solid var(--line); border-radius: var(--r-s); background: #fff; }
/* Pagination */
.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
/* État de la synchronisation */
.sync { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.sync-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px var(--ok-soft); }
.sync-dot.warn { background: var(--warn); box-shadow: 0 0 0 4px var(--warn-soft); }
.sync p { margin: 0; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next) AtkinsonHyperlegibleNext-Italic[wght].ttf: Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+111
View File
@@ -0,0 +1,111 @@
/*
* Comportements communs du back-office (aucune dépendance).
* Chaque bloc s'active par un attribut data-* : sans JavaScript, les formulaires fonctionnent quand même.
*/
(() => {
'use strict';
// --- Navigation en bandeau (petits écrans) : l'entrée active reste visible ---------------------
const current = document.querySelector('.nav .nav-link.active');
if (current && current.parentElement.scrollWidth > current.parentElement.clientWidth) {
current.parentElement.scrollLeft = current.offsetLeft - 16;
}
// --- Notifications : fermeture manuelle, et automatique après quelques secondes ----------------
const dismiss = (toast) => {
toast.classList.add('is-leaving');
setTimeout(() => toast.remove(), 220);
};
document.querySelectorAll('[data-toast]').forEach((toast) => {
let timer = setTimeout(() => dismiss(toast), 9000);
toast.addEventListener('mouseenter', () => clearTimeout(timer));
toast.addEventListener('mouseleave', () => { timer = setTimeout(() => dismiss(toast), 4000); });
toast.querySelector('[data-dismiss]')?.addEventListener('click', () => { clearTimeout(timer); dismiss(toast); });
});
// --- Confirmation : <form data-confirm="Question ?" data-confirm-label="Supprimer" data-confirm-danger>
const dialog = document.getElementById('confirm-dialog');
if (dialog && typeof dialog.showModal === 'function') {
const message = dialog.querySelector('[data-message]');
const ok = dialog.querySelector('[data-ok]');
let pending = null;
document.addEventListener('submit', (e) => {
const form = e.target;
if (!(form instanceof HTMLFormElement) || !form.dataset.confirm || form.dataset.confirmed) return;
e.preventDefault();
e.stopImmediatePropagation();
pending = form;
message.textContent = form.dataset.confirm;
ok.textContent = form.dataset.confirmLabel || 'Confirmer';
ok.classList.toggle('danger', 'confirmDanger' in form.dataset);
ok.classList.toggle('solid', 'confirmDanger' in form.dataset);
dialog.returnValue = '';
dialog.showModal();
}, true);
dialog.addEventListener('close', () => {
const form = pending;
pending = null;
if (!form || dialog.returnValue !== 'ok') return;
form.dataset.confirmed = '1';
form.requestSubmit ? form.requestSubmit() : form.submit();
});
}
// --- Bouton occupé : <form data-busy="Texte pendant l'envoi…"> ----------------------------------
document.addEventListener('submit', (e) => {
const form = e.target;
if (!(form instanceof HTMLFormElement) || !form.dataset.busy || e.defaultPrevented) return;
const button = form.querySelector('button[type=submit], button:not([type])');
if (!button) return;
button.classList.add('is-busy');
button.setAttribute('aria-disabled', 'true');
const label = button.querySelector('[data-label]');
if (label) label.textContent = form.dataset.busy;
});
// --- Copie dans le presse-papiers : <button data-copy="texte"> ---------------------------------
document.addEventListener('click', async (e) => {
const button = e.target.closest('[data-copy]');
if (!button) return;
const label = button.querySelector('[data-label]') || button;
const initial = label.textContent;
try {
await navigator.clipboard.writeText(button.dataset.copy);
label.textContent = 'Copié';
} catch {
label.textContent = 'Copie impossible';
}
setTimeout(() => { label.textContent = initial; }, 1600);
});
// --- Zone de dépôt : <label class="dropzone" data-dropzone><input type="file"> … ---------------
document.querySelectorAll('[data-dropzone]').forEach((zone) => {
const input = zone.querySelector('input[type=file]');
const name = zone.querySelector('[data-name]');
const prompt = zone.querySelector('[data-prompt]');
if (!input) return;
const show = () => {
const file = input.files[0];
if (name) { name.textContent = file ? file.name : ''; name.hidden = !file; }
if (prompt) prompt.hidden = Boolean(file);
};
input.addEventListener('change', show);
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (e) => {
e.preventDefault();
zone.classList.add('is-over');
}));
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, () => zone.classList.remove('is-over')));
zone.addEventListener('drop', (e) => {
e.preventDefault();
if (input.disabled || !e.dataTransfer.files.length) return;
const transfer = new DataTransfer();
transfer.items.add(e.dataTransfer.files[0]);
input.files = transfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
});
});
})();
@@ -1,40 +1,74 @@
@extends('layouts.app')
@section('title', 'Agents — Signatures CABM')
@section('title', 'Agents — Signatures')
@section('content')
@php
$initials = fn (?string $name) => mb_strtoupper(collect(preg_split('/[\s\-]+/u', trim((string) $name)))->filter()->take(2)->map(fn ($w) => mb_substr($w, 0, 1))->join(''));
@endphp
<header class="page-head">
<div>
<h1>Agents</h1>
<p class="lead">Qui reçoit quoi : le template appliqué à chaque agent selon ses groupes. Ouvrez un agent pour voir sa signature.</p>
</div>
</header>
<form method="get" class="row" style="margin-bottom:16px">
<input type="search" name="q" value="{{ $q }}" placeholder="Nom, adresse ou service" style="max-width:360px" aria-label="Rechercher un agent">
<button class="btn" type="submit">Rechercher</button>
<span class="muted">{{ $agents->total() }} agent(s)</span>
<div class="search">
<x-icon name="search" :size="16" />
<input type="search" name="q" value="{{ $q }}" placeholder="Nom, adresse ou service" aria-label="Rechercher un agent">
</div>
<button class="btn secondary" type="submit">Rechercher</button>
<span class="muted">{{ $agents->total() }} agent{{ $agents->total() > 1 ? 's' : '' }}</span>
</form>
<div class="card">
<div class="card card--flush">
<div class="table-wrap">
<table class="list">
<thead><tr><th>Agent</th><th>Service</th><th>Groupes signature_*</th><th>Template appliqué</th></tr></thead>
<tbody>
@forelse ($agents as $a)
<tr>
<td><a href="{{ route('admin.agents.show', $a) }}">{{ $a->display_name }}</a><div class="muted">{{ $a->mail }}</div></td>
<td>
<div class="person">
<span class="avatar" aria-hidden="true">{{ $initials($a->display_name) }}</span>
<div>
<a class="cell-title" href="{{ route('admin.agents.show', $a) }}">{{ $a->display_name }}</a>
<div class="muted">{{ $a->mail }}</div>
</div>
</div>
</td>
<td>{{ $a->department }}</td>
<td class="muted">{{ $a->groups->pluck('display_name')->join(', ') ?: '—' }}</td>
<td>{{ $resolved[$a->id] ?? '— aucun —' }}</td>
<td>
@if ($resolved[$a->id] ?? null)
{{ $resolved[$a->id] }}
@else
<span class="badge warn">aucun</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="4" class="muted">Aucun agent trouvé.</td></tr>
<tr><td colspan="4">
<div class="empty">
<x-icon name="search" :size="32" />
<p><strong>Aucun agent trouvé</strong>{{ $q !== '' ? 'Essayez un autre nom, une adresse ou un service.' : 'Lancez une synchronisation de l\'annuaire depuis le tableau de bord.' }}</p>
</div>
</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($agents->hasPages())
<nav class="row" style="margin-top:12px" aria-label="Pagination">
<nav class="card-foot pager" aria-label="Pagination">
@if ($agents->onFirstPage())
<span class="muted">← Précédent</span>
<span class="btn secondary small" aria-disabled="true"><x-icon name="chevron-left" :size="15" />Précédent</span>
@else
<a href="{{ $agents->previousPageUrl() }}" rel="prev">← Précédent</a>
<a class="btn secondary small" href="{{ $agents->previousPageUrl() }}" rel="prev"><x-icon name="chevron-left" :size="15" />Précédent</a>
@endif
<span class="muted">Page {{ $agents->currentPage() }} / {{ $agents->lastPage() }}</span>
<span class="muted">Page {{ $agents->currentPage() }} sur {{ $agents->lastPage() }}</span>
@if ($agents->hasMorePages())
<a href="{{ $agents->nextPageUrl() }}" rel="next">Suivant →</a>
<a class="btn secondary small" href="{{ $agents->nextPageUrl() }}" rel="next">Suivant<x-icon name="chevron-right" :size="15" /></a>
@else
<span class="muted">Suivant →</span>
<span class="btn secondary small" aria-disabled="true">Suivant<x-icon name="chevron-right" :size="15" /></span>
@endif
</nav>
@endif
@@ -1,41 +1,51 @@
@extends('layouts.app')
@section('title', $agent->display_name.' — Signatures CABM')
@section('title', $agent->display_name.' — Signatures')
@section('content')
<div class="row" style="margin-bottom:16px">
<a href="{{ route('admin.agents.index') }}">← Agents</a>
<h1 style="margin:0 0 0 12px">{{ $agent->display_name }}</h1>
<header class="page-head">
<div>
<a class="back" href="{{ route('admin.agents.index') }}"><x-icon name="chevron-left" :size="16" />Agents</a>
<h1>{{ $agent->display_name }}</h1>
<p class="lead">{{ collect([$agent->job_title, $agent->department])->filter()->join(', ') }}</p>
</div>
</header>
<div class="split">
<div class="card">
<h2>Annuaire (Entra)</h2>
<table class="list">
<tr><th>Adresse</th><td>{{ $agent->mail }}</td></tr>
<tr><th>Fonction</th><td>{{ $agent->job_title }}</td></tr>
<tr><th>Service</th><td>{{ $agent->department }}</td></tr>
<tr><th>Téléphone</th><td>{{ $agent->business_phone }}</td></tr>
<tr><th>Mobile</th><td>{{ $agent->mobile_phone }}</td></tr>
<tr><th>Groupes signature_*</th><td>{{ $agent->groups->pluck('display_name')->join(', ') ?: '—' }}</td></tr>
<tr><th>Template appliqué</th><td>
<dl class="facts">
<dt>Adresse</dt><dd>{{ $agent->mail ?: '—' }}</dd>
<dt>Fonction</dt><dd>{{ $agent->job_title ?: '—' }}</dd>
<dt>Service</dt><dd>{{ $agent->department ?: '—' }}</dd>
<dt>Téléphone</dt><dd>{{ $agent->business_phone ?: '—' }}</dd>
<dt>Mobile</dt><dd>{{ $agent->mobile_phone ?: '—' }}</dd>
<dt>Groupes signature_*</dt><dd>{{ $agent->groups->pluck('display_name')->join(', ') ?: '—' }}</dd>
<dt>Template appliqué</dt>
<dd>
@if ($template)
<a href="{{ route('admin.templates.edit', $template) }}">{{ $template->name }}</a>
@else — aucun (pas de template par défaut publié) — @endif
</td></tr>
<tr><th>Bannière</th><td>
@else
<span class="badge warn">aucun</span> <span class="muted">pas de template par défaut publié</span>
@endif
</dd>
<dt>Bannière</dt>
<dd>
@if ($campaign)
<a href="{{ route('admin.campaigns.edit', $campaign) }}">{{ $campaign->name }}</a>
@if ($template && ! str_contains((string) $template->html_full, 'banniere'))
<span class="badge warn">non affichée : le template n'a pas de bannière</span>
@endif
@else — aucune campagne en cours pour cet agent — @endif
</td></tr>
<tr><th>Synchronisé le</th><td class="muted">{{ $agent->synced_at?->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td></tr>
</table>
@else
<span class="muted">Aucune campagne en cours pour cet agent</span>
@endif
</dd>
<dt>Synchronisé le</dt><dd class="muted">{{ $agent->synced_at?->timezone('Europe/Paris')->format('d/m/Y H:i') ?? '—' }}</dd>
</dl>
</div>
<div class="card">
<h2>Nouveau message</h2>
@include('partials.signature-frame', ['html' => $full['html'] ?? ''])
<h2 style="margin-top:16px">Réponse / transfert</h2>
@include('partials.signature-frame', ['html' => $reply['html'] ?? ''])
<h2 style="margin-top:18px">Réponse et transfert</h2>
@include('partials.signature-frame', ['html' => $reply['html'] ?? '', 'class' => 'short'])
</div>
</div>
@endsection
@@ -1,79 +1,92 @@
@extends('layouts.app')
@section('title', 'Affectations — Signatures CABM')
@section('title', 'Affectations — Signatures')
@section('content')
<header class="page-head">
<div>
<h1>Affectations</h1>
<p class="muted" style="margin-top:-8px">
<p class="lead">
Un agent membre de plusieurs groupes reçoit le template de l'affectation dont la <strong>priorité est la plus haute</strong>
(à égalité : la plus ancienne). Sans groupe affecté, il reçoit le template par défaut.
Tous les groupes AD synchronisés sont proposés, sauf les groupes de rôles du back-office.
</p>
</div>
</header>
<div class="card">
<h2>Ajouter une affectation</h2>
<form method="post" action="{{ route('admin.assignments.store') }}" class="row" style="align-items:flex-end">
<form method="post" action="{{ route('admin.assignments.store') }}" class="card">
@csrf
<div style="flex:2;min-width:220px">
<label for="directory_group_id">Groupe AD</label>
<h2>Ajouter une affectation</h2>
<div class="field-row" style="align-items:flex-end">
<div style="flex:2">
<label for="directory_group_id" style="margin-top:0">Groupe AD</label>
<select id="directory_group_id" name="directory_group_id" required>
<option value="">— choisir —</option>
<option value="">Choisir un groupe</option>
@if ($groups->isEmpty())
<option value="" disabled>Aucun groupe synchronisé : lancez php artisan signature:sync --full</option>
<option value="" disabled>Aucun groupe synchronisé : lancez une synchronisation complète</option>
@endif
@foreach ($groups as $g)
<option value="{{ $g->id }}" @selected(old('directory_group_id') == $g->id)>{{ $g->display_name }} ({{ $g->members_count }})</option>
@endforeach
</select>
</div>
<div style="flex:2;min-width:220px">
<label for="template_id">Template</label>
<div style="flex:2">
<label for="template_id" style="margin-top:0">Template</label>
<select id="template_id" name="template_id" required>
<option value="">— choisir —</option>
<option value="">Choisir un template</option>
@foreach ($templates as $t)
<option value="{{ $t->id }}" @selected(old('template_id') == $t->id)>{{ $t->name }}{{ $t->html_full ? '' : ' (non publié)' }}</option>
@endforeach
</select>
</div>
<div>
<label for="priority">Priorité</label>
<input class="small" type="number" id="priority" name="priority" min="0" max="1000" value="{{ old('priority', 10) }}" required>
<div style="flex:0 0 96px;min-width:96px">
<label for="priority" style="margin-top:0">Priorité</label>
<input type="number" id="priority" name="priority" min="0" max="1000" value="{{ old('priority', 10) }}" required>
</div>
<div style="flex:0 0 auto;min-width:0">
<button class="btn" type="submit"><x-icon name="plus" />Ajouter</button>
</div>
</div>
<button class="btn" type="submit">Ajouter</button>
</form>
</div>
<div class="card">
<h2>Affectations actives, par ordre de priorité</h2>
<div class="card card--flush">
<div class="card-head"><h2>Affectations, par ordre de priorité</h2></div>
<div class="table-wrap">
<table class="list">
<thead><tr><th>Priorité</th><th>Groupe AD</th><th>Membres</th><th>Template</th><th></th></tr></thead>
<tbody>
@forelse ($assignments as $a)
<tr>
<td>
<form class="row" method="post" action="{{ route('admin.assignments.update', $a) }}">
<form class="row" method="post" action="{{ route('admin.assignments.update', $a) }}" style="flex-wrap:nowrap">
@csrf @method('PUT')
<input class="small" type="number" name="priority" min="0" max="1000" value="{{ $a->priority }}" aria-label="Priorité">
<button class="btn secondary small" type="submit">OK</button>
<input class="small" type="number" name="priority" min="0" max="1000" value="{{ $a->priority }}" aria-label="Priorité de {{ $a->group?->display_name }}">
<button class="btn secondary small" type="submit" title="Enregistrer la priorité" aria-label="Enregistrer la priorité"><x-icon name="check" :size="15" /></button>
</form>
</td>
<td>{{ $a->group?->display_name }}</td>
<td>{{ $a->group?->members_count }}</td>
<td class="num">{{ $a->group?->members_count }}</td>
<td>
<a href="{{ route('admin.templates.edit', $a->template_id) }}">{{ $a->template?->name }}</a>
<a class="cell-title" href="{{ route('admin.templates.edit', $a->template_id) }}">{{ $a->template?->name }}</a>
@unless ($a->template?->html_full) <span class="badge warn">non publié : ignoré</span> @endunless
</td>
<td style="text-align:right">
<td class="cell-actions">
<form class="inline" method="post" action="{{ route('admin.assignments.destroy', $a) }}"
onsubmit="return confirm('Supprimer cette affectation ?')">
data-confirm="Supprimer cette affectation ? Les agents du groupe recevront le template suivant dans l'ordre de priorité, ou le template par défaut."
data-confirm-label="Supprimer" data-confirm-danger>
@csrf @method('DELETE')
<button class="btn danger small" type="submit">Supprimer</button>
<button class="btn danger small" type="submit"><x-icon name="trash-2" :size="15" />Supprimer</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="5" class="muted">Aucune affectation : tous les agents reçoivent le template par défaut.</td></tr>
<tr><td colspan="5">
<div class="empty">
<x-icon name="users" :size="32" />
<p><strong>Aucune affectation</strong>Tous les agents reçoivent le template par défaut.</p>
</div>
</td></tr>
@endforelse
</tbody>
</table>
<p class="muted">Vérifiez le résultat agent par agent dans l'onglet <a href="{{ route('admin.agents.index') }}">Agents</a>.</p>
</div>
<div class="card-foot muted">Vérifiez le résultat agent par agent dans l'écran <a href="{{ route('admin.agents.index') }}">Agents</a>.</div>
</div>
@endsection
@@ -1,5 +1,5 @@
@extends('layouts.app')
@section('title', ($campaign->exists ? $campaign->name : 'Nouvelle campagne').' — Signatures CABM')
@section('title', ($campaign->exists ? $campaign->name : 'Nouvelle campagne').' — Signatures')
@section('content')
@php
$isAdmin = auth()->user()->hasRole('admin');
@@ -11,27 +11,34 @@
$checked = collect(old('groups', $selected))->map(fn ($id) => (int) $id)->all();
$status = $campaign->exists ? $campaign->status() : null;
@endphp
<div class="row" style="margin-bottom:16px">
<a href="{{ route('admin.campaigns.index') }}">← Campagnes</a>
<h1 style="margin:0 0 0 12px">{{ $campaign->exists ? $campaign->name : 'Nouvelle campagne' }}</h1>
<header class="page-head">
<div>
<a class="back" href="{{ route('admin.campaigns.index') }}"><x-icon name="chevron-left" :size="16" />Campagnes</a>
<div class="page-title">
<h1>{{ $campaign->exists ? $campaign->name : 'Nouvelle campagne' }}</h1>
@if ($status)
<span class="badge {{ ['running' => 'ok', 'inactive' => 'warn'][$status] ?? '' }}">{{ \App\Models\Campaign::STATUSES[$status] }}</span>
<span class="badge {{ ['running' => 'ok', 'inactive' => 'warn', 'ended' => 'off'][$status] ?? '' }}">{{ \App\Models\Campaign::STATUSES[$status] }}</span>
@endif
</div>
</div>
</header>
@if ($locked)
<div class="notice" role="note">Cette campagne est active : seul un administrateur peut la modifier.</div>
<div class="alert notice" role="note">
<x-icon name="info" />
<div>Cette campagne est active : seul un administrateur peut la modifier.</div>
</div>
@endif
<form method="post" enctype="multipart/form-data"
action="{{ $campaign->exists ? route('admin.campaigns.update', $campaign) : route('admin.campaigns.store') }}">
@csrf
@if ($campaign->exists) @method('PUT') @endif
<fieldset @disabled($locked) style="border:0;margin:0;padding:0">
<fieldset class="bare" @disabled($locked)>
<div class="split">
<div class="card">
<h2>Bannière</h2>
<label for="name">Nom de la campagne</label>
<label for="name" style="margin-top:0">Nom de la campagne</label>
<input type="text" id="name" name="name" maxlength="120" required value="{{ old('name', $campaign->name) }}" placeholder="ex. Collecte des encombrants — octobre">
<label for="image_path">Image</label>
@@ -46,40 +53,45 @@
</option>
@endforeach
</select>
<label for="image_file">… ou envoyer une nouvelle image (PNG, JPEG, GIF)</label>
<input type="file" id="image_file" name="image_file" accept="image/png,image/jpeg,image/gif">
<p class="muted">Visez 600 px de large et moins de 100 Ko : les destinataires la téléchargent à chaque ouverture du message.</p>
<img id="thumb" alt="" hidden style="max-width:100%;max-height:160px;border:1px solid var(--line);background:#fff">
<label class="dropzone" data-dropzone style="margin-top:10px">
<input type="file" id="image_file" name="image_file" accept="image/png,image/jpeg,image/gif" aria-label="Nouvelle image">
<x-icon name="cloud-upload" :size="24" />
<span data-prompt>… ou glissez une nouvelle image ici, ou <u>parcourez vos fichiers</u></span>
<span class="dropzone-name" data-name hidden></span>
<span class="muted">PNG, JPEG ou GIF</span>
</label>
<p class="hint">Visez 600 px de large et moins de 100 Ko : les destinataires la téléchargent à chaque ouverture du message.</p>
<img id="thumb" class="preview-img" alt="" hidden>
<label for="alt_text">Texte alternatif</label>
<input type="text" id="alt_text" name="alt_text" maxlength="150" required value="{{ old('alt_text', $campaign->alt_text) }}" placeholder="ex. Collecte des encombrants : inscrivez-vous avant le 15 octobre">
<p class="muted">Affiché quand le destinataire bloque les images, et lu par les lecteurs d'écran. Résumez le message de la bannière.</p>
<p class="hint">Affiché quand le destinataire bloque les images, et lu par les lecteurs d'écran. Résumez le message de la bannière.</p>
<label for="link_url">Lien au clic (facultatif)</label>
<input type="url" id="link_url" name="link_url" maxlength="255" value="{{ old('link_url', $campaign->link_url) }}" placeholder="https://www.beziers-mediterranee.fr/…">
<label for="width">Largeur d'affichage (px, facultatif)</label>
<input type="number" id="width" name="width" style="width:140px" min="100" max="800" value="{{ old('width', $width) }}" placeholder="auto">
<p class="muted">Vide : largeur réelle de l'image, plafonnée à 600 px.</p>
<p class="hint">Vide : largeur réelle de l'image, plafonnée à 600 px.</p>
</div>
<div class="card">
<h2>Diffusion</h2>
<div class="row">
<div style="flex:1;min-width:200px">
<label for="starts_at">Début</label>
<div class="field-row">
<div>
<label for="starts_at" style="margin-top:0">Début</label>
<input type="datetime-local" id="starts_at" name="starts_at" required value="{{ old('starts_at', $local($campaign->starts_at)) }}">
</div>
<div style="flex:1;min-width:200px">
<label for="ends_at">Fin</label>
<div>
<label for="ends_at" style="margin-top:0">Fin</label>
<input type="datetime-local" id="ends_at" name="ends_at" required value="{{ old('ends_at', $local($campaign->ends_at)) }}">
</div>
</div>
<p class="muted">Heure de Paris. La bannière cesse d'être insérée à la fin ; les messages déjà envoyés la conservent.</p>
<p class="hint">Heure de Paris. La bannière cesse d'être insérée à la fin ; les messages déjà envoyés la conservent.</p>
<label for="priority">Priorité</label>
<input class="small" type="number" id="priority" name="priority" min="0" max="1000" required value="{{ old('priority', $campaign->priority ?? 10) }}">
<p class="muted">Si plusieurs campagnes en cours concernent le même agent, la priorité la plus haute s'affiche.</p>
<p class="hint">Si plusieurs campagnes en cours concernent le même agent, la priorité la plus haute s'affiche.</p>
<fieldset>
<legend>Destinataires</legend>
@@ -98,18 +110,19 @@
@if ($isAdmin)
<input type="hidden" name="active" value="0">
<label class="check" style="margin-top:14px">
<input type="checkbox" name="active" value="1" @checked(old('active', $campaign->active))>Campagne active
<label class="switch" style="margin-top:16px">
<input type="checkbox" name="active" value="1" @checked(old('active', $campaign->active))>
<span class="switch-track"></span><strong>Campagne active</strong>
</label>
<p class="muted">Décochée, la bannière n'est jamais insérée, même pendant la période.</p>
<p class="hint">Désactivée, la bannière n'est jamais insérée, même pendant la période.</p>
@else
<p class="muted" style="margin-top:14px">La campagne reste inactive tant qu'un administrateur ne l'a pas activée.</p>
<p class="hint" style="margin-top:14px">La campagne reste inactive tant qu'un administrateur ne l'a pas activée.</p>
@endif
</div>
</div>
<div class="row">
<button class="btn" type="submit">{{ $campaign->exists ? 'Enregistrer' : 'Créer la campagne' }}</button>
<button class="btn" type="submit"><x-icon name="save" />{{ $campaign->exists ? 'Enregistrer' : 'Créer la campagne' }}</button>
<a class="btn secondary" href="{{ route('admin.campaigns.index') }}">Annuler</a>
</div>
</fieldset>
@@ -117,9 +130,9 @@
@if ($campaign->exists)
<div class="card" style="margin-top:16px">
<h2>Rendu enregistré</h2>
<h2>Bannière enregistrée</h2>
<p class="muted">Tel qu'inséré à l'emplacement <code>{{ '{'.'{'.'{banniere}'.'}'.'}' }}</code> du template. Pour le voir dans une signature complète, ouvrez un agent dans l'onglet <a href="{{ route('admin.agents.index') }}">Agents</a>.</p>
@include('partials.signature-frame', ['html' => $bannerHtml, 'title' => 'Aperçu de la bannière'])
@include('partials.signature-frame', ['html' => $bannerHtml, 'title' => 'Aperçu de la bannière', 'class' => 'short'])
</div>
@endif
@endsection
@@ -140,7 +153,7 @@
width.placeholder = natural ? `auto (${Math.min(natural, 600)})` : 'auto';
};
select.addEventListener('change', () => { file.value = ''; fromLibrary(); });
select.addEventListener('change', () => { file.value = ''; file.dispatchEvent(new Event('change')); });
file.addEventListener('change', () => {
if (!file.files.length) return fromLibrary();
select.value = '';
@@ -1,36 +1,41 @@
@extends('layouts.app')
@section('title', 'Campagnes — Signatures CABM')
@section('title', 'Campagnes — Signatures')
@section('content')
@php
$isAdmin = auth()->user()->hasRole('admin');
$tz = config('signature.timezone');
$badge = ['running' => 'ok', 'scheduled' => '', 'inactive' => 'warn', 'ended' => ''];
$badge = ['running' => 'ok', 'scheduled' => '', 'inactive' => 'warn', 'ended' => 'off'];
$variable = '{'.'{'.'{banniere}'.'}'.'}';
$period = fn ($d) => $d->timezone($tz)->format('d/m/Y H:i');
@endphp
<div class="row" style="margin-bottom:8px">
<h1 style="margin:0">Campagnes</h1>
<span class="spacer"></span>
<a class="btn" href="{{ route('admin.campaigns.create') }}">Nouvelle campagne</a>
</div>
<p class="muted">
<header class="page-head">
<div>
<h1>Campagnes</h1>
<p class="lead">
Une campagne ajoute une bannière sous la signature pendant une période, pour tous les agents ou pour certains groupes AD.
<strong>Une seule bannière par signature</strong> : si plusieurs campagnes en cours concernent un agent, celle dont la priorité
est la plus haute l'emporte (à égalité : la plus ancienne). La bannière n'apparaît que dans la signature complète,
pas dans la signature courte des réponses, et seulement si le template contient <code>{{ $variable }}</code>.
pas dans la signature courte des réponses.
@unless ($isAdmin) Vous préparez les campagnes ; un administrateur les active. @endunless
</p>
</div>
<div class="page-actions">
<a class="btn" href="{{ route('admin.campaigns.create') }}"><x-icon name="plus" />Nouvelle campagne</a>
</div>
</header>
@if ($withoutBanner->isNotEmpty())
<div class="notice" role="note">
Ces templates publiés ne contiennent pas <code>{{ $variable }}</code> : leurs agents ne verront aucune bannière —
{{ $withoutBanner->join(', ') }}.
<div class="alert notice" role="note">
<x-icon name="triangle-alert" />
<div>Ces templates publiés ne contiennent pas <code>{{ $variable }}</code> : leurs agents ne verront aucune bannière.
<strong>{{ $withoutBanner->join(', ') }}</strong></div>
</div>
@endif
<div class="card" style="overflow-x:auto">
<div class="card card--flush">
<div class="table-wrap">
<table class="list">
<thead><tr><th style="width:130px">Bannière</th><th>Campagne</th><th>Période</th><th>Destinataires</th><th>Priorité</th><th>État</th><th></th></tr></thead>
<thead><tr><th>Bannière</th><th>Campagne</th><th>Période</th><th>Destinataires</th><th>Priorité</th><th>État</th><th class="right">{{ $isAdmin ? 'Active' : '' }}</th></tr></thead>
<tbody>
@forelse ($campaigns as $c)
@php
@@ -43,22 +48,22 @@
<span class="badge warn">image manquante</span>
<div class="muted">{{ $c->image_path }}</div>
@else
<img src="{{ asset('img/'.$c->image_path) }}" alt="{{ $c->alt_text }}" style="max-width:120px;max-height:60px;border:1px solid var(--line);background:#fff">
<img class="thumb" src="{{ asset('img/'.$c->image_path) }}" alt="{{ $c->alt_text }}" loading="lazy">
@endif
</td>
<td>
<a href="{{ route('admin.campaigns.edit', $c) }}"><strong>{{ $c->name }}</strong></a>
<a class="cell-title" href="{{ route('admin.campaigns.edit', $c) }}">{{ $c->name }}</a>
<div class="muted">
@if ($c->link_url)
Lien : <a href="{{ $c->link_url }}" target="_blank" rel="noopener">{{ \Illuminate\Support\Str::limit($c->link_url, 50) }}</a>
<a href="{{ $c->link_url }}" target="_blank" rel="noopener" title="{{ $c->link_url }}">{{ \Illuminate\Support\Str::limit(preg_replace('#^https?://(www\.)?#', '', $c->link_url), 38) }}</a>
@else
Sans lien
@endif
</div>
</td>
<td style="white-space:nowrap">{{ $period($c->starts_at) }}<br>→ {{ $period($c->ends_at) }}</td>
<td style="overflow-wrap:anywhere">{{ $c->groups->isEmpty() ? 'Tous les agents' : $c->groups->pluck('display_name')->join(', ') }}</td>
<td>{{ $c->priority }}</td>
<td class="nowrap">{{ $period($c->starts_at) }}<br><span class="muted">au</span> {{ $period($c->ends_at) }}</td>
<td style="min-width:130px;overflow-wrap:anywhere">{{ $c->groups->isEmpty() ? 'Tous les agents' : $c->groups->pluck('display_name')->join(', ') }}</td>
<td class="num">{{ $c->priority }}</td>
<td>
<span class="badge {{ $badge[$status] }}">{{ \App\Models\Campaign::STATUSES[$status] }}</span>
@if ($status === 'running')
@@ -73,28 +78,40 @@
<div class="muted">{{ $isAdmin ? 'à activer' : 'en attente d\'un administrateur' }}</div>
@endif
</td>
<td style="text-align:right">
<a class="btn secondary small" style="margin:2px 0" href="{{ route('admin.campaigns.edit', $c) }}">{{ $locked ? 'Voir' : 'Modifier' }}</a>
<td class="cell-actions">
<a class="btn secondary small icon-only" href="{{ route('admin.campaigns.edit', $c) }}"
title="{{ $locked ? 'Voir' : 'Modifier' }}" aria-label="{{ $locked ? 'Voir' : 'Modifier' }} {{ $c->name }}">
<x-icon :name="$locked ? 'eye' : 'pencil'" :size="15" />
</a>
@if ($isAdmin)
@if ($status !== 'ended')
<form class="inline" method="post" action="{{ route('admin.campaigns.toggle', $c) }}">
@csrf
<button class="btn secondary small" style="margin:2px 0" type="submit">{{ $c->active ? 'Désactiver' : 'Activer' }}</button>
<button class="switch-btn" type="submit" role="switch" aria-checked="{{ $c->active ? 'true' : 'false' }}"
title="{{ $c->active ? 'Désactiver' : 'Activer' }}" aria-label="Campagne {{ $c->name }} active"></button>
</form>
@endif
<form class="inline" method="post" action="{{ route('admin.campaigns.destroy', $c) }}"
onsubmit="return confirm('Supprimer cette campagne ? Les messages déjà envoyés conservent la bannière.')">
data-confirm="Supprimer la campagne « {{ $c->name }} » ? Les messages déjà envoyés conservent la bannière."
data-confirm-label="Supprimer" data-confirm-danger>
@csrf @method('DELETE')
<button class="btn danger small" style="margin:2px 0" type="submit">Supprimer</button>
<button class="btn danger small icon-only" type="submit" title="Supprimer" aria-label="Supprimer {{ $c->name }}"><x-icon name="trash-2" :size="15" /></button>
</form>
@endif
</td>
</tr>
@empty
<tr><td colspan="7" class="muted">Aucune campagne. Les signatures sont envoyées sans bannière.</td></tr>
<tr><td colspan="7">
<div class="empty">
<x-icon name="megaphone" :size="32" />
<p><strong>Aucune campagne</strong>Les signatures partent sans bannière. Créez une campagne pour en afficher une pendant une période donnée.</p>
<p><a class="btn" href="{{ route('admin.campaigns.create') }}"><x-icon name="plus" />Nouvelle campagne</a></p>
</div>
</td></tr>
@endforelse
</tbody>
</table>
<p class="muted">Vérifiez la bannière reçue par un agent précis dans l'onglet <a href="{{ route('admin.agents.index') }}">Agents</a>.</p>
</div>
<div class="card-foot muted">Vérifiez la bannière reçue par un agent précis dans l'écran <a href="{{ route('admin.agents.index') }}">Agents</a>.</div>
</div>
@endsection
@@ -1,55 +1,85 @@
@extends('layouts.app')
@section('title', 'Back-office — Signatures CABM')
@section('title', 'Tableau de bord — Signatures')
@section('content')
<div class="card">
<h1>{{ $tenant->name }}</h1>
<div class="row" style="margin-bottom:12px">
<p class="muted" style="margin:0">
Dernière synchro annuaire :
{{ $tenant->last_sync_at?->timezone('Europe/Paris')->format('d/m/Y H:i') ?? 'jamais' }}
— {{ $tenant->last_sync_status ?? 'n/a' }}
<br>Automatique toutes les 15 minutes (incrémentale) et chaque nuit (complète).
@php
$isAdmin = auth()->user()->hasRole('admin');
$syncOk = $tenant->last_sync_status === 'ok';
@endphp
<header class="page-head">
<div>
<h1>Tableau de bord</h1>
<p class="lead">{{ $tenant->name }}</p>
</div>
</header>
<div class="card sync">
<span @class(['sync-dot', 'warn' => ! $syncOk]) aria-hidden="true"></span>
<div>
<p><strong>Annuaire synchronisé
{{ $tenant->last_sync_at ? 'le '.$tenant->last_sync_at->timezone('Europe/Paris')->format('d/m/Y à H:i') : ': jamais' }}</strong>
@if ($tenant->last_sync_at && ! $syncOk) <span class="badge warn">{{ $tenant->last_sync_status ?? 'état inconnu' }}</span> @endif
</p>
<p class="muted">Automatique toutes les 15 minutes (incrémentale) et chaque nuit (complète).</p>
</div>
<span class="spacer"></span>
@if (auth()->user()->hasRole('admin'))
<form method="post" action="{{ route('admin.sync') }}" class="row" id="sync-form">
@if ($isAdmin)
<form method="post" action="{{ route('admin.sync') }}" class="row" data-busy="Synchronisation en cours…">
@csrf
<label class="muted" style="margin:0;font-weight:400">
<input type="checkbox" name="full" value="1"> complète
<label class="switch">
<input type="checkbox" name="full" value="1"><span class="switch-track"></span>Synchronisation complète
</label>
<button class="btn" type="submit" id="sync-btn">Synchroniser maintenant</button>
<button class="btn" type="submit"><x-icon name="refresh-cw" /><span data-label>Synchroniser maintenant</span></button>
</form>
@endif
</div>
<div class="grid">
<div><div class="stat">{{ $users }}</div><div class="muted">agents synchronisés</div></div>
<div><div class="stat">{{ $groups->count() }}</div><div class="muted">groupes signature_*</div></div>
<div><div class="stat"><a href="{{ route('admin.templates.index') }}">{{ $templates }}</a></div><div class="muted">templates</div></div>
<div><div class="stat">{{ $assignments }}</div><div class="muted">affectations</div></div>
<div><div class="stat"><a href="{{ route('admin.campaigns.index') }}">{{ $campaigns }}</a></div><div class="muted">campagnes en cours</div></div>
<div class="tiles">
<a class="tile" href="{{ route('admin.agents.index') }}">
<span class="tile-icon"><x-icon name="contact" :size="20" /></span>
<span><span class="tile-value">{{ $users }}</span><span class="tile-label">agents synchronisés</span></span>
</a>
<a class="tile" href="{{ route('admin.templates.index') }}">
<span class="tile-icon"><x-icon name="file-code" :size="20" /></span>
<span><span class="tile-value">{{ $templates }}</span><span class="tile-label">templates</span></span>
</a>
@if ($isAdmin)
<a class="tile" href="{{ route('admin.assignments.index') }}">
<span class="tile-icon"><x-icon name="users" :size="20" /></span>
<span><span class="tile-value">{{ $assignments }}</span><span class="tile-label">affectations</span></span>
</a>
@else
<div class="tile">
<span class="tile-icon"><x-icon name="users" :size="20" /></span>
<span><span class="tile-value">{{ $assignments }}</span><span class="tile-label">affectations</span></span>
</div>
@endif
<a class="tile" href="{{ route('admin.campaigns.index') }}">
<span class="tile-icon"><x-icon name="megaphone" :size="20" /></span>
<span><span class="tile-value">{{ $campaigns }}</span><span class="tile-label">campagnes en cours</span></span>
</a>
</div>
<div class="card">
<h2>Groupes synchronisés</h2>
<div class="card card--flush">
<div class="card-head">
<h2>Groupes AD synchronisés</h2>
<span class="badge plain">{{ $groups->count() }}</span>
</div>
<div class="table-wrap">
<table class="list">
<thead><tr><th>Groupe</th><th>Membres (transitifs)</th></tr></thead>
<thead><tr><th>Groupe</th><th class="right">Membres (imbriqués inclus)</th></tr></thead>
<tbody>
@forelse ($groups as $g)
<tr><td>{{ $g->display_name }}</td><td>{{ $g->members_count }}</td></tr>
<tr><td>{{ $g->display_name }}</td><td class="right num">{{ $g->members_count }}</td></tr>
@empty
<tr><td colspan="2" class="muted">Aucun groupe : lancez « php artisan signature:sync --full ».</td></tr>
<tr><td colspan="2">
<div class="empty">
<x-icon name="users" :size="32" />
<p><strong>Aucun groupe synchronisé</strong>Cochez « complète » puis lancez une synchronisation pour importer les groupes signature_* de l'annuaire.</p>
</div>
</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('sync-form')?.addEventListener('submit', () => {
const b = document.getElementById('sync-btn');
b.disabled = true;
b.textContent = 'Synchronisation en cours…';
});
</script>
@endpush
@@ -1,39 +1,49 @@
@extends('layouts.app')
@section('title', 'Champs agents — Signatures CABM')
@section('title', 'Champs agents — Signatures')
@section('content')
<header class="page-head">
<div>
<h1>Champs modifiables par les agents</h1>
<p class="muted" style="margin-top:-8px">
<p class="lead">
Les agents modifient ces champs dans l'add-in (bouton « Ma signature ») ou sur le portail.
La valeur saisie remplace celle de l'annuaire <strong>dans la signature uniquement</strong> : l'AD reste la référence.
Publication immédiate, chaque modification est tracée. Fermer un champ fait revenir les signatures concernées à la valeur de l'annuaire.
</p>
<form method="post" action="{{ route('admin.fields.update') }}" class="card">
</div>
</header>
<form method="post" action="{{ route('admin.fields.update') }}" class="card card--flush">
@csrf @method('PUT')
<div class="table-wrap">
<table class="list">
<thead><tr><th>Ouvert</th><th>Variable</th><th>Libellé affiché à l'agent</th><th>Contrôle</th><th>Agents ayant une valeur perso</th></tr></thead>
<thead><tr><th>Ouvert aux agents</th><th>Variable</th><th>Libellé affiché à l'agent</th><th>Contrôle de saisie</th><th class="right">Valeurs personnalisées</th></tr></thead>
<tbody>
@foreach ($fields as $f)
<tr>
<td>
<input type="hidden" name="fields[{{ $f->field }}][enabled]" value="0">
<label class="switch">
<input type="checkbox" name="fields[{{ $f->field }}][enabled]" value="1" @checked($f->enabled) aria-label="Ouvrir {{ $f->label }}">
<span class="switch-track"></span>
</label>
</td>
<td><code>{{ $f->field }}</code></td>
<td><input type="text" name="fields[{{ $f->field }}][label]" value="{{ $f->label }}" maxlength="60" required></td>
<td><input type="text" name="fields[{{ $f->field }}][label]" value="{{ $f->label }}" maxlength="60" required aria-label="Libellé de {{ $f->field }}"></td>
<td>
<select name="fields[{{ $f->field }}][type]">
<select name="fields[{{ $f->field }}][type]" aria-label="Contrôle de {{ $f->field }}">
@foreach ($types as $key => $type)
<option value="{{ $key }}" @selected(($f->validation_rule ?: 'text') === $key)>{{ $type['label'] }}</option>
@endforeach
</select>
</td>
<td class="muted">{{ $usage[$f->field] ?? 0 }}</td>
<td class="right num muted">{{ $usage[$f->field] ?? 0 }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="row" style="margin-top:12px">
<button class="btn" type="submit">Enregistrer</button>
</div>
<div class="card-foot row">
<button class="btn" type="submit"><x-icon name="save" />Enregistrer</button>
<span class="muted">L'adresse e-mail n'est jamais modifiable.</span>
</div>
</form>
@@ -1,79 +1,90 @@
@extends('layouts.app')
@section('title', 'Images — Signatures CABM')
@section('title', 'Images — Signatures')
@section('content')
@php
$tag = fn (string $inner) => '{'.'{'.$inner.'}'.'}';
$isAdmin = auth()->user()->hasRole('admin');
@endphp
<header class="page-head">
<div>
<h1>Images</h1>
<p class="muted" style="margin-top:-8px">
Logos et bannières utilisés dans les signatures, publiés sur <code>{{ config('signature.public_url') }}/img/</code>.
Les destinataires les chargent depuis ce serveur : visez moins de 100 Ko et la largeur réelle d'affichage (ex. 120 px pour un logo, 600 px pour une bannière).
<p class="lead">
Logos et bannières des signatures, publiés sur <code>{{ config('signature.public_url') }}/img/</code>.
Les destinataires les téléchargent depuis ce serveur : visez moins de 100 Ko et la largeur réelle d'affichage
(120 px pour un logo, 600 px pour une bannière).
</p>
</div>
</header>
<div class="card">
<h2>Ajouter une image</h2>
<form method="post" action="{{ route('admin.images.store') }}" enctype="multipart/form-data" class="row" style="align-items:flex-end">
<form method="post" action="{{ route('admin.images.store') }}" enctype="multipart/form-data" class="card" data-busy="Envoi…">
@csrf
<div style="flex:2;min-width:240px">
<label for="image">Fichier (PNG, JPEG, GIF — {{ (int) ($maxKb / 1024) }} Mo max.)</label>
<input type="file" id="image" name="image" accept="image/png,image/jpeg,image/gif" required>
<h2>Ajouter une image</h2>
<div class="field-row" style="align-items:flex-end">
<div style="flex:2">
<label class="dropzone" data-dropzone>
<input type="file" id="image" name="image" accept="image/png,image/jpeg,image/gif" required aria-label="Fichier image">
<x-icon name="cloud-upload" :size="26" />
<span data-prompt>Glissez une image ici ou <u>parcourez vos fichiers</u></span>
<span class="dropzone-name" data-name hidden></span>
<span class="muted">PNG, JPEG ou GIF, {{ (int) ($maxKb / 1024) }} Mo maximum</span>
</label>
</div>
<div style="flex:1;min-width:200px">
<label for="name">Nom (facultatif)</label>
<div>
<label for="name">Nom du fichier (facultatif)</label>
<input type="text" id="name" name="name" maxlength="80" placeholder="ex. logo-cabm">
<p class="hint">Sans nom, celui du fichier envoyé est repris.</p>
<button class="btn" type="submit" style="margin-top:12px"><x-icon name="cloud-upload" /><span data-label>Envoyer</span></button>
</div>
</div>
<button class="btn" type="submit">Envoyer</button>
</form>
<div class="card-head">
<h2>Images en ligne</h2>
<span class="badge plain">{{ $images->count() }}</span>
</div>
<div class="card">
<h2>Images en ligne ({{ $images->count() }})</h2>
<table class="list">
<thead><tr><th style="width:150px">Aperçu</th><th>Fichier</th><th>À coller dans un template</th><th>Utilisée par</th><th></th></tr></thead>
<tbody>
@forelse ($images as $img)
@php $snippet = '<img src="'.$tag('site_url').'/img/'.$img['name'].'" alt="" width="'.($img['width'] ?? '').'" style="display:block;border:0">'; @endphp
<tr>
<td><img src="{{ asset('img/'.$img['name']) }}" alt="" style="max-width:140px;max-height:70px;border:1px solid var(--line);background:#fff"></td>
<td>
<a href="{{ $img['url'] }}" target="_blank" rel="noopener">{{ $img['name'] }}</a>
<div class="muted">{{ $img['width'] }}×{{ $img['height'] }} px · {{ $img['kb'] }} Ko
@if ($img['kb'] > 150) <span class="badge warn">lourde</span> @endif</div>
</td>
<td>
<div class="row">
<code style="font-size:12px;word-break:break-all">{{ $snippet }}</code>
<button type="button" class="btn secondary small" data-copy="{{ $snippet }}">Copier</button>
@if ($images->isEmpty())
<div class="card empty">
<x-icon name="images" :size="32" />
<p><strong>Aucune image</strong>Commencez par le logo : le template fourni par défaut attend un fichier nommé logo-cabm.png.</p>
</div>
</td>
<td class="muted">{{ $img['used_in'] ? implode(', ', $img['used_in']) : '—' }}</td>
<td style="text-align:right">
@else
<div class="gallery">
@foreach ($images as $img)
@php $snippet = '<img src="'.$tag('site_url').'/img/'.$img['name'].'" alt="" width="'.($img['width'] ?? '').'" style="display:block;border:0">'; @endphp
<article class="media">
<a class="media-thumb" href="{{ $img['url'] }}" target="_blank" rel="noopener" title="Ouvrir l'image publique">
<img src="{{ asset('img/'.$img['name']) }}" alt="" loading="lazy">
</a>
<div class="media-body">
<div class="media-name">{{ $img['name'] }}</div>
<div class="muted">
{{ $img['width'] }}×{{ $img['height'] }} px, {{ $img['kb'] }} Ko
@if ($img['kb'] > 150) <span class="badge warn">lourde</span> @endif
</div>
<div class="muted" style="margin-top:6px">
{{ $img['used_in'] ? 'Utilisée par : '.implode(', ', $img['used_in']) : 'Non utilisée' }}
</div>
</div>
<div class="media-foot">
<button type="button" class="btn secondary small" data-copy="{{ $snippet }}" title="{{ $snippet }}">
<x-icon name="copy" :size="15" /><span data-label>Copier la balise</span>
</button>
<span class="spacer"></span>
@if ($isAdmin)
<form class="inline" method="post" action="{{ route('admin.images.destroy', $img['name']) }}"
onsubmit="return confirm('Supprimer {{ $img['name'] }} ? Les signatures déjà envoyées qui l\'affichent ne la montreront plus.')">
data-confirm="Supprimer {{ $img['name'] }} ? Les messages déjà envoyés qui l'affichent ne la montreront plus."
data-confirm-label="Supprimer" data-confirm-danger>
@csrf @method('DELETE')
<button class="btn danger small" type="submit" @disabled($img['used_in'])>Supprimer</button>
<button class="btn danger small icon-only" type="submit" @disabled($img['used_in'])
title="{{ $img['used_in'] ? 'Retirez-la d\'abord des templates et campagnes' : 'Supprimer' }}" aria-label="Supprimer {{ $img['name'] }}">
<x-icon name="trash-2" :size="15" />
</button>
</form>
@endif
</td>
</tr>
@empty
<tr><td colspan="5" class="muted">Aucune image. Commencez par le logo (ex. logo-cabm.png, utilisé par le template par défaut).</td></tr>
@endforelse
</tbody>
</table>
</div>
</article>
@endforeach
</div>
@endif
@endsection
@push('scripts')
<script>
document.addEventListener('click', async (e) => {
const text = e.target.dataset && e.target.dataset.copy;
if (!text) return;
try { await navigator.clipboard.writeText(text); e.target.textContent = 'Copié'; }
catch { e.target.textContent = 'Copie impossible'; }
setTimeout(() => { e.target.textContent = 'Copier'; }, 1500);
});
</script>
@endpush
@@ -1,5 +1,5 @@
@extends('layouts.app')
@section('title', ($template->exists ? $template->name : 'Nouveau template').' — Signatures CABM')
@section('title', ($template->exists ? $template->name : 'Nouveau template').' — Signatures')
@section('content')
@php
$isAdmin = auth()->user()->hasRole('admin');
@@ -15,28 +15,33 @@
];
@endphp
<div class="row" style="margin-bottom:16px">
<a href="{{ route('admin.templates.index') }}">← Templates</a>
<h1 style="margin:0 0 0 12px">{{ $template->exists ? $template->name : 'Nouveau template' }}</h1>
@if ($template->is_default) <span class="badge">par défaut</span> @endif
<header class="page-head">
<div>
<a class="back" href="{{ route('admin.templates.index') }}"><x-icon name="chevron-left" :size="16" />Templates</a>
<div class="page-title">
<h1>{{ $template->exists ? $template->name : 'Nouveau template' }}</h1>
@if ($template->is_default) <span class="badge plain">par défaut</span> @endif
@if ($template->exists)
@if (! $template->published_version)
<span class="badge warn">jamais publié</span>
@elseif ($dirty)
<span class="badge warn">brouillon modifié depuis v{{ $template->published_version }}</span>
@else
<span class="badge ok">publié · v{{ $template->published_version }}</span>
<span class="badge ok">publié, v{{ $template->published_version }}</span>
@endif
@endif
<span class="spacer"></span>
@if ($template->exists && $isAdmin)
<form class="inline" method="post" action="{{ route('admin.templates.publish', $template) }}"
onsubmit="return confirm('Publier le brouillon enregistré ? Il sera servi immédiatement aux agents concernés.')">
@csrf
<button class="btn" type="submit" @disabled(! $dirty && $template->published_version)>Publier</button>
</form>
@endif
</div>
</div>
@if ($template->exists && $isAdmin)
<div class="page-actions">
<form class="inline" method="post" action="{{ route('admin.templates.publish', $template) }}"
data-confirm="Publier le brouillon enregistré ? Il sera servi immédiatement aux agents concernés." data-confirm-label="Publier">
@csrf
<button class="btn" type="submit" @disabled(! $dirty && $template->published_version)><x-icon name="send" />Publier</button>
</form>
</div>
@endif
</header>
<form method="post" id="template-form"
action="{{ $template->exists ? route('admin.templates.update', $template) : route('admin.templates.store') }}">
@@ -57,16 +62,16 @@
<label for="draft_html_short">Signature courte (réponses et transferts)</label>
<textarea class="code" id="draft_html_short" name="draft_html_short" data-variant="short" spellcheck="false" style="min-height:120px">{{ old('draft_html_short', $template->draft_html_short) }}</textarea>
<div class="row" style="margin-top:12px">
<button class="btn" type="submit">Enregistrer le brouillon</button>
<span class="muted">L'enregistrement ne change rien pour les agents tant que le template n'est pas publié.</span>
<div class="row" style="margin-top:14px">
<button class="btn" type="submit"><x-icon name="save" />Enregistrer le brouillon</button>
<span class="muted">Rien ne change pour les agents tant que le template n'est pas publié.</span>
</div>
</div>
<div>
<div class="card">
<div class="row">
<h2 style="margin:0">Aperçu</h2>
<div class="card-head">
<h2>Aperçu</h2>
<span class="spacer"></span>
<select id="preview-variant" aria-label="Variante affichée" style="width:auto">
<option value="full">Complète</option>
@@ -95,7 +100,7 @@
<button type="button" data-insert="{{ $snippets['site_url'] }}" title="URL publique (images)">site_url</button>
<button type="button" data-insert="{{ $snippets['if'] }}" title="Ligne conditionnelle">#if …</button>
</div>
<p class="muted">Images : déposez-les dans <code>public/img</code> et référencez
<p class="muted">Images : envoyez-les depuis l'écran <a href="{{ route('admin.images.index') }}">Images</a> et référencez
<code>{{ $snippets['img'] }}</code>. Pas de script ni de formulaire : ils sont retirés à l'enregistrement.</p>
</div>
</div>
@@ -103,21 +108,22 @@
</form>
@if ($template->exists)
<div class="card">
<h2>Historique des publications</h2>
<div class="card card--flush">
<div class="card-head"><h2>Historique des publications</h2></div>
<div class="table-wrap">
<table class="list">
<thead><tr><th>Version</th><th>Publiée le</th><th>Par</th><th></th></tr></thead>
<tbody>
@forelse ($versions as $v)
<tr>
<td>v{{ $v->version }} @if ($v->version === $template->published_version) <span class="badge ok">en ligne</span> @endif</td>
<td>{{ $v->created_at->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td>
<td class="num">v{{ $v->version }} @if ($v->version === $template->published_version) <span class="badge ok">en ligne</span> @endif</td>
<td class="nowrap">{{ $v->created_at->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td>
<td>{{ $v->publisher?->name ?? '—' }}</td>
<td style="text-align:right">
<td class="cell-actions">
<form class="inline" method="post" action="{{ route('admin.templates.restore', [$template, $v]) }}"
onsubmit="return confirm('Remplacer le brouillon actuel par la version {{ $v->version }} ?')">
data-confirm="Remplacer le brouillon actuel par la version {{ $v->version }} ?" data-confirm-label="Recharger">
@csrf
<button class="btn secondary small" type="submit">Recharger dans le brouillon</button>
<button class="btn secondary small" type="submit"><x-icon name="rotate-ccw" :size="15" />Recharger dans le brouillon</button>
</form>
</td>
</tr>
@@ -127,19 +133,20 @@
</tbody>
</table>
</div>
</div>
@if ($isAdmin)
<div class="card row">
@unless ($template->is_default)
<form class="inline" method="post" action="{{ route('admin.templates.default', $template) }}">
@csrf
<button class="btn secondary" type="submit" @disabled(! $template->html_full)>Définir comme template par défaut</button>
<button class="btn secondary" type="submit" @disabled(! $template->html_full)><x-icon name="star" />Définir comme template par défaut</button>
</form>
<span class="spacer"></span>
<form class="inline" method="post" action="{{ route('admin.templates.destroy', $template) }}"
onsubmit="return confirm('Supprimer ce template et ses affectations ?')">
data-confirm="Supprimer ce template et ses affectations ?" data-confirm-label="Supprimer" data-confirm-danger>
@csrf @method('DELETE')
<button class="btn danger" type="submit">Supprimer</button>
<button class="btn danger" type="submit"><x-icon name="trash-2" />Supprimer</button>
</form>
@else
<span class="muted">Le template par défaut s'applique aux agents qui ne sont dans aucun groupe affecté. Il ne peut pas être supprimé.</span>
@@ -1,20 +1,26 @@
@extends('layouts.app')
@section('title', 'Templates — Signatures CABM')
@section('title', 'Templates — Signatures')
@section('content')
<div class="row" style="margin-bottom:16px">
<h1 style="margin:0">Templates</h1>
<span class="spacer"></span>
<a class="btn" href="{{ route('admin.templates.create') }}">Nouveau template</a>
<header class="page-head">
<div>
<h1>Templates</h1>
<p class="lead">Un template est le modèle HTML d'une signature. Il est servi aux agents une fois publié, selon les affectations par groupe AD.</p>
</div>
<div class="card">
<div class="page-actions">
<a class="btn" href="{{ route('admin.templates.create') }}"><x-icon name="plus" />Nouveau template</a>
</div>
</header>
<div class="card card--flush">
<div class="table-wrap">
<table class="list">
<thead><tr><th>Nom</th><th>État</th><th>Version publiée</th><th>Groupes affectés</th><th>Mis à jour</th></tr></thead>
<tbody>
@forelse ($templates as $t)
<tr>
<td>
<a href="{{ route('admin.templates.edit', $t) }}">{{ $t->name }}</a>
@if ($t->is_default) <span class="badge">par défaut</span> @endif
<a class="cell-title" href="{{ route('admin.templates.edit', $t) }}">{{ $t->name }}</a>
@if ($t->is_default) <span class="badge plain">par défaut</span> @endif
@if ($t->description)<div class="muted">{{ $t->description }}</div>@endif
</td>
<td>
@@ -26,14 +32,21 @@
<span class="badge ok">publié</span>
@endif
</td>
<td>{{ $t->published_version ? 'v'.$t->published_version : '—' }}</td>
<td>{{ $t->assignments_count }}</td>
<td class="muted">{{ $t->updated_at?->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td>
<td class="num">{{ $t->published_version ? 'v'.$t->published_version : '—' }}</td>
<td class="num">{{ $t->assignments_count }}</td>
<td class="muted nowrap">{{ $t->updated_at?->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td>
</tr>
@empty
<tr><td colspan="5" class="muted">Aucun template.</td></tr>
<tr><td colspan="5">
<div class="empty">
<x-icon name="file-code" :size="32" />
<p><strong>Aucun template</strong>Créez le premier modèle de signature, puis publiez-le pour qu'il soit servi aux agents.</p>
<p><a class="btn" href="{{ route('admin.templates.create') }}"><x-icon name="plus" />Nouveau template</a></p>
</div>
</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection
@@ -0,0 +1,42 @@
{{-- Icônes Lucide (licence ISC), intégrées pour ne dépendre d'aucun CDN. Usage : <x-icon name="plus" /> --}}
@props(['name', 'size' => 18])
@php
$icons = [
'layout-dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/> <rect width="7" height="5" x="14" y="3" rx="1"/> <rect width="7" height="9" x="14" y="12" rx="1"/> <rect width="7" height="5" x="3" y="16" rx="1"/>',
'file-code' => '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/> <path d="M14 2v5a1 1 0 0 0 1 1h5"/> <path d="M10 12.5 8 15l2 2.5"/> <path d="m14 12.5 2 2.5-2 2.5"/>',
'images' => '<path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16"/> <path d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2"/> <circle cx="13" cy="7" r="1" fill="currentColor"/> <rect x="8" y="2" width="14" height="14" rx="2"/>',
'megaphone' => '<path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/> <path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14"/> <path d="M8 6v8"/>',
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/> <path d="M16 3.128a4 4 0 0 1 0 7.744"/> <path d="M22 21v-2a4 4 0 0 0-3-3.87"/> <circle cx="9" cy="7" r="4"/>',
'sliders-horizontal' => '<path d="M10 5H3"/> <path d="M12 19H3"/> <path d="M14 3v4"/> <path d="M16 17v4"/> <path d="M21 12h-9"/> <path d="M21 19h-5"/> <path d="M21 5h-7"/> <path d="M8 10v4"/> <path d="M8 12H3"/>',
'contact' => '<path d="M16 2v2"/> <path d="M7 21v-2a2 2 0 012-2h6a2 2 0 012 2v2"/> <path d="M8 2v2"/> <circle cx="12" cy="10" r="3"/> <rect x="3" y="3" width="18" height="18" rx="2"/>',
'signature' => '<path d="m21 17-2.156-1.868A.5.5 0 0 0 18 15.5v.5a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1c0-2.545-3.991-3.97-8.5-4a1 1 0 0 0 0 5c4.153 0 4.745-11.295 5.708-13.5a2.5 2.5 0 1 1 3.31 3.284"/> <path d="M3 21h18"/>',
'log-out' => '<path d="m16 17 5-5-5-5"/> <path d="M21 12H9"/> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',
'plus' => '<path d="M5 12h14"/> <path d="M12 5v14"/>',
'pencil' => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/> <path d="m15 5 4 4"/>',
'trash-2' => '<path d="M10 11v6"/> <path d="M14 11v6"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/> <path d="M3 6h18"/> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'copy' => '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
'check' => '<path d="M20 6 9 17l-5-5"/>',
'refresh-cw' => '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/> <path d="M21 3v5h-5"/> <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/> <path d="M8 16H3v5"/>',
'cloud-upload' => '<path d="M12 13v8"/> <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/> <path d="m8 17 4-4 4 4"/>',
'search' => '<path d="m21 21-4.34-4.34"/> <circle cx="11" cy="11" r="8"/>',
'chevron-left' => '<path d="m15 18-6-6 6-6"/>',
'chevron-right' => '<path d="m9 18 6-6-6-6"/>',
'triangle-alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/> <path d="M12 9v4"/> <path d="M12 17h.01"/>',
'info' => '<circle cx="12" cy="12" r="10"/> <path d="M12 16v-4"/> <path d="M12 8h.01"/>',
'circle-check' => '<circle cx="12" cy="12" r="10"/> <path d="m16 9-5.5 5.5L8 12"/>',
'x' => '<path d="M18 6 6 18"/> <path d="m6 6 12 12"/>',
'eye' => '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/> <circle cx="12" cy="12" r="3"/>',
'power' => '<path d="M12 2v10"/> <path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>',
'calendar-clock' => '<path d="M16 14v2.2l1.6 1"/> <path d="M16 2v3"/> <path d="M21 7.338V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h2.338"/> <path d="M3 9h5.859"/> <path d="M8 2v3"/> <circle cx="16" cy="16" r="6"/>',
'link' => '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'external-link' => '<path d="M15 3h6v6"/> <path d="M10 14 21 3"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
'history' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/>',
'star' => '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
'rotate-ccw' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/>',
'send' => '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/> <path d="m21.854 2.147-10.94 10.939"/>',
'save' => '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/> <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/> <path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
'building-2' => '<path d="M10 12h4"/> <path d="M10 8h4"/> <path d="M14 21v-3a2 2 0 0 0-4 0v3"/> <path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2"/> <path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/>',
'mail' => '<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/> <rect x="2" y="4" width="20" height="16" rx="2"/>',
];
@endphp
<svg {{ $attributes->merge(['class' => 'icon']) }} xmlns="http://www.w3.org/2000/svg" width="{{ $size }}" height="{{ $size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">{!! $icons[$name] ?? '' !!}</svg>
+107 -71
View File
@@ -1,93 +1,129 @@
@php
$user = auth()->user();
$hasNav = (bool) $user?->role;
$isAdmin = $hasNav && $user->hasRole('admin');
$org = $user?->tenant?->name ?? config('signature.default_tenant.name');
$roleLabel = ['superadmin' => 'Super-administrateur', 'admin' => 'Administrateur', 'editor' => 'Éditeur'][$user?->role] ?? 'Agent';
$initials = $user ? mb_strtoupper(collect(preg_split('/[\s\-]+/u', trim($user->name)))->filter()->take(2)->map(fn ($w) => mb_substr($w, 0, 1))->join('')) : '';
// Version des fichiers statiques = date de modification : le navigateur recharge après chaque mise à jour.
$asset = fn (string $path) => asset($path).'?v='.(@filemtime(public_path($path)) ?: '1');
$nav = fn (string $route, string $pattern, string $icon, string $label) => compact('route', 'pattern', 'icon', 'label');
$links = $hasNav ? array_filter([
['group' => null, 'items' => [$nav('admin.dashboard', 'admin.dashboard', 'layout-dashboard', 'Tableau de bord')]],
['group' => 'Contenu', 'items' => [
$nav('admin.templates.index', 'admin.templates.*', 'file-code', 'Templates'),
$nav('admin.images.index', 'admin.images.*', 'images', 'Images'),
$nav('admin.campaigns.index', 'admin.campaigns.*', 'megaphone', 'Campagnes'),
]],
['group' => 'Diffusion', 'items' => array_values(array_filter([
$isAdmin ? $nav('admin.assignments.index', 'admin.assignments.*', 'users', 'Affectations') : null,
$isAdmin ? $nav('admin.fields.index', 'admin.fields.*', 'sliders-horizontal', 'Champs agents') : null,
$nav('admin.agents.index', 'admin.agents.*', 'contact', 'Agents'),
]))],
]) : [];
@endphp
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'Signatures CABM')</title>
<style>
:root { --ink:#1b1b1b; --muted:#5c5c5c; --line:#dcdcdc; --accent:#0b5cad; --accent-soft:#e7f0fa; --bg:#f6f7f9; --ok:#1f7a3d; --warn:#9a5b00; --danger:#b3261e; }
* { box-sizing: border-box; }
body { margin:0; font-family:"Segoe UI", system-ui, sans-serif; color:var(--ink); background:var(--bg); font-size:15px; }
header { background:#fff; border-bottom:1px solid var(--line); padding:0 24px; display:flex; justify-content:space-between; align-items:center; gap:16px; flex-wrap:wrap; }
header .brand { color:var(--accent); font-weight:600; padding:14px 0; }
nav a { display:inline-block; padding:14px 10px; color:var(--muted); text-decoration:none; border-bottom:2px solid transparent; }
nav a.active, nav a:hover { color:var(--ink); border-bottom-color:var(--accent); }
main { max-width:1200px; margin:24px auto; padding:0 16px; }
h1 { font-size:22px; margin:0 0 16px; }
h2 { font-size:17px; margin:0 0 12px; }
.card { background:#fff; border:1px solid var(--line); border-radius:8px; padding:20px; margin-bottom:16px; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; }
.split { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:16px; align-items:start; }
@media (max-width: 900px) { .split { grid-template-columns:1fr; } }
.stat { font-size:28px; font-weight:600; }
.muted { color:var(--muted); font-size:13px; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.spacer { flex:1; }
table.list { width:100%; border-collapse:collapse; }
table.list th, table.list td { text-align:left; padding:8px; border-bottom:1px solid var(--line); vertical-align:middle; }
table.list th { font-size:13px; color:var(--muted); font-weight:600; }
label { display:block; font-weight:600; font-size:13px; margin:12px 0 4px; }
input[type=text], input[type=number], input[type=search], input[type=url], input[type=datetime-local], select, textarea { width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:6px; font:inherit; background:#fff; }
textarea.code { font-family:Consolas, "Cascadia Mono", monospace; font-size:13px; line-height:1.45; min-height:260px; resize:vertical; tab-size:2; }
input.small { width:90px; }
.btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:7px 14px; font:inherit; font-size:14px; cursor:pointer; text-decoration:none; }
.btn.secondary { background:#fff; color:var(--accent); }
.btn.danger { background:#fff; color:var(--danger); border-color:var(--danger); }
.btn.small { padding:4px 10px; font-size:13px; }
.btn:disabled { opacity:.5; cursor:default; }
button.link { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; background:var(--accent-soft); color:var(--accent); }
.badge.ok { background:#e5f4ea; color:var(--ok); }
.badge.warn { background:#fdf1dc; color:var(--warn); }
.flash { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#e5f4ea; color:var(--ok); border:1px solid #bfe3cb; }
.errors { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#fde8e6; color:var(--danger); border:1px solid #f5c2bd; }
.notice { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#fdf1dc; color:var(--warn); border:1px solid #f3d9a4; }
label.check { font-weight:400; font-size:14px; margin:4px 0; }
label.check input { margin-right:6px; }
fieldset { border:1px solid var(--line); border-radius:6px; margin:12px 0 0; padding:8px 12px 12px; }
legend { font-weight:600; font-size:13px; padding:0 4px; }
.scroll { max-height:220px; overflow:auto; }
.chips button { background:var(--accent-soft); color:var(--accent); border:0; border-radius:12px; padding:3px 9px; margin:2px; font:12px Consolas, monospace; cursor:pointer; }
iframe.sig { width:100%; min-height:220px; border:1px solid var(--line); border-radius:6px; background:#fff; resize:vertical; }
form.inline { display:inline; }
</style>
<title>@yield('title', 'Signatures')</title>
<script>document.documentElement.classList.add('js');</script>
<link rel="preload" href="{{ asset('fonts/atkinson-next.woff2') }}" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="{{ $asset('css/app.css') }}">
</head>
<body>
<header>
<span class="brand">Signatures CABM</span>
<a class="skip" href="#main">Aller au contenu</a>
<div @class(['app', 'app--nav' => $hasNav])>
@auth
@if (auth()->user()->role)
<nav>
<a href="{{ route('admin.dashboard') }}" @class(['active' => request()->routeIs('admin.dashboard')])>Tableau de bord</a>
<a href="{{ route('admin.templates.index') }}" @class(['active' => request()->routeIs('admin.templates.*')])>Templates</a>
<a href="{{ route('admin.images.index') }}" @class(['active' => request()->routeIs('admin.images.*')])>Images</a>
<a href="{{ route('admin.campaigns.index') }}" @class(['active' => request()->routeIs('admin.campaigns.*')])>Campagnes</a>
@if (auth()->user()->hasRole('admin'))
<a href="{{ route('admin.assignments.index') }}" @class(['active' => request()->routeIs('admin.assignments.*')])>Affectations</a>
<a href="{{ route('admin.fields.index') }}" @class(['active' => request()->routeIs('admin.fields.*')])>Champs agents</a>
@endif
<a href="{{ route('admin.agents.index') }}" @class(['active' => request()->routeIs('admin.agents.*')])>Agents</a>
<a href="{{ route('portal.home') }}" @class(['active' => request()->routeIs('portal.*')])>Ma signature</a>
@if ($hasNav)
<aside class="sidebar">
<a class="brand" href="{{ route('admin.dashboard') }}">
<span class="brand-name">Signatures</span>
<svg class="brand-stroke" viewBox="0 0 104 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M2 8c9-6 15-6 20-1s9 4 14-1 10-5 15 0 9 4 14 0 11-5 17-1 12 4 20-1"/>
</svg>
<span class="brand-org">{{ $org }}</span>
</a>
<nav class="nav" aria-label="Navigation principale">
@foreach ($links as $section)
@if ($section['group'])<p class="nav-group">{{ $section['group'] }}</p>@endif
@foreach ($section['items'] as $item)
<a href="{{ route($item['route']) }}" @class(['nav-link', 'active' => request()->routeIs($item['pattern'])])
@if (request()->routeIs($item['pattern'])) aria-current="page" @endif>
<x-icon :name="$item['icon']" />{{ $item['label'] }}
</a>
@endforeach
@endforeach
</nav>
@endif
<form method="post" action="{{ route('logout') }}" class="row">@csrf
<span class="muted">{{ auth()->user()->name }}@if (auth()->user()->role) · {{ auth()->user()->role }}@endif</span>
<button class="link" type="submit">Se déconnecter</button>
<div class="sidebar-foot">
<a href="{{ route('portal.home') }}" @class(['nav-link', 'active' => request()->routeIs('portal.*')])>
<x-icon name="signature" />Ma signature
</a>
<div class="user">
<span class="avatar" aria-hidden="true">{{ $initials }}</span>
<span>
<span class="user-name">{{ $user->name }}</span>
<span class="user-role">{{ $roleLabel }}</span>
</span>
<form method="post" action="{{ route('logout') }}">@csrf
<button class="icon-btn" type="submit" title="Se déconnecter" aria-label="Se déconnecter"><x-icon name="log-out" /></button>
</form>
@endauth
</div>
</div>
</aside>
@else
<header class="topbar">
<a class="brand" href="{{ route('portal.home') }}"><span class="brand-name">Signatures</span></a>
<span class="muted" style="color:inherit">{{ $org }}</span>
<div class="user">
<span class="avatar" aria-hidden="true">{{ $initials }}</span>
<span class="user-name">{{ $user->name }}</span>
<form method="post" action="{{ route('logout') }}">@csrf
<button class="icon-btn" type="submit" title="Se déconnecter" aria-label="Se déconnecter"><x-icon name="log-out" /></button>
</form>
</div>
</header>
<main>
@endif
@endauth
<div class="content">
<main id="main">
@if (session('status'))
<div class="flash" role="status">{{ session('status') }}</div>
<div class="toasts">
<div class="toast" role="status" data-toast>
<x-icon name="circle-check" />
<span>{{ session('status') }}</span>
<button class="icon-btn" type="button" data-dismiss aria-label="Fermer"><x-icon name="x" :size="16" /></button>
</div>
</div>
@endif
@if ($errors->any())
<div class="errors" role="alert">
@foreach ($errors->all() as $error)<div>{{ $error }}</div>@endforeach
<div class="alert errors" role="alert">
<x-icon name="triangle-alert" />
<div>@foreach ($errors->all() as $error)<div>{{ $error }}</div>@endforeach</div>
</div>
@endif
@yield('content')
</main>
</div>
</div>
<dialog class="dialog" id="confirm-dialog" aria-labelledby="confirm-title">
<form method="dialog">
<div class="dialog-body">
<h2 id="confirm-title">Confirmer l'action</h2>
<p data-message></p>
</div>
<div class="dialog-actions">
<button class="btn secondary" value="cancel" autofocus>Annuler</button>
<button class="btn" value="ok" data-ok>Confirmer</button>
</div>
</form>
</dialog>
<script src="{{ $asset('js/app.js') }}" defer></script>
@stack('scripts')
</body>
</html>
@@ -1,3 +1,3 @@
{{-- Rendu isolé d'une signature : iframe sandbox (aucun script, origine opaque). --}}
<iframe class="sig" sandbox title="{{ $title ?? 'Aperçu de la signature' }}" @isset($id) id="{{ $id }}" @endisset
<iframe class="sig {{ $class ?? '' }}" sandbox title="{{ $title ?? 'Aperçu de la signature' }}" @isset($id) id="{{ $id }}" @endisset
srcdoc="{{ '<!doctype html><meta charset="utf-8"><base target="_blank"><body style="margin:12px">'.($html ?? '').'</body>' }}"></iframe>
+26 -20
View File
@@ -1,16 +1,19 @@
@extends('layouts.app')
@section('title', 'Ma signature — CABM')
@section('title', 'Ma signature — Signatures')
@section('content')
<h1>Ma signature</h1>
<div class="split">
<header class="page-head">
<div>
<h1>Ma signature</h1>
<p class="lead">Elle est insérée automatiquement dans Outlook. Modèle appliqué : {{ $full['template'] ?? 'aucun' }}.</p>
</div>
</header>
<div class="split">
<div class="card">
<h2>Nouveau message</h2>
<p class="muted">Modèle : {{ $full['template'] ?? 'aucun' }}</p>
@include('partials.signature-frame', ['html' => $full['html'] ?? '<em>Aucune signature</em>'])
<h2 style="margin-top:16px">Réponse / transfert</h2>
@include('partials.signature-frame', ['html' => ($reply['html'] ?? '') !== '' ? $reply['html'] : '<em>Aucune signature</em>'])
</div>
<h2 style="margin-top:18px">Réponse et transfert</h2>
@include('partials.signature-frame', ['html' => ($reply['html'] ?? '') !== '' ? $reply['html'] : '<em>Aucune signature</em>', 'class' => 'short'])
</div>
<form method="post" action="{{ route('portal.update') }}" class="card">
@@ -24,29 +27,32 @@
@foreach ($profile['fields'] as $f)
@php
$mode = ! $f['overridden'] ? 'directory' : ($f['override'] === '' ? 'hidden' : 'custom');
$id = 'f-'.$f['key'];
$fieldId = 'f-'.$f['key'];
@endphp
<fieldset style="border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin:12px 0" data-field>
<legend style="font-weight:600;font-size:13px;padding:0 4px">{{ $f['label'] }}</legend>
<label style="font-weight:400;margin:4px 0"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="directory" @checked($mode === 'directory')>
Annuaire : <strong>{{ $f['directory_value'] ?: '(vide)' }}</strong></label>
<label style="font-weight:400;margin:4px 0"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="custom" @checked($mode === 'custom')>
Valeur personnalisée</label>
<input type="text" id="{{ $id }}" name="fields[{{ $f['key'] }}][value]" value="{{ $mode === 'custom' ? $f['override'] : '' }}"
<fieldset data-field>
<legend>{{ $f['label'] }}</legend>
<label class="option"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="directory" @checked($mode === 'directory')>
<span>Annuaire : <strong>{{ $f['directory_value'] ?: '(vide)' }}</strong></span></label>
<label class="option"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="custom" @checked($mode === 'custom')>
<span style="flex:1">Valeur personnalisée
<input type="text" id="{{ $fieldId }}" name="fields[{{ $f['key'] }}][value]" value="{{ $mode === 'custom' ? $f['override'] : '' }}" style="margin-top:6px"
maxlength="120" @if ($f['type'] === 'phone') inputmode="tel" placeholder="ex. 06 12 34 56 78" @endif aria-label="{{ $f['label'] }} personnalisé">
<label style="font-weight:400;margin:4px 0"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="hidden" @checked($mode === 'hidden')>
Ne pas afficher</label>
</span></label>
<label class="option"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="hidden" @checked($mode === 'hidden')>
<span>Ne pas afficher</span></label>
</fieldset>
@endforeach
@endif
<h2 style="margin-top:16px">En réponse et transfert</h2>
@php $rv = $profile['reply_variant'] ?? 'default'; $labels = ['short' => 'signature courte', 'full' => 'signature complète', 'none' => 'aucune signature']; @endphp
<fieldset>
<legend>En réponse et transfert</legend>
@foreach (['default' => 'Réglage par défaut ('.$labels[$profile['reply_variant_default']].')', 'short' => 'Signature courte', 'full' => 'Signature complète', 'none' => 'Aucune signature'] as $value => $label)
<label style="font-weight:400;margin:4px 0"><input type="radio" name="reply_variant" value="{{ $value }}" @checked($rv === $value)> {{ $label }}</label>
<label class="option"><input type="radio" name="reply_variant" value="{{ $value }}" @checked($rv === $value)><span>{{ $label }}</span></label>
@endforeach
</fieldset>
<div class="row" style="margin-top:16px"><button class="btn" type="submit">Enregistrer</button></div>
<div class="row" style="margin-top:16px"><button class="btn" type="submit"><x-icon name="save" />Enregistrer</button></div>
</form>
</div>
@endsection