Repository Public
1"use client";2// Auteur : Simon-Pierre Boucher — contact@spboucher.ai3// /admin/courriel — composants interactifs. Composeur v3 : gabarits Groupe KA4// (sélecteur visuel avec mini-aperçus, aperçu fidèle en iframe sandbox rendu5// par le serveur), pièces jointes (3 × 8 Mo), envoi multipart. L'expéditeur6// et l'HTML final sont TOUJOURS décidés serveur — le client n'envoie que du7// texte et l'identifiant du gabarit.8import { useRef, useState } from "react";9import { useRouter } from "next/navigation";10import { MAIL_TEMPLATES } from "@/lib/comms/mail-templates";1112function fmtBytes(n: number): string {13 if (n < 1024) return `${n} o`;14 if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;15 return `${(n / 1024 / 1024).toFixed(1)} Mo`;16}1718/** Mini-aperçu stylisé d'un gabarit (pur CSS — l'aperçu fidèle est l'iframe). */19function TemplateThumb({ id, accent }: { id: string; accent: string }) {20 return (21 <span className="flex h-[52px] w-full flex-col justify-between rounded-md border border-[rgba(20,24,20,0.35)] bg-[#f5f3ee] p-[6px]" aria-hidden="true">22 <span className="flex items-center gap-1">23 <span className="gk-display text-[7px] leading-none font-bold">24 G<span className="rounded-[2px] bg-lime px-[2px]">KA</span>25 </span>26 {id === "annonce" ? (27 <span className="ml-auto h-[10px] w-[52%] rounded-[2px] bg-ink" />28 ) : null}29 </span>30 <span31 className={`flex flex-col gap-[3px] rounded-[3px] p-[4px] ${id === "simple" ? "" : "border border-ink bg-white shadow-[2px_2px_0_rgba(20,24,20,0.55)]"}`}32 >33 <span className="h-[3px] w-[38%] rounded-full" style={{ background: accent }} />34 <span className="h-[2.5px] w-[92%] rounded-full bg-[rgba(20,24,20,0.28)]" />35 <span className="h-[2.5px] w-[74%] rounded-full bg-[rgba(20,24,20,0.28)]" />36 <span className="mt-[2px] h-[3px] w-[30%] rounded-full bg-[rgba(28,92,65,0.55)]" />37 </span>38 </span>39 );40}4142export function MailComposer({43 fromEmail,44 replyTo,45 startOpen = false,46}: {47 fromEmail: string;48 /** préremplissage en mode réponse */49 replyTo?: { id: number; to: string; subject: string } | null;50 startOpen?: boolean;51}) {52 const router = useRouter();53 const [open, setOpen] = useState(startOpen);54 const [to, setTo] = useState(replyTo?.to ?? "");55 const [cc, setCc] = useState("");56 const [subject, setSubject] = useState(57 replyTo ? (replyTo.subject.startsWith("Re:") ? replyTo.subject : `Re: ${replyTo.subject}`) : "",58 );59 const [body, setBody] = useState("");60 const [template, setTemplate] = useState("simple");61 const [files, setFiles] = useState<File[]>([]);62 const [busy, setBusy] = useState(false);63 const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);64 const [preview, setPreview] = useState<string | null>(null);65 const [previewBusy, setPreviewBusy] = useState(false);66 const fileRef = useRef<HTMLInputElement>(null);6768 function addFiles(list: FileList | null) {69 if (!list) return;70 const next = [...files];71 for (const f of Array.from(list)) {72 if (next.length >= 3) break;73 if (f.size > 8 * 1024 * 1024) {74 setMsg({ ok: false, text: `« ${f.name} » dépasse 8 Mo.` });75 continue;76 }77 if (!next.some((x) => x.name === f.name && x.size === f.size)) next.push(f);78 }79 setFiles(next);80 if (fileRef.current) fileRef.current.value = "";81 }8283 async function showPreview() {84 setPreviewBusy(true);85 try {86 const res = await fetch("/api/admin/mail/preview", {87 method: "POST",88 headers: { "Content-Type": "application/json" },89 body: JSON.stringify({ template, subject, body }),90 });91 const data = (await res.json().catch(() => ({}))) as { html?: string };92 if (res.ok && data.html) setPreview(data.html);93 } catch {94 setMsg({ ok: false, text: "Aperçu indisponible — réseau ?" });95 }96 setPreviewBusy(false);97 }9899 async function send(e: React.FormEvent) {100 e.preventDefault();101 if (busy) return;102 setBusy(true);103 setMsg(null);104 try {105 const fd = new FormData();106 fd.set("to", to);107 fd.set("cc", cc);108 fd.set("subject", subject);109 fd.set("body", body);110 fd.set("template", template);111 if (replyTo) fd.set("reply_to_id", String(replyTo.id));112 for (const f of files) fd.append("files", f);113 const res = await fetch("/api/admin/mail/send", { method: "POST", body: fd });114 const data = (await res.json().catch(() => ({}))) as { error?: string };115 if (res.ok) {116 setMsg({ ok: true, text: "Courriel envoyé — il est dans vos Envoyés." });117 setBody("");118 setFiles([]);119 if (!replyTo) {120 setTo("");121 setCc("");122 setSubject("");123 }124 router.refresh();125 } else setMsg({ ok: false, text: data.error ?? "Envoi refusé." });126 } catch {127 setMsg({ ok: false, text: "Réseau indisponible." });128 }129 setBusy(false);130 }131132 if (!open)133 return (134 <button className="btn btn-primary" onClick={() => setOpen(true)}>135 {replyTo ? "Répondre" : "Nouveau courriel"}136 </button>137 );138139 return (140 <>141 <form onSubmit={send} className="gk-card mt-2 w-full p-5 !shadow-none sm:p-6">142 <div className="flex items-center justify-between gap-3">143 <p className="kicker">{replyTo ? "Répondre" : "Nouveau courriel"}</p>144 <button145 type="button"146 className="gk-mono cursor-pointer text-[10.5px] font-bold tracking-[0.1em] text-ink-3 uppercase hover:text-ink"147 onClick={() => setOpen(false)}148 >149 Fermer ✕150 </button>151 </div>152 <p className="gk-mono mt-3 text-[11px] text-ink-3">153 De : <strong className="text-ink">{fromEmail}</strong> · signé automatiquement154 </p>155156 <div className="mt-4 grid gap-3 sm:grid-cols-2">157 <div className={replyTo ? "sm:col-span-2" : ""}>158 <label className="klabel mb-[4px] block" htmlFor="mc-to">À (virgules pour plusieurs)</label>159 <input id="mc-to" className="field !py-[9px]" value={to} onChange={(e) => setTo(e.target.value)} required placeholder="personne@exemple.com" />160 </div>161 {!replyTo ? (162 <div>163 <label className="klabel mb-[4px] block" htmlFor="mc-cc">Cc (facultatif)</label>164 <input id="mc-cc" className="field !py-[9px]" value={cc} onChange={(e) => setCc(e.target.value)} />165 </div>166 ) : null}167 </div>168 <label className="klabel mt-3 mb-[4px] block" htmlFor="mc-subject">Sujet</label>169 <input id="mc-subject" className="field !py-[9px]" value={subject} onChange={(e) => setSubject(e.target.value)} required />170171 {/* ---------- gabarit ---------- */}172 <p className="klabel mt-5 mb-[6px]">Gabarit Groupe KA</p>173 <div className="grid grid-cols-3 gap-2 sm:grid-cols-6">174 {MAIL_TEMPLATES.map((t) => (175 <button176 key={t.id}177 type="button"178 onClick={() => setTemplate(t.id)}179 title={t.desc}180 aria-pressed={template === t.id}181 className={`flex cursor-pointer flex-col gap-[6px] rounded-lg border-[1.5px] p-2 text-left transition-transform ${182 template === t.id183 ? "border-ink bg-lime-soft shadow-[3px_3px_0_rgba(20,24,20,0.85)]"184 : "border-[rgba(20,24,20,0.25)] bg-surface hover:-translate-y-[1px] hover:border-ink"185 }`}186 >187 <TemplateThumb id={t.id} accent={t.accent} />188 <span className="gk-display text-[11px] leading-tight font-bold">{t.label}</span>189 </button>190 ))}191 </div>192 <p className="gk-mono mt-[6px] text-[10px] leading-relaxed text-ink-3">193 {MAIL_TEMPLATES.find((t) => t.id === template)?.desc} Votre message est194 habillé aux couleurs Groupe KA et signé « {fromEmail} ».195 </p>196197 <label className="klabel mt-4 mb-[4px] block" htmlFor="mc-body">Message</label>198 <textarea199 id="mc-body"200 rows={9}201 className="field resize-y"202 value={body}203 onChange={(e) => setBody(e.target.value)}204 required205 placeholder={"Bonjour,\n\nVotre texte — les doubles sauts de ligne deviennent des paragraphes.\n\nLa signature est ajoutée automatiquement."}206 />207208 {/* ---------- pièces jointes ---------- */}209 <p className="klabel mt-4 mb-[6px]">Pièces jointes (3 max · 8 Mo chacune)</p>210 <div className="flex flex-wrap items-center gap-2">211 {files.map((f, i) => (212 <span key={`${f.name}-${i}`} className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-[7px] text-[11px] font-bold">213 📎 {f.name}214 <span className="font-normal text-ink-3">{fmtBytes(f.size)}</span>215 <button216 type="button"217 aria-label={`Retirer ${f.name}`}218 className="cursor-pointer text-danger hover:scale-110"219 onClick={() => setFiles(files.filter((_, j) => j !== i))}220 >221 ✕222 </button>223 </span>224 ))}225 {files.length < 3 ? (226 <label className="gk-mono inline-flex cursor-pointer items-center gap-2 rounded-lg border-[1.5px] border-dashed border-[rgba(20,24,20,0.45)] px-3 py-[7px] text-[11px] font-bold text-ink-2 hover:border-ink hover:bg-lime-soft">227 + Ajouter un fichier228 <input229 ref={fileRef}230 type="file"231 multiple232 className="sr-only"233 accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.png,.jpg,.jpeg,.webp,.zip"234 onChange={(e) => addFiles(e.target.files)}235 />236 </label>237 ) : null}238 </div>239240 <div className="mt-5 flex flex-wrap items-center gap-3">241 <button type="submit" className="btn btn-primary !min-h-[42px] !px-6" disabled={busy}>242 {busy ? "Envoi…" : `Envoyer${files.length ? ` (${files.length} 📎)` : ""}`}243 </button>244 <button245 type="button"246 className="btn btn-ghost !min-h-[42px] !px-5"247 onClick={showPreview}248 disabled={previewBusy}249 >250 {previewBusy ? "Rendu…" : "Aperçu du courriel"}251 </button>252 <p className="gk-mono max-w-[260px] text-[9.5px] leading-relaxed text-ink-3">253 Envoyé par Resend depuis votre adresse — copie et pièces jointes254 conservées dans la base.255 </p>256 </div>257 {msg ? (258 <p className={`gk-mono mt-3 rounded-lg border-[1.5px] px-3 py-2 text-[11px] font-bold ${msg.ok ? "border-green bg-lime-soft text-green" : "border-danger bg-[var(--danger-soft)] text-danger"}`}>259 {msg.text}260 </p>261 ) : null}262 </form>263264 {/* ---------- aperçu fidèle (rendu serveur, iframe sandbox) ---------- */}265 {preview !== null ? (266 <div267 className="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center bg-[rgba(20,24,20,0.55)] p-3 sm:p-8"268 role="dialog"269 aria-label="Aperçu du courriel"270 onClick={() => setPreview(null)}271 >272 <div273 className="flex h-full max-h-[860px] w-full max-w-[680px] flex-col overflow-hidden rounded-xl border-2 border-ink bg-paper shadow-[10px_10px_0_rgba(20,24,20,0.5)]"274 onClick={(e) => e.stopPropagation()}275 >276 <div className="flex items-center justify-between border-b-[1.5px] border-ink bg-ink px-4 py-3">277 <p className="gk-mono text-[10px] font-bold tracking-[0.18em] text-lime uppercase">278 Aperçu — ce que recevra le destinataire279 </p>280 <button281 className="gk-mono cursor-pointer text-[11px] font-bold tracking-[0.1em] text-paper uppercase hover:text-lime"282 onClick={() => setPreview(null)}283 >284 Fermer ✕285 </button>286 </div>287 <iframe288 srcDoc={preview}289 sandbox=""290 title="Aperçu du courriel"291 className="h-full w-full flex-1 bg-white"292 />293 </div>294 </div>295 ) : null}296 </>297 );298}299300export function MailActions({301 id,302 folder,303 isRead,304 unrouted,305}: {306 id: number;307 folder: string;308 isRead: boolean;309 unrouted: boolean;310}) {311 const router = useRouter();312 const [busy, setBusy] = useState(false);313314 async function act(patch: Record<string, unknown>, thenList = false) {315 setBusy(true);316 await fetch(`/api/admin/mail/${id}`, {317 method: "PATCH",318 headers: { "Content-Type": "application/json" },319 body: JSON.stringify(patch),320 }).catch(() => {});321 setBusy(false);322 if (thenList) router.push("/admin/courriel");323 router.refresh();324 }325326 const b = "btn btn-ghost !min-h-[34px] !px-4 !text-[12px]";327 return (328 <div className="flex flex-wrap gap-2">329 {unrouted ? (330 <button className={`${b} !bg-lime`} disabled={busy} onClick={() => act({ claim: true })}>331 M'attribuer ce message332 </button>333 ) : null}334 <button className={b} disabled={busy} onClick={() => act({ is_read: !isRead })}>335 {isRead ? "Marquer non lu" : "Marquer lu"}336 </button>337 {folder !== "archive" ? (338 <button className={b} disabled={busy} onClick={() => act({ folder: "archive" }, true)}>339 Archiver340 </button>341 ) : null}342 {folder !== "trash" ? (343 <button className={b} disabled={busy} onClick={() => act({ folder: "trash" }, true)}>344 Corbeille345 </button>346 ) : (347 <button className={b} disabled={busy} onClick={() => act({ folder: "inbox" })}>348 Restaurer349 </button>350 )}351 </div>352 );353}354355/** Actions rapides d'une rangée de la liste (desktop) : lu/non-lu, archiver. */356export function MailRowQuick({357 id,358 isRead,359 folder,360}: {361 id: number;362 isRead: boolean;363 folder: string;364}) {365 const router = useRouter();366 const [busy, setBusy] = useState(false);367368 async function act(patch: Record<string, unknown>) {369 setBusy(true);370 await fetch(`/api/admin/mail/${id}`, {371 method: "PATCH",372 headers: { "Content-Type": "application/json" },373 body: JSON.stringify(patch),374 }).catch(() => {});375 setBusy(false);376 router.refresh();377 }378379 const b =380 "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";381 return (382 <span className="hidden flex-none items-center gap-1 sm:flex">383 <button384 className={b}385 disabled={busy}386 title={isRead ? "Marquer non lu" : "Marquer lu"}387 onClick={() => act({ is_read: !isRead })}388 >389 {isRead ? "Non lu" : "Lu"}390 </button>391 {folder !== "archive" ? (392 <button className={b} disabled={busy} title="Archiver" onClick={() => act({ folder: "archive" })}>393 Archiver394 </button>395 ) : (396 <button className={b} disabled={busy} title="Restaurer" onClick={() => act({ folder: "inbox" })}>397 Restaurer398 </button>399 )}400 </span>401 );402}403