SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

design(admin v2): refonte visuelle — connexion, boîte courriel, inbox

Connexion : écran scindé signature — mur encre avec filigrane KA géant,
manchette, lignes « ce que le panel contient » (pulse-dot), carte à ombre
dure ; champs généreux, bouton afficher/masquer le mot de passe, erreur
avec secousse, état de vérification animé.

Courriel : version client-mail moderne — en-tête identité avec avatar à
teinte stable, dossiers en pilules avec compteurs (total + non-lus),
recherche plein champ (expéditeur/sujet/extrait/destinataire), rangées
riches (avatar, sujet + extrait, heure relative fr, pièces jointes, chips
non-routé/demande liée), actions rapides lu/archiver sans ouvrir le
message ; vue message rehaussée (en-tête correspondant, sujet, corps
typographié 68ch, barre d actions, réponse inline).

Inbox : recherche toujours visible + filtres avancés repliables sur
mobile (patron checkbox, zéro JS), chips de statut avec compteurs
(statusCounts), rangées riches (pastille catégorie à teinte stable,
priorité/statut, site, suivi, référence, pièces jointes, heure relative,
point non-lu).

Nouvelles primitives CSS (adm-avatar, hscroll, adm-filter-form,
login-wall/watermark/card) ; utilitaires format.ts (timeAgo fr,
initiales, teinte stable) ; listMail(q, folderCounts, att_count),
listSubmissions.att_count. Vérifié par captures Playwright desktop
(1440) et mobile (390) en prod https.
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent f872a48

10 changed files +752 −243

