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.
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 & 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'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'é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 | |