modified src/app/admin/(panel)/courriel/MailClient.tsx +49 −0
@@ -172,3 +172,52 @@ export function MailActions({
172 172 </div>
173 173 );
174 174 }
175 +
176 +/** Actions rapides d'une rangée de la liste (desktop) : lu/non-lu, archiver. */
177 +export function MailRowQuick({
178 + id,
179 + isRead,
180 + folder,
181 +}: {
182 + id: number;
183 + isRead: boolean;
184 + folder: string;
185 +}) {
186 + const router = useRouter();
187 + const [busy, setBusy] = useState(false);
188 +
189 + async function act(patch: Record<string, unknown>) {
190 + setBusy(true);
191 + await fetch(`/api/admin/mail/${id}`, {
192 + method: "PATCH",
193 + headers: { "Content-Type": "application/json" },
194 + body: JSON.stringify(patch),
195 + }).catch(() => {});
196 + setBusy(false);
197 + router.refresh();
198 + }
199 +
200 + const b =
201 + "gk-mono cursor-pointer rounded-md border border-[rgba(20,24,20,0.3)] bg-surface px-2 py-[5px] text-[9px] font-bold tracking-[0.06em] uppercase text-ink-2 hover:bg-lime-soft disabled:opacity-40";
202 + return (
203 + <span className="hidden flex-none items-center gap-1 sm:flex">
204 + <button
205 + className={b}
206 + disabled={busy}
207 + title={isRead ? "Marquer non lu" : "Marquer lu"}
208 + onClick={() => act({ is_read: !isRead })}
209 + >
210 + {isRead ? "Non lu" : "Lu"}
211 + </button>
212 + {folder !== "archive" ? (
213 + <button className={b} disabled={busy} title="Archiver" onClick={() => act({ folder: "archive" })}>
214 + Archiver
215 + </button>
216 + ) : (
217 + <button className={b} disabled={busy} title="Restaurer" onClick={() => act({ folder: "inbox" })}>
218 + Restaurer
219 + </button>
220 + )}
221 + </span>
222 + );
223 +}
modified src/app/admin/(panel)/courriel/[id]/page.tsx +75 −60
@@ -1,8 +1,9 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −// /admin/courriel/[id] — lecture d'un courriel : en-têtes, corps (texte
3 −// prioritaire, HTML assaini en dernier recours), pièces jointes, actions et
4 −// réponse. L'ouverture marque le message lu. Accès : propriétaire de la
5 −// boîte, ou super admin pour les non-routés (404 sinon).
2 +// /admin/courriel/[id] — lecture d'un courriel, version rehaussée : en-tête
3 +// avec avatar du correspondant, métadonnées propres, corps typographié,
4 +// pièces jointes, actions et réponse inline. Le HTML entrant n'est JAMAIS
5 +// rendu brut — texte extrait seulement. L'ouverture marque le message lu.
6 +// Accès : propriétaire de la boîte, ou super admin pour les non-routés.
6 7 import { notFound, redirect } from "next/navigation";
7 8 import { getAdminSession } from "@/lib/comms/admin-auth";
8 9 import { findAdminById, findSubmissionById } from "@/lib/comms/db";
@@ -14,13 +15,10 @@ import {
14 15 MAIL_FOLDERS,
15 16 } from "@/lib/comms/mail";
16 17 import { MailComposer, MailActions } from "../MailClient";
18 +import { timeAgo, fmtDateFull, initialsOf, avatarStyle } from "../../format";
17 19
18 20 export const dynamic = "force-dynamic";
19 21
20 −function fmtDate(iso: string): string {
21 − return iso.replace("T", " ").slice(0, 16);
22 −}
23 −
24 22 function fmtBytes(n: number): string {
25 23 if (n < 1024) return `${n} o`;
26 24 if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;
@@ -63,65 +61,80 @@ export default async function MailViewPage({
63 61 const body =
64 62 mail.body_text?.trim() || (mail.body_html ? htmlToText(mail.body_html) : "");
65 63 const linked = mail.submission_id ? findSubmissionById(mail.submission_id) : undefined;
64 + const isOut = mail.direction === "out";
65 + const counterpartEmail = isOut ? (to[0] ?? "?") : mail.from_email;
66 66
67 67 return (
68 − <>
69 − <nav aria-label="Fil d'Ariane">
68 + <div className="mx-auto max-w-4xl">
69 + <nav aria-label="Fil d'Ariane" className="flex items-center justify-between gap-3">
70 70 <a
71 71 href={`/admin/courriel?folder=${mail.folder}`}
72 72 className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green"
73 73 >
74 74 ← {MAIL_FOLDERS[mail.folder] ?? "Courriel"}
75 75 </a>
76 + <span className="gk-mono text-[10px] text-ink-3" title={fmtDateFull(mail.created_at)}>
77 + {timeAgo(mail.created_at)}
78 + </span>
76 79 </nav>
77 80
78 − <div className="mt-5 flex flex-wrap items-center gap-3">
79 − <h1 className="gk-display min-w-0 flex-1 text-[clamp(19px,3vw,27px)] leading-tight font-bold tracking-[-0.03em]">
80 − {mail.subject || "(sans sujet)"}
81 − </h1>
82 − <span className="adm-chip adm-chip--ghost">{mail.direction === "in" ? "Reçu" : "Envoyé"}</span>
83 − {mail.admin_id === null ? (
84 − <span className="adm-chip adm-chip--haute">Non routé</span>
85 − ) : null}
86 − </div>
81 + <div className="gk-card mt-4 overflow-hidden">
82 + {/* en-tête correspondant */}
83 + <div className="flex flex-wrap items-center gap-4 border-b-[1.5px] border-ink bg-surface-2 px-5 py-4 sm:px-7">
84 + <span className="adm-avatar !h-[48px] !w-[48px] !text-[17px]" style={avatarStyle(counterpartEmail)}>
85 + {initialsOf(isOut ? null : mail.from_name, counterpartEmail)}
86 + </span>
87 + <div className="min-w-0 flex-1">
88 + <p className="truncate text-[15px] font-bold">
89 + {isOut
90 + ? `Vous → ${to.join(", ")}`
91 + : mail.from_name
92 + ? `${mail.from_name}`
93 + : mail.from_email}
94 + </p>
95 + <p className="gk-mono mt-[1px] truncate text-[10.5px] text-ink-3">
96 + {isOut ? `de ${mail.from_email}` : mail.from_email}
97 + {cc.length ? ` · cc ${cc.join(", ")}` : ""}
98 + {!isOut ? ` · à ${to.join(", ")}` : ""}
99 + </p>
100 + </div>
101 + <div className="flex flex-none flex-wrap gap-2">
102 + <span className="adm-chip adm-chip--ghost !text-[9px]">
103 + {isOut ? "Envoyé" : "Reçu"}
104 + </span>
105 + {mail.admin_id === null ? (
106 + <span className="adm-chip adm-chip--haute !text-[9px]">Non routé</span>
107 + ) : null}
108 + </div>
109 + </div>
87 110
88 − <div className="gk-card mt-5 p-5 !shadow-none">
89 − <div className="grid gap-x-6 gap-y-2 text-[13px] sm:grid-cols-2">
90 − <p>
91 − <span className="klabel mr-2">De</span>
92 − {mail.from_name ? `${mail.from_name} · ` : ""}
93 − <span className="gk-mono">{mail.from_email}</span>
94 − </p>
95 − <p>
96 − <span className="klabel mr-2">Date</span>
97 − <span className="gk-mono">{fmtDate(mail.created_at)}</span>
98 − </p>
99 − <p>
100 − <span className="klabel mr-2">À</span>
101 − <span className="gk-mono">{to.join(", ")}</span>
102 − </p>
103 − {cc.length ? (
104 − <p>
105 − <span className="klabel mr-2">Cc</span>
106 − <span className="gk-mono">{cc.join(", ")}</span>
111 + {/* sujet + demande liée */}
112 + <div className="px-5 pt-5 sm:px-7">
113 + <h1 className="gk-display text-[clamp(19px,2.6vw,26px)] leading-tight font-bold tracking-[-0.03em]">
114 + {mail.subject || "(sans sujet)"}
115 + </h1>
116 + {linked ? (
117 + <p className="mt-3 inline-flex flex-wrap items-center gap-2 rounded-lg border-[1.5px] border-green bg-lime-soft px-3 py-2 text-[12px]">
118 + <span className="gk-mono font-bold tracking-[0.06em] text-green uppercase">Demande liée</span>
119 + <a href={`/admin/demande/${linked.reference}`} className="gk-mono font-bold text-ink underline underline-offset-4">
120 + {linked.reference}
121 + </a>
122 + <span className="text-ink-2">— aussi versé dans sa conversation</span>
107 123 </p>
108 124 ) : null}
109 125 </div>
110 − {linked ? (
111 − <p className="mt-3 border-t border-line pt-3 text-[12.5px]">
112 − <span className="klabel mr-2">Demande liée</span>
113 − <a href={`/admin/demande/${linked.reference}`} className="gk-mono font-bold text-green underline underline-offset-4">
114 − {linked.reference}
115 − </a>
116 − <span className="text-ink-3"> — ce courriel a aussi été versé dans sa conversation.</span>
126 +
127 + {/* corps */}
128 + <div className="px-5 py-5 sm:px-7 sm:py-6">
129 + <p className="max-w-[68ch] text-[15px] leading-[1.75] whitespace-pre-wrap">
130 + {body || "(message vide)"}
117 131 </p>
118 − ) : null}
119 − <div className="mt-5 border-t-[1.5px] border-ink pt-5">
120 − <p className="text-[14.5px] leading-relaxed whitespace-pre-wrap">{body || "(message vide)"}</p>
121 132 </div>
133 +
134 + {/* pièces jointes */}
122 135 {atts.length ? (
123 − <div className="mt-5 border-t border-line pt-4">
124 − <p className="klabel">Pièces jointes</p>
136 + <div className="border-t border-line px-5 py-4 sm:px-7">
137 + <p className="klabel">Pièces jointes ({atts.length})</p>
125 138 <ul className="mt-2 flex flex-wrap gap-2">
126 139 {atts.map((a) => (
127 140 <li key={a.id}>
@@ -137,19 +150,21 @@ export default async function MailViewPage({
137 150 </ul>
138 151 </div>
139 152 ) : null}
140 − </div>
141 153
142 − <div className="mt-5 flex flex-wrap items-start gap-3">
143 − <MailActions
144 − id={mail.id}
145 − folder={mail.folder}
146 − isRead={true}
147 − unrouted={mail.admin_id === null}
148 − />
154 + {/* barre d'actions */}
155 + <div className="border-t-[1.5px] border-ink bg-surface-2 px-5 py-3 sm:px-7">
156 + <MailActions
157 + id={mail.id}
158 + folder={mail.folder}
159 + isRead={true}
160 + unrouted={mail.admin_id === null}
161 + />
162 + </div>
149 163 </div>
150 164
165 + {/* réponse */}
151 166 {mail.direction === "in" ? (
152 − <div className="mt-6">
167 + <div className="mt-5">
153 168 <MailComposer
154 169 fromEmail={admin?.email ?? ""}
155 170 replyTo={{
@@ -161,6 +176,6 @@ export default async function MailViewPage({
161 176 />
162 177 </div>
163 178 ) : null}
164 − </>
179 + </div>
165 180 );
166 181 }
modified src/app/admin/(panel)/courriel/page.tsx +142 −69
@@ -1,20 +1,18 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −// /admin/courriel — la boîte courriel de l'admin connecté (adresse
3 −// @groupe-ka.com personnelle) : réception via le webhook Resend, envoi via
4 −// Resend, tout archivé en base. Les super admins voient aussi les messages
5 −// « non routés » (adressés à une adresse du domaine sans boîte).
2 +// /admin/courriel — la boîte courriel du panel, version client-mail moderne :
3 +// dossiers avec compteurs, recherche plein champ, rangées riches (avatar à
4 +// teinte stable, sujet + extrait, heure relative, pièces jointes, chips),
5 +// actions rapides lu/archiver sans quitter la liste. Mobile-first : pilules
6 +// défilantes, cibles tactiles généreuses.
6 7 import { redirect } from "next/navigation";
7 8 import { getAdminSession } from "@/lib/comms/admin-auth";
8 9 import { findAdminById } from "@/lib/comms/db";
9 −import { listMail, MAIL_FOLDERS } from "@/lib/comms/mail";
10 −import { MailComposer } from "./MailClient";
10 +import { listMail, folderCounts, MAIL_FOLDERS } from "@/lib/comms/mail";
11 +import { MailComposer, MailRowQuick } from "./MailClient";
12 +import { timeAgo, initialsOf, avatarStyle } from "../format";
11 13
12 14 export const dynamic = "force-dynamic";
13 15
14 −function fmtDate(iso: string): string {
15 − return iso.replace("T", " ").slice(0, 16);
16 −}
17 −
18 16 export default async function MailboxPage({
19 17 searchParams,
20 18 }: {
@@ -25,80 +23,151 @@ export default async function MailboxPage({
25 23 const sp = await searchParams;
26 24 const folder =
27 25 typeof sp.folder === "string" && sp.folder in MAIL_FOLDERS ? sp.folder : "inbox";
26 + const q = typeof sp.q === "string" ? sp.q.slice(0, 120) : "";
28 27 const page = Math.max(1, Number(sp.page) || 1);
29 28 const superAdmin = session.roles.includes("super_admin");
30 − const { rows, total } = listMail(session.id, superAdmin, folder, page);
29 + const { rows, total } = listMail(session.id, superAdmin, folder, page, 30, q || undefined);
30 + const counts = folderCounts(session.id, superAdmin);
31 31 const admin = findAdminById(session.id);
32 32 const myEmail = admin?.email ?? "";
33 − const pages = Math.max(1, Math.ceil(total / 40));
33 + const pages = Math.max(1, Math.ceil(total / 30));
34 34
35 35 return (
36 36 <>
37 − <div className="flex flex-wrap items-end justify-between gap-4">
38 − <div>
39 − <p className="kicker">Courriel</p>
40 − <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]">
41 − {MAIL_FOLDERS[folder]} · {total}
42 − </h1>
43 − <p className="gk-mono mt-1 text-[11px] text-ink-3">
44 − Votre adresse : <strong className="text-green">{myEmail || "non définie"}</strong>
45 − {superAdmin ? " · les messages non routés du domaine apparaissent aussi ici" : ""}
46 − </p>
37 + {/* en-tête : identité de la boîte + composeur */}
38 + <div className="flex flex-wrap items-start justify-between gap-4">
39 + <div className="flex min-w-0 items-center gap-4">
40 + <span className="adm-avatar !h-[52px] !w-[52px] !text-[19px]" style={avatarStyle(myEmail)}>
41 + {initialsOf(session.displayName, myEmail || session.username)}
42 + </span>
43 + <div className="min-w-0">
44 + <p className="kicker">Courriel</p>
45 + <h1 className="gk-display mt-1 truncate text-[clamp(20px,3vw,28px)] leading-tight font-bold tracking-[-0.03em]">
46 + {myEmail || "Adresse non définie"}
47 + </h1>
48 + <p className="gk-mono mt-[2px] text-[10.5px] text-ink-3">
49 + {counts.inbox.unread > 0
50 + ? `${counts.inbox.unread} non lu${counts.inbox.unread > 1 ? "s" : ""} · `
51 + : ""}
52 + envoi &amp; réception Resend
53 + {superAdmin ? " · messages non routés du domaine inclus" : ""}
54 + </p>
55 + </div>
47 56 </div>
48 57 <MailComposer fromEmail={myEmail} />
49 58 </div>
50 59
51 − <div className="mt-6 flex flex-wrap gap-2">
52 − {Object.entries(MAIL_FOLDERS).map(([k, v]) => (
53 − <a
54 − key={k}
55 − href={`/admin/courriel?folder=${k}`}
56 − className={`adm-chip no-underline ${folder === k ? "adm-chip--lime" : ""}`}
57 − >
58 − {v}
59 − </a>
60 − ))}
60 + {/* dossiers (compteurs) + recherche */}
61 + <div className="mt-6 flex flex-wrap items-center gap-3">
62 + <div className="hscroll max-w-full">
63 + {Object.entries(MAIL_FOLDERS).map(([k, v]) => (
64 + <a
65 + key={k}
66 + href={`/admin/courriel?folder=${k}`}
67 + className={`adm-chip !py-[6px] no-underline ${folder === k ? "adm-chip--lime" : ""}`}
68 + >
69 + {v}
70 + <span className={folder === k ? "text-ink-2" : "text-ink-3"}>
71 + {counts[k].total}
72 + </span>
73 + {k === "inbox" && counts.inbox.unread > 0 ? (
74 + <span className="rounded-full bg-ink px-[7px] py-[1px] text-[9px] text-lime">
75 + {counts.inbox.unread}
76 + </span>
77 + ) : null}
78 + </a>
79 + ))}
80 + </div>
81 + <form method="get" action="/admin/courriel" className="ml-auto flex w-full items-center gap-2 sm:w-auto">
82 + <input type="hidden" name="folder" value={folder} />
83 + <input
84 + name="q"
85 + defaultValue={q}
86 + placeholder="Rechercher (expéditeur, sujet…)"
87 + className="field w-full !py-[9px] !text-[13px] sm:w-[260px]"
88 + aria-label="Rechercher dans la boîte"
89 + />
90 + <button type="submit" className="btn btn-ghost !min-h-[40px] !px-4 !text-[13px]">
91 + ⌕
92 + </button>
93 + {q ? (
94 + <a href={`/admin/courriel?folder=${folder}`} className="gk-mono flex-none text-[10px] font-bold text-ink-3 uppercase underline underline-offset-4">
95 + Effacer
96 + </a>
97 + ) : null}
98 + </form>
61 99 </div>
62 100
63 − <div className="gk-card mt-6 overflow-hidden !shadow-none">
101 + {/* liste */}
102 + <div className="gk-card mt-5 overflow-hidden !shadow-none">
64 103 {rows.length === 0 ? (
65 − <p className="gk-mono p-6 text-[12px] text-ink-3">
66 − {folder === "inbox"
67 − ? "Boîte vide. La réception exige le MX Resend + le webhook (voir Équipe & accès si rien n'arrive)."
68 − : "Aucun message dans ce dossier."}
69 − </p>
104 + <div className="px-6 py-12 text-center">
105 + <p className="gk-display text-[17px] font-bold">
106 + {q ? "Aucun résultat" : "Boîte vide"}
107 + </p>
108 + <p className="gk-mono mt-2 text-[11px] leading-relaxed text-ink-3">
109 + {q
110 + ? `Rien ne correspond à « ${q} » dans ${MAIL_FOLDERS[folder].toLowerCase()}.`
111 + : folder === "inbox"
112 + ? "Les courriels reçus sur votre adresse apparaîtront ici."
113 + : "Aucun message dans ce dossier."}
114 + </p>
115 + </div>
70 116 ) : (
71 117 rows.map((m) => {
72 − const counterpart =
73 − m.direction === "out"
74 − ? `À : ${(JSON.parse(m.to_emails) as string[]).join(", ")}`
75 − : m.from_name
76 − ? `${m.from_name} · ${m.from_email}`
77 − : m.from_email;
118 + const to = JSON.parse(m.to_emails) as string[];
119 + const isOut = m.direction === "out";
120 + const counterpartEmail = isOut ? (to[0] ?? "?") : m.from_email;
121 + const counterpartName = isOut
122 + ? to.join(", ")
123 + : (m.from_name ?? m.from_email);
78 124 return (
79 − <a
80 − key={m.id}
81 − href={`/admin/courriel/${m.id}`}
82 − className={`adm-row ${m.is_read ? "" : "adm-row--unread"}`}
83 − >
84 − <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
85 − <span className="max-w-[45%] truncate text-[13px] font-semibold">{counterpart}</span>
86 − {m.admin_id === null ? (
87 − <span className="adm-chip adm-chip--haute !text-[9px]">Non routé</span>
88 − ) : null}
89 − {m.submission_id ? (
90 − <span className="adm-chip adm-chip--lime !text-[9px]">Demande liée</span>
91 − ) : null}
92 − <span className="gk-mono ml-auto flex-none text-[10.5px] text-ink-3">
93 − {fmtDate(m.created_at)}
94 − {m.is_read ? null : <span className="ml-2 font-bold text-green">●</span>}
125 + <div key={m.id} className={`adm-row !flex items-center !py-0 !pr-3 !pl-0 ${m.is_read ? "" : "adm-row--unread"}`}>
126 + <a
127 + href={`/admin/courriel/${m.id}`}
128 + className="flex min-w-0 flex-1 items-center gap-3 py-3 pl-4 no-underline sm:gap-4"
129 + >
130 + <span className="adm-avatar" style={avatarStyle(counterpartEmail)}>
131 + {initialsOf(isOut ? null : m.from_name, counterpartEmail)}
132 + </span>
133 + <span className="min-w-0 flex-1">
134 + <span className="flex items-baseline gap-2">
135 + <span className={`truncate text-[13.5px] ${m.is_read ? "font-medium" : "font-bold"}`}>
136 + {isOut ? `À : ${counterpartName}` : counterpartName}
137 + </span>
138 + {m.admin_id === null ? (
139 + <span className="adm-chip adm-chip--haute flex-none !px-2 !text-[8.5px]">Non routé</span>
140 + ) : null}
141 + {m.submission_id ? (
142 + <span className="adm-chip adm-chip--lime flex-none !px-2 !text-[8.5px]">Demande liée</span>
143 + ) : null}
144 + </span>
145 + <span className="mt-[1px] block truncate text-[13px]">
146 + <span className={m.is_read ? "text-ink-2" : "font-semibold text-ink"}>
147 + {m.subject || "(sans sujet)"}
148 + </span>
149 + {m.snippet ? (
150 + <span className="text-ink-3"> — {m.snippet}</span>
151 + ) : null}
152 + </span>
153 + </span>
154 + <span className="flex flex-none flex-col items-end gap-[3px]">
155 + <span className={`gk-mono text-[10px] ${m.is_read ? "text-ink-3" : "font-bold text-green"}`}>
156 + {timeAgo(m.created_at)}
157 + </span>
158 + <span className="flex items-center gap-2">
159 + {m.att_count > 0 ? (
160 + <span className="gk-mono text-[10px] text-ink-3">📎 {m.att_count}</span>
161 + ) : null}
162 + {!m.is_read ? (
163 + <span className="h-[8px] w-[8px] rounded-full bg-green" aria-label="non lu" />
164 + ) : null}
165 + </span>
95 166 </span>
96 − </div>
97 − <p className="mt-[2px] truncate text-[13.5px]">
98 − <strong>{m.subject || "(sans sujet)"}</strong>
99 − {m.snippet ? <span className="text-ink-3"> — {m.snippet}</span> : null}
100 − </p>
101 − </a>
167 + </a>
168 + {/* actions rapides sans ouvrir le message */}
169 + <MailRowQuick id={m.id} isRead={!!m.is_read} folder={m.folder} />
170 + </div>
102 171 );
103 172 })
104 173 )}
@@ -107,11 +176,15 @@ export default async function MailboxPage({
107 176 {pages > 1 ? (
108 177 <div className="gk-mono mt-5 flex items-center gap-4 text-[12px] font-bold">
109 178 {page > 1 ? (
110 − <a href={`/admin/courriel?folder=${folder}&page=${page - 1}`} className="underline underline-offset-4">← Précédente</a>
179 + <a href={`/admin/courriel?folder=${folder}&page=${page - 1}${q ? `&q=${encodeURIComponent(q)}` : ""}`} className="underline underline-offset-4">
180 + ← Précédente
181 + </a>
111 182 ) : null}
112 − <span className="text-ink-3">Page {page} / {pages}</span>
183 + <span className="text-ink-3">Page {page} / {pages} · {total} message{total > 1 ? "s" : ""}</span>
113 184 {page < pages ? (
114 − <a href={`/admin/courriel?folder=${folder}&page=${page + 1}`} className="underline underline-offset-4">Suivante →</a>
185 + <a href={`/admin/courriel?folder=${folder}&page=${page + 1}${q ? `&q=${encodeURIComponent(q)}` : ""}`} className="underline underline-offset-4">
186 + Suivante →
187 + </a>
115 188 ) : null}
116 189 </div>
117 190 ) : null}
added src/app/admin/(panel)/format.ts +38 −0
@@ -0,0 +1,38 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Panel /admin — petits utilitaires d'affichage partagés (server-safe) :
3 +// heures relatives fr, initiales et teinte stable d'un correspondant.
4 +
5 +/** « il y a 4 min », « hier 14:02 », « 12 août »… */
6 +export function timeAgo(iso: string): string {
7 + const d = new Date(iso.replace(" ", "T") + (iso.includes("Z") ? "" : "Z"));
8 + const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
9 + if (s < 60) return "à l'instant";
10 + if (s < 3600) return `il y a ${Math.floor(s / 60)} min`;
11 + if (s < 86400) return `il y a ${Math.floor(s / 3600)} h`;
12 + if (s < 172800) return `hier ${iso.slice(11, 16)}`;
13 + if (s < 30 * 86400) return `il y a ${Math.floor(s / 86400)} j`;
14 + return iso.slice(0, 10);
15 +}
16 +
17 +export function fmtDateFull(iso: string): string {
18 + return iso.replace("T", " ").slice(0, 16);
19 +}
20 +
21 +/** Initiale(s) d'un correspondant — « Jean Client » → JC, « a@b.co » → A. */
22 +export function initialsOf(name: string | null, email: string): string {
23 + const src = (name ?? "").trim() || email;
24 + const words = src.split(/[\s.@_-]+/).filter(Boolean);
25 + const chars = words.slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "");
26 + return chars.join("") || "?";
27 +}
28 +
29 +/** Teinte pastel stable dérivée d'une chaîne (fond d'avatar). */
30 +export function hueOf(key: string): number {
31 + let h = 0;
32 + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
33 + return h;
34 +}
35 +
36 +export function avatarStyle(key: string): { background: string; color: string } {
37 + return { background: `hsl(${hueOf(key)} 55% 86%)`, color: "var(--ink)" };
38 +}
modified src/app/admin/(panel)/inbox/page.tsx +145 −66
@@ -1,11 +1,16 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −// /admin/inbox — toutes les demandes, centralisées. Filtres (catégorie,
3 −// sous-catégorie, statut, priorité, site, assignation, KA ID, vue) +
4 −// recherche globale + pagination. Le filtrage RBAC est fait dans la requête
5 −// SQL : un admin ne voit jamais une ligne interdite.
2 +// /admin/inbox — toutes les demandes, version v2 : recherche toujours
3 +// visible, filtres avancés repliables sur mobile (patron checkbox, zéro JS),
4 +// chips de statut avec compteurs, rangées riches (pastille catégorie à
5 +// teinte stable, priorité, heure relative, pièces jointes). Le RBAC reste
6 +// appliqué dans la requête SQL.
6 7 import { redirect } from "next/navigation";
7 8 import { getAdminSession } from "@/lib/comms/admin-auth";
8 −import { listSubmissions, type InboxFilters } from "@/lib/comms/queries";
9 +import {
10 + listSubmissions,
11 + statusCounts,
12 + type InboxFilters,
13 +} from "@/lib/comms/queries";
9 14 import { listAdmins } from "@/lib/comms/db";
10 15 import {
11 16 CATEGORIES,
@@ -16,6 +21,7 @@ import {
16 21 JOB_STATUSES,
17 22 statusesFor,
18 23 } from "@/lib/comms/categories";
24 +import { timeAgo, avatarStyle } from "../format";
19 25
20 26 export const dynamic = "force-dynamic";
21 27
@@ -27,10 +33,6 @@ const QUICK = [
27 33 { label: "Médias", category: "medias" },
28 34 ] as const;
29 35
30 −function fmtDate(iso: string): string {
31 − return iso.replace("T", " ").slice(0, 16);
32 −}
33 −
34 36 export default async function InboxPage({
35 37 searchParams,
36 38 }: {
@@ -57,12 +59,14 @@ export default async function InboxPage({
57 59 page: Math.max(1, Number(one("page")) || 1),
58 60 };
59 61 const { rows, total } = listSubmissions(session, filters);
62 + const byStatus = statusCounts(session, filters.category);
60 63 const admins = listAdmins().filter((a) => a.active);
61 64 const pages = Math.max(1, Math.ceil(total / 40));
62 65 const currentCat = filters.category ? categoryBySlug(filters.category) : undefined;
66 + // libellés génériques prioritaires (« nouvelle » existe dans les deux jeux)
63 67 const statusOptions = filters.category
64 68 ? statusesFor(filters.category)
65 − : { ...STATUSES, ...JOB_STATUSES };
69 + : { ...JOB_STATUSES, ...STATUSES };
66 70
67 71 const qs = (patch: Record<string, string>) => {
68 72 const params = new URLSearchParams();
@@ -75,13 +79,22 @@ export default async function InboxPage({
75 79 assigned: filters.assigned ?? "",
76 80 ka_id: filters.kaId ?? "",
77 81 q: filters.q ?? "",
78 − view: filters.view ?? "actives",
82 + view: filters.view === "actives" ? "" : (filters.view ?? ""),
79 83 ...patch,
80 84 }))
81 85 if (v) params.set(k, v);
82 86 const s = params.toString();
83 87 return s ? `/admin/inbox?${s}` : "/admin/inbox";
84 88 };
89 + // champs cachés qui préservent les filtres dans le formulaire de recherche
90 + const hidden = Object.entries({
91 + category: filters.category,
92 + status: filters.status,
93 + priority: filters.priority,
94 + site: filters.site,
95 + assigned: filters.assigned,
96 + view: filters.view === "actives" ? undefined : filters.view,
97 + }).filter(([, v]) => v) as [string, string][];
85 98
86 99 return (
87 100 <>
@@ -91,13 +104,14 @@ export default async function InboxPage({
91 104 <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]">
92 105 {total} demande{total > 1 ? "s" : ""}
93 106 {currentCat ? ` · ${currentCat.title}` : ""}
107 + {filters.q ? ` · « ${filters.q} »` : ""}
94 108 </h1>
95 109 </div>
96 − <div className="flex flex-wrap gap-2">
110 + <div className="hscroll max-w-full">
97 111 {QUICK.map((qf) => (
98 112 <a
99 113 key={qf.category}
100 − href={qs({ category: filters.category === qf.category ? "" : qf.category, page: "" })}
114 + href={qs({ category: filters.category === qf.category ? "" : qf.category, status: "", page: "" })}
101 115 className={`adm-chip no-underline ${filters.category === qf.category ? "adm-chip--lime" : ""}`}
102 116 >
103 117 {qf.label}
@@ -106,12 +120,41 @@ export default async function InboxPage({
106 120 </div>
107 121 </div>
108 122
109 − {/* filtres — formulaire GET simple, robuste, sans JS */}
110 − <form method="get" action="/admin/inbox" className="gk-card mt-6 grid grid-cols-2 gap-3 p-4 !shadow-none sm:grid-cols-3 xl:grid-cols-6">
111 − <div className="col-span-2 sm:col-span-3 xl:col-span-2">
112 − <label className="klabel mb-[4px] block" htmlFor="f-q">Recherche globale</label>
113 − <input id="f-q" name="q" defaultValue={filters.q ?? ""} placeholder="référence, nom, organisation, sujet…" className="field !py-[8px]" />
114 − </div>
123 + {/* recherche (toujours visible) + bascule filtres mobile.
124 + Le checkbox reste sibling direct du <form> : #adm-filtres:checked ~
125 + .adm-filter-form (globals.css) le déplie sur mobile, zéro JS. */}
126 + <input type="checkbox" id="adm-filtres" className="sr-only" />
127 + <div className="mt-5 flex items-center gap-2">
128 + <form method="get" action="/admin/inbox" className="flex min-w-0 flex-1 items-center gap-2">
129 + {hidden.map(([k, v]) => (
130 + <input key={k} type="hidden" name={k} value={v} />
131 + ))}
132 + <input
133 + name="q"
134 + defaultValue={filters.q ?? ""}
135 + placeholder="Rechercher : référence, nom, organisation, sujet…"
136 + className="field min-w-0 flex-1 !py-[10px] !text-[13.5px]"
137 + aria-label="Recherche globale"
138 + />
139 + <button type="submit" className="btn btn-primary !min-h-[42px] !px-4 !text-[13px]">
140 + ⌕
141 + </button>
142 + </form>
143 + <label
144 + htmlFor="adm-filtres"
145 + className="adm-filter-toggle btn btn-ghost !min-h-[42px] cursor-pointer !px-4 !text-[12.5px]"
146 + >
147 + Filtres
148 + </label>
149 + </div>
150 +
151 + {/* filtres avancés — repliés sur mobile, toujours visibles desktop */}
152 + <form
153 + method="get"
154 + action="/admin/inbox"
155 + className="adm-filter-form gk-card mt-3 grid-cols-2 gap-3 p-4 !shadow-none sm:grid-cols-3 xl:grid-cols-6"
156 + >
157 + {filters.q ? <input type="hidden" name="q" value={filters.q} /> : null}
115 158 <div>
116 159 <label className="klabel mb-[4px] block" htmlFor="f-cat">Catégorie</label>
117 160 <select id="f-cat" name="category" defaultValue={filters.category ?? ""} className="field cursor-pointer !py-[8px]">
@@ -168,83 +211,119 @@ export default async function InboxPage({
168 211 <option value="spam">Spam</option>
169 212 </select>
170 213 </div>
171 − <div className="flex items-end gap-2">
172 − <button type="submit" className="btn btn-primary !min-h-[38px] flex-1 !px-4 !text-[13px]">Filtrer</button>
173 − <a href="/admin/inbox" className="btn btn-ghost !min-h-[38px] !px-4 !text-[13px]">Effacer</a>
214 + <div className="col-span-2 flex items-end gap-2 sm:col-span-3 xl:col-span-6">
215 + <button type="submit" className="btn btn-primary !min-h-[38px] !px-5 !text-[13px]">
216 + Appliquer les filtres
217 + </button>
218 + <a href="/admin/inbox" className="btn btn-ghost !min-h-[38px] !px-4 !text-[13px]">
219 + Tout effacer
220 + </a>
174 221 </div>
175 222 </form>
176 223
224 + {/* chips de statut avec compteurs */}
225 + <div className="hscroll mt-4">
226 + <a
227 + href={qs({ status: "", page: "" })}
228 + className={`adm-chip no-underline ${!filters.status ? "adm-chip--lime" : ""}`}
229 + >
230 + Tous statuts
231 + </a>
232 + {Object.entries(statusOptions).map(([k, v]) =>
233 + (byStatus[k] ?? 0) > 0 || filters.status === k ? (
234 + <a
235 + key={k}
236 + href={qs({ status: filters.status === k ? "" : k, page: "" })}
237 + className={`adm-chip no-underline ${filters.status === k ? "adm-chip--lime" : ""}`}
238 + >
239 + {v} <span className="text-ink-3">{byStatus[k] ?? 0}</span>
240 + </a>
241 + ) : null,
242 + )}
243 + </div>
244 +
177 245 {/* liste */}
178 − <div className="gk-card mt-6 overflow-hidden !shadow-none">
179 − <div className="gk-mono hidden grid-cols-[130px_1fr_150px_130px_120px_110px] gap-3 border-b-[1.5px] border-ink bg-surface-2 px-4 py-2 text-[9.5px] font-bold tracking-[0.1em] text-ink-3 uppercase lg:grid">
180 − <span>Référence</span>
181 − <span>Demandeur · sujet</span>
182 − <span>Site / sous-cat.</span>
183 − <span>Priorité · statut</span>
184 − <span>Assignée à</span>
185 − <span>Date</span>
186 − </div>
246 + <div className="gk-card mt-4 overflow-hidden !shadow-none">
187 247 {rows.length === 0 ? (
188 − <p className="gk-mono p-6 text-[12px] text-ink-3">Aucune demande ne correspond à ces filtres.</p>
248 + <div className="px-6 py-12 text-center">
249 + <p className="gk-display text-[17px] font-bold">Aucune demande</p>
250 + <p className="gk-mono mt-2 text-[11px] text-ink-3">
251 + Rien ne correspond à ces filtres — essayez « Tout effacer ».
252 + </p>
253 + </div>
189 254 ) : (
190 255 rows.map((r) => {
191 256 const cat = categoryBySlug(r.category);
257 + const who = r.is_anonymous
258 + ? "Anonyme"
259 + : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—";
192 260 return (
193 261 <a
194 262 key={r.id}
195 263 href={`/admin/demande/${r.reference}`}
196 − className={`adm-row lg:grid lg:grid-cols-[130px_1fr_150px_130px_120px_110px] lg:items-center lg:gap-3 ${r.is_read ? "" : "adm-row--unread"}`}
264 + className={`adm-row !flex items-center gap-3 sm:gap-4 ${r.is_read ? "" : "adm-row--unread"}`}
197 265 >
198 − <span className="gk-mono block text-[11px] font-bold text-green">
199 − {r.reference}
200 − {r.ka_id ? <span className="block font-normal text-ink-3">{r.ka_id}</span> : null}
266 + <span
267 + className="adm-avatar gk-mono !text-[10.5px] !tracking-[0.04em]"
268 + style={avatarStyle(r.category)}
269 + title={cat?.title ?? r.category}
270 + >
271 + {cat?.prefix ?? "?"}
201 272 </span>
202 − <span className="mt-1 block min-w-0 lg:mt-0">
203 − <span className="block truncate text-[13.5px] font-semibold">
204 − {r.is_anonymous
205 − ? "Anonyme"
206 − : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—"}
207 − {r.organization ? <span className="font-normal text-ink-2"> · {r.organization}</span> : null}
273 + <span className="min-w-0 flex-1">
274 + <span className="flex flex-wrap items-baseline gap-x-2 gap-y-[2px]">
275 + <span className={`truncate text-[13.5px] ${r.is_read ? "font-medium" : "font-bold"}`}>
276 + {who}
277 + </span>
278 + {r.organization ? (
279 + <span className="truncate text-[12px] text-ink-2">· {r.organization}</span>
280 + ) : null}
281 + <span className={`adm-chip adm-chip--${r.priority} flex-none !px-2 !text-[8.5px]`}>
282 + {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority}
283 + </span>
284 + <span className="adm-chip adm-chip--ghost flex-none !px-2 !text-[8.5px]">
285 + {statusesFor(r.category)[r.status] ?? r.status}
286 + </span>
208 287 </span>
209 − <span className="block truncate text-[12px] text-ink-2">
210 − <span className="adm-chip mr-2 align-middle !text-[9px]">{cat?.short ?? r.category}</span>
211 − {r.subject ?? r.sub_category ?? (r.message ?? "").slice(0, 80)}
288 + <span className="mt-[1px] block truncate text-[12.5px]">
289 + <span className={r.is_read ? "text-ink-2" : "font-semibold text-ink"}>
290 + {r.subject ?? r.sub_category ?? (r.message ?? "").slice(0, 90)}
291 + </span>
292 + <span className="gk-mono text-[10.5px] text-ink-3">
293 + {r.site_concerned ? ` · ${r.site_concerned}` : ""}
294 + {r.assignee ? ` · suivi : ${r.assignee}` : ""}
295 + </span>
212 296 </span>
213 297 </span>
214 − <span className="gk-mono mt-1 block truncate text-[10.5px] text-ink-3 lg:mt-0">
215 − {r.site_concerned ?? "—"}
216 − {r.sub_category ? <span className="block truncate">{r.sub_category}</span> : null}
217 − </span>
218 − <span className="mt-1 flex flex-wrap gap-1 lg:mt-0">
219 − <span className={`adm-chip adm-chip--${r.priority} !text-[9px]`}>
220 − {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority}
221 − </span>
222 − <span className="adm-chip adm-chip--ghost !text-[9px]">
223 − {statusesFor(r.category)[r.status] ?? r.status}
298 + <span className="flex flex-none flex-col items-end gap-[3px]">
299 + <span className="gk-mono text-[10px] font-bold text-green">{r.reference}</span>
300 + <span className="flex items-center gap-2">
301 + {r.att_count > 0 ? (
302 + <span className="gk-mono text-[10px] text-ink-3">📎 {r.att_count}</span>
303 + ) : null}
304 + {r.ka_id ? (
305 + <span className="gk-mono text-[9px] font-bold tracking-[0.06em] text-ink-3 uppercase">KA ID</span>
306 + ) : null}
307 + <span className={`gk-mono text-[10px] ${r.is_read ? "text-ink-3" : "font-bold text-green"}`}>
308 + {timeAgo(r.created_at)}
309 + </span>
310 + {!r.is_read ? (
311 + <span className="h-[8px] w-[8px] rounded-full bg-green" aria-label="non lue" />
312 + ) : null}
224 313 </span>
225 314 </span>
226 − <span className="gk-mono mt-1 block truncate text-[10.5px] text-ink-2 lg:mt-0">
227 − {r.assignee ?? "—"}
228 − </span>
229 − <span className="gk-mono mt-1 block text-[10.5px] text-ink-3 lg:mt-0">
230 − {fmtDate(r.created_at)}
231 − {r.is_read ? null : <span className="ml-2 font-bold text-green">●</span>}
232 − </span>
233 315 </a>
234 316 );
235 317 })
236 318 )}
237 319 </div>
238 320
239 − {/* pagination */}
240 321 {pages > 1 ? (
241 322 <div className="gk-mono mt-5 flex items-center gap-4 text-[12px] font-bold">
242 323 {filters.page! > 1 ? (
243 324 <a href={qs({ page: String(filters.page! - 1) })} className="underline underline-offset-4">← Précédente</a>
244 325 ) : null}
245 − <span className="text-ink-3">
246 − Page {filters.page} / {pages}
247 − </span>
326 + <span className="text-ink-3">Page {filters.page} / {pages}</span>
248 327 {filters.page! < pages ? (
249 328 <a href={qs({ page: String(filters.page! + 1) })} className="underline underline-offset-4">Suivante →</a>
250 329 ) : null}
modified src/app/admin/connexion/LoginForm.tsx +45 −15
@@ -1,6 +1,7 @@
1 1 "use client";
2 2 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 −// Formulaire de connexion du panel — POST /api/admin/login puis /admin.
3 +// Formulaire de connexion du panel — champs généreux, œil pour afficher le
4 +// mot de passe, erreur avec secousse, état d'envoi. POST /api/admin/login.
4 5 import { useState } from "react";
5 6 import { useRouter } from "next/navigation";
6 7
@@ -8,8 +9,10 @@ export default function LoginForm() {
8 9 const router = useRouter();
9 10 const [username, setUsername] = useState("");
10 11 const [password, setPassword] = useState("");
12 + const [showPw, setShowPw] = useState(false);
11 13 const [error, setError] = useState("");
12 14 const [busy, setBusy] = useState(false);
15 + const [shake, setShake] = useState(0);
13 16
14 17 async function submit(e: React.FormEvent) {
15 18 e.preventDefault();
@@ -29,22 +32,26 @@ export default function LoginForm() {
29 32 return;
30 33 }
31 34 setError(data.error ?? "Connexion impossible.");
35 + setShake((n) => n + 1);
32 36 } catch {
33 37 setError("Connexion impossible — vérifiez le réseau.");
38 + setShake((n) => n + 1);
34 39 }
35 40 setBusy(false);
36 41 }
37 42
38 43 return (
39 − <form onSubmit={submit}>
44 + <form onSubmit={submit} key={shake} className={error ? "login-error" : ""}>
40 45 <label className="klabel mb-[6px] block" htmlFor="adm-user">
41 46 Identifiant
42 47 </label>
43 48 <input
44 49 id="adm-user"
45 − className="field"
50 + className="field field-lg"
46 51 autoComplete="username"
47 52 autoCapitalize="none"
53 + autoFocus
54 + placeholder="ex. erikabc"
48 55 value={username}
49 56 onChange={(e) => setUsername(e.target.value)}
50 57 required
@@ -52,20 +59,43 @@ export default function LoginForm() {
52 59 <label className="klabel mt-4 mb-[6px] block" htmlFor="adm-pass">
53 60 Mot de passe
54 61 </label>
55 − <input
56 − id="adm-pass"
57 − type="password"
58 − className="field"
59 − autoComplete="current-password"
60 − value={password}
61 − onChange={(e) => setPassword(e.target.value)}
62 − required
63 − />
62 + <div className="relative">
63 + <input
64 + id="adm-pass"
65 + type={showPw ? "text" : "password"}
66 + className="field field-lg !pr-[86px]"
67 + autoComplete="current-password"
68 + value={password}
69 + onChange={(e) => setPassword(e.target.value)}
70 + required
71 + />
72 + <button
73 + type="button"
74 + onClick={() => setShowPw((v) => !v)}
75 + className="gk-mono absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer rounded-md border border-[rgba(20,24,20,0.3)] px-2 py-[3px] text-[9.5px] font-bold tracking-[0.1em] text-ink-2 uppercase hover:bg-lime-soft"
76 + aria-pressed={showPw}
77 + >
78 + {showPw ? "Masquer" : "Afficher"}
79 + </button>
80 + </div>
64 81 {error ? (
65 − <p className="gk-mono mt-4 text-[11px] font-bold text-danger">{error}</p>
82 + <p className="gk-mono mt-4 rounded-lg border-[1.5px] border-danger bg-[var(--danger-soft)] px-3 py-[10px] text-[11px] leading-relaxed font-bold text-danger">
83 + {error}
84 + </p>
66 85 ) : null}
67 − <button type="submit" className="btn btn-primary mt-6 w-full" disabled={busy}>
68 − {busy ? "Connexion…" : "Ouvrir le panel"}
86 + <button
87 + type="submit"
88 + className="btn btn-primary mt-6 w-full !min-h-[50px] !text-[15px]"
89 + disabled={busy}
90 + >
91 + {busy ? (
92 + <span className="inline-flex items-center gap-2">
93 + <span className="inline-block h-[14px] w-[14px] animate-spin rounded-full border-2 border-lime border-t-transparent" />
94 + Vérification…
95 + </span>
96 + ) : (
97 + "Entrer dans le panel →"
98 + )}
69 99 </button>
70 100 </form>
71 101 );
modified src/app/admin/connexion/page.tsx +67 −24
@@ -1,6 +1,8 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −// /admin/connexion — porte du panel. Hors du groupe (panel) : pas de garde,
3 −// pas de coquille — juste la carte de connexion sur fond encre.
2 +// /admin/connexion — la porte du panel, version signature : mur encre avec
3 +// filigrane KA géant et lignes « ce que le panel contient » à gauche,
4 +// carte de connexion à ombre dure à droite. Mobile : la carte seule, sur
5 +// un condensé du mur. Hors du groupe (panel) : pas de garde ici.
4 6 import type { Metadata } from "next";
5 7 import { redirect } from "next/navigation";
6 8 import { getAdminSession } from "@/lib/comms/admin-auth";
@@ -11,32 +13,73 @@ export const metadata: Metadata = {
11 13 robots: { index: false, follow: false },
12 14 };
13 15
16 +const LINES: [string, string][] = [
17 + ["Inbox", "les demandes des 10 formulaires publics, priorisées et assignées"],
18 + ["Courriel", "erikabc@ et spboucher@groupe-ka.com — envoi et réception Resend"],
19 + ["Candidatures", "le pipeline d'embauche, du CV à l'offre"],
20 + ["Équipe & accès", "rôles, confidentialité et mots de passe"],
21 +];
22 +
14 23 export default async function AdminLoginPage() {
15 24 if (await getAdminSession()) redirect("/admin");
16 25 return (
17 − <main
18 − data-admin-root
19 − className="flex min-h-[100dvh] items-center justify-center bg-ink px-4"
20 − >
21 − <div className="w-full max-w-sm">
22 − <p className="gk-display text-center text-[26px] font-bold tracking-[-0.04em] text-paper">
23 − Groupe{" "}
24 − <span className="inline-block -rotate-2 rounded-md bg-lime px-[7px] pb-[2px] text-ink">
25 − KA
26 − </span>
27 − </p>
28 − <p className="gk-mono mt-2 text-center text-[10px] font-bold tracking-[0.22em] text-[rgba(245,243,238,0.5)] uppercase">
29 − Centre de communication · Administration
30 − </p>
31 − <div className="gk-card mt-8 p-7">
32 − <LoginForm />
26 + <main data-admin-root className="min-h-[100dvh] lg:grid lg:grid-cols-[1.05fr_1fr]">
27 + {/* mur encre — plein écran desktop, manchette compacte mobile */}
28 + <section className="login-wall flex flex-col justify-between px-6 py-8 lg:px-14 lg:py-12">
29 + <div className="login-watermark" aria-hidden="true">
30 + KA
31 + </div>
32 + <div className="relative">
33 + <p className="gk-display text-[26px] font-bold tracking-[-0.04em] lg:text-[32px]">
34 + Groupe{" "}
35 + <span className="inline-block -rotate-2 rounded-md bg-lime px-[8px] pb-[3px] text-ink">
36 + KA
37 + </span>
38 + </p>
39 + <p className="gk-mono mt-2 text-[10px] font-bold tracking-[0.24em] text-[rgba(245,243,238,0.55)] uppercase">
40 + Communication Hub · Administration
41 + </p>
42 + <h1 className="gk-display mt-8 hidden max-w-md text-[clamp(30px,3.2vw,44px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase lg:block">
43 + Une seule porte.
44 + <br />
45 + Tout le <span className="text-lime">courrier</span> du groupe.
46 + </h1>
47 + </div>
48 + <div className="relative mt-8 hidden lg:block">
49 + {LINES.map(([k, v]) => (
50 + <p key={k} className="login-line">
51 + <span className="pulse-dot" aria-hidden="true" />
52 + <b>{k}</b>
53 + <span className="font-normal normal-case tracking-normal">{v}</span>
54 + </p>
55 + ))}
56 + <p className="gk-mono mt-6 text-[9.5px] font-bold tracking-[0.18em] text-[rgba(245,243,238,0.35)] uppercase">
57 + Accès réservé · toutes les actions sont journalisées · RBAC serveur
58 + </p>
59 + </div>
60 + </section>
61 +
62 + {/* carte de connexion */}
63 + <section className="flex flex-1 items-start justify-center bg-ink px-4 pt-6 pb-14 lg:items-center lg:bg-paper lg:px-10 lg:pt-10">
64 + <div className="w-full max-w-[400px]">
65 + <div className="login-card p-7 sm:p-9">
66 + <p className="kicker">Connexion</p>
67 + <h2 className="gk-display mt-3 text-[24px] leading-tight font-bold tracking-[-0.03em]">
68 + Ouvrir le panel
69 + </h2>
70 + <p className="mt-2 text-[13px] leading-relaxed text-ink-2">
71 + Votre boîte courriel, l&apos;inbox des demandes et le pipeline —
72 + au même endroit.
73 + </p>
74 + <div className="mt-6">
75 + <LoginForm />
76 + </div>
77 + </div>
78 + <p className="gk-mono mt-5 text-center text-[9.5px] font-bold tracking-[0.16em] text-[rgba(245,243,238,0.45)] uppercase lg:text-ink-3">
79 + Session de 12 heures · chiffrée · révoquée au changement de mot de passe
80 + </p>
33 81 </div>
34 − <p className="gk-mono mt-6 text-center text-[10px] leading-relaxed text-[rgba(245,243,238,0.4)]">
35 − Accès réservé à l&apos;équipe Groupe KA.
36 − <br />
37 − Toutes les actions sont journalisées.
38 − </p>
39 − </div>
82 + </section>
40 83 </main>
41 84 );
42 85 }
modified src/app/globals.css +123 −0
@@ -1236,3 +1236,126 @@ body:has([data-admin-root]) {
1236 1236 color: var(--ink);
1237 1237 background: var(--lime);
1238 1238 }
1239 +
1240 +/* ---------- Panel v2 : primitives visuelles supplémentaires ---------- */
1241 +
1242 +/* Avatar-pastille d'un correspondant (initiales, teinte stable) */
1243 +.adm-avatar {
1244 + display: flex;
1245 + align-items: center;
1246 + justify-content: center;
1247 + width: 40px;
1248 + height: 40px;
1249 + flex: none;
1250 + border-radius: 12px;
1251 + border: 1.5px solid var(--ink);
1252 + box-shadow: 2px 2px 0 rgba(20, 24, 20, 0.16);
1253 + font-family: var(--font-display);
1254 + font-weight: 700;
1255 + font-size: 14px;
1256 + letter-spacing: -0.02em;
1257 +}
1258 +.adm-avatar--sm {
1259 + width: 32px;
1260 + height: 32px;
1261 + font-size: 12px;
1262 + border-radius: 9px;
1263 +}
1264 +
1265 +/* Rangée horizontale scrollable sans barre (pilules mobiles) */
1266 +.hscroll {
1267 + display: flex;
1268 + gap: 8px;
1269 + overflow-x: auto;
1270 + -webkit-overflow-scrolling: touch;
1271 + scrollbar-width: none;
1272 + padding-bottom: 2px;
1273 +}
1274 +.hscroll::-webkit-scrollbar {
1275 + display: none;
1276 +}
1277 +.hscroll > * {
1278 + flex: none;
1279 +}
1280 +
1281 +/* Rangées : cibles tactiles généreuses sur mobile */
1282 +@media (max-width: 1023px) {
1283 + .adm-row {
1284 + padding: 14px 14px 15px;
1285 + }
1286 +}
1287 +
1288 +/* Filtres repliables (mobile) — même patron checkbox que le menu du site */
1289 +.adm-filter-form {
1290 + display: none;
1291 +}
1292 +#adm-filtres:checked ~ .adm-filter-form {
1293 + display: grid;
1294 +}
1295 +@media (min-width: 1024px) {
1296 + .adm-filter-form {
1297 + display: grid;
1298 + }
1299 + .adm-filter-toggle {
1300 + display: none;
1301 + }
1302 +}
1303 +
1304 +/* ---------- Connexion /admin : écran scindé signature ---------- */
1305 +
1306 +.login-wall {
1307 + position: relative;
1308 + overflow: hidden;
1309 + background: var(--ink);
1310 + color: var(--paper);
1311 +}
1312 +.login-watermark {
1313 + position: absolute;
1314 + right: -60px;
1315 + bottom: -110px;
1316 + pointer-events: none;
1317 + font-family: var(--font-display);
1318 + font-weight: 700;
1319 + font-size: clamp(280px, 34vw, 520px);
1320 + line-height: 0.8;
1321 + letter-spacing: -0.06em;
1322 + color: rgba(217, 242, 107, 0.07);
1323 + transform: rotate(-6deg);
1324 + user-select: none;
1325 +}
1326 +.login-line {
1327 + display: flex;
1328 + align-items: center;
1329 + gap: 12px;
1330 + padding: 13px 2px;
1331 + border-top: 1px dashed rgba(245, 243, 238, 0.18);
1332 + font-family: var(--font-mono);
1333 + font-size: 11px;
1334 + font-weight: 700;
1335 + letter-spacing: 0.14em;
1336 + text-transform: uppercase;
1337 + color: rgba(245, 243, 238, 0.6);
1338 +}
1339 +.login-line b {
1340 + color: var(--lime);
1341 +}
1342 +.login-card {
1343 + background: var(--surface);
1344 + border: 2px solid var(--ink);
1345 + border-radius: 14px;
1346 + box-shadow: 9px 9px 0 rgba(20, 24, 20, 0.9);
1347 +}
1348 +.field-lg {
1349 + padding: 14px 16px;
1350 + font-size: 15px;
1351 + border-radius: 9px;
1352 +}
1353 +@keyframes login-shake {
1354 + 10%, 90% { transform: translateX(-1px); }
1355 + 20%, 80% { transform: translateX(2px); }
1356 + 30%, 50%, 70% { transform: translateX(-4px); }
1357 + 40%, 60% { transform: translateX(4px); }
1358 +}
1359 +.login-error {
1360 + animation: login-shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
1361 +}
modified src/lib/comms/mail.ts +39 −7
@@ -163,33 +163,65 @@ export function findMail(id: number): MailRow | undefined {
163 163 | undefined;
164 164 }
165 165
166 −/** Boîte d'un admin : ses messages + (super_admin) les non-routés. */
166 +/** Boîte d'un admin : ses messages + (super_admin) les non-routés.
167 + Recherche plein champ facultative (expéditeur, sujet, extrait). */
167 168 export function listMail(
168 169 adminId: number,
169 170 includeUnrouted: boolean,
170 171 folder: string,
171 172 page = 1,
172 − pageSize = 40,
173 −): { rows: MailRow[]; total: number } {
173 + pageSize = 30,
174 + q?: string,
175 +): { rows: (MailRow & { att_count: number })[]; total: number } {
174 176 const owner = includeUnrouted
175 177 ? "(admin_id = ? OR admin_id IS NULL)"
176 178 : "admin_id = ?";
179 + const search = q
180 + ? "AND (from_email LIKE ? OR from_name LIKE ? OR subject LIKE ? OR snippet LIKE ? OR to_emails LIKE ?)"
181 + : "";
182 + const like = `%${q ?? ""}%`;
183 + const searchArgs = q ? [like, like, like, like, like] : [];
177 184 const total = (
178 185 commsDb
179 186 .prepare(
180 − `SELECT COUNT(*) AS c FROM mail_messages WHERE ${owner} AND folder = ?`,
187 + `SELECT COUNT(*) AS c FROM mail_messages WHERE ${owner} AND folder = ? ${search}`,
181 188 )
182 − .get(adminId, folder) as { c: number }
189 + .get(adminId, folder, ...searchArgs) as { c: number }
183 190 ).c;
184 191 const rows = commsDb
185 192 .prepare(
186 − `SELECT * FROM mail_messages WHERE ${owner} AND folder = ?
193 + `SELECT m.*,
194 + (SELECT COUNT(*) FROM mail_attachments a WHERE a.message_id = m.id) AS att_count
195 + FROM mail_messages m WHERE ${owner} AND folder = ? ${search}
187 196 ORDER BY created_at DESC LIMIT ? OFFSET ?`,
188 197 )
189 − .all(adminId, folder, pageSize, (page - 1) * pageSize) as MailRow[];
198 + .all(adminId, folder, ...searchArgs, pageSize, (page - 1) * pageSize) as (MailRow & {
199 + att_count: number;
200 + })[];
190 201 return { rows, total };
191 202 }
192 203
204 +/** Compteurs par dossier (total + non-lus) — pilules de la boîte. */
205 +export function folderCounts(
206 + adminId: number,
207 + includeUnrouted: boolean,
208 +): Record<string, { total: number; unread: number }> {
209 + const owner = includeUnrouted
210 + ? "(admin_id = ? OR admin_id IS NULL)"
211 + : "admin_id = ?";
212 + const rows = commsDb
213 + .prepare(
214 + `SELECT folder, COUNT(*) AS total, SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) AS unread
215 + FROM mail_messages WHERE ${owner} GROUP BY folder`,
216 + )
217 + .all(adminId) as { folder: string; total: number; unread: number }[];
218 + const out: Record<string, { total: number; unread: number }> = {};
219 + for (const f of Object.keys(MAIL_FOLDERS)) out[f] = { total: 0, unread: 0 };
220 + for (const r of rows)
221 + if (out[r.folder]) out[r.folder] = { total: r.total, unread: r.unread ?? 0 };
222 + return out;
223 +}
224 +
193 225 export function unreadMailCount(adminId: number, includeUnrouted: boolean): number {
194 226 const owner = includeUnrouted
195 227 ? "(admin_id = ? OR admin_id IS NULL)"
modified src/lib/comms/queries.ts +29 −2
@@ -48,7 +48,10 @@ function rbacWhere(s: AdminSession): { sql: string; args: (string | number)[] }
48 48 export function listSubmissions(
49 49 s: AdminSession,
50 50 f: InboxFilters,
51 −): { rows: (SubmissionRow & { assignee: string | null })[]; total: number } {
51 +): {
52 + rows: (SubmissionRow & { assignee: string | null; att_count: number })[];
53 + total: number;
54 +} {
52 55 const rbac = rbacWhere(s);
53 56 const where: string[] = [rbac.sql];
54 57 const args: (string | number)[] = [...rbac.args];
@@ -108,7 +111,8 @@ export function listSubmissions(
108 111 const page = Math.max(1, f.page ?? 1);
109 112 const rows = commsDb
110 113 .prepare(
111 − `SELECT cs.*, a.display_name AS assignee
114 + `SELECT cs.*, a.display_name AS assignee,
115 + (SELECT COUNT(*) FROM contact_attachments ca WHERE ca.submission_id = cs.id) AS att_count
112 116 FROM contact_submissions cs
113 117 LEFT JOIN admin_users a ON a.id = cs.assigned_to
114 118 WHERE ${whereSql}
@@ -120,6 +124,7 @@ export function listSubmissions(
120 124 )
121 125 .all(...args, PAGE_SIZE, (page - 1) * PAGE_SIZE) as (SubmissionRow & {
122 126 assignee: string | null;
127 + att_count: number;
123 128 })[];
124 129 return { rows, total };
125 130 }
@@ -202,6 +207,28 @@ export function recentSubmissions(
202 207 .all(...rbac.args, limit) as (SubmissionRow & { assignee: string | null })[];
203 208 }
204 209
210 +/** Compteurs par statut (vue actives, catégorie facultative) — chips inbox. */
211 +export function statusCounts(
212 + s: AdminSession,
213 + category?: string,
214 +): Record<string, number> {
215 + const rbac = rbacWhere(s);
216 + const extra = category ? "AND category = ?" : "";
217 + const rows = commsDb
218 + .prepare(
219 + `SELECT status, COUNT(*) AS c FROM contact_submissions
220 + WHERE ${rbac.sql} AND is_archived = 0 AND is_spam = 0 ${extra}
221 + GROUP BY status`,
222 + )
223 + .all(...rbac.args, ...(category ? [category] : [])) as {
224 + status: string;
225 + c: number;
226 + }[];
227 + const out: Record<string, number> = {};
228 + for (const r of rows) out[r.status] = r.c;
229 + return out;
230 +}
231 +
205 232 export function unreadCount(s: AdminSession): number {
206 233 const rbac = rbacWhere(s);
207 234 return (
208 235