Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /admin/inbox — toutes les demandes, version v2 : recherche toujours3// 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 reste6// appliqué dans la requête SQL.7import { redirect } from "next/navigation";8import { getAdminSession } from "@/lib/comms/admin-auth";9import {10 listSubmissions,11 statusCounts,12 type InboxFilters,13} from "@/lib/comms/queries";14import { listAdmins } from "@/lib/comms/db";15import {16 CATEGORIES,17 categoryBySlug,18 PRIORITIES,19 SITES_KA,20 STATUSES,21 JOB_STATUSES,22 statusesFor,23} from "@/lib/comms/categories";24import { timeAgo, avatarStyle } from "../format";2526export const dynamic = "force-dynamic";2728const QUICK = [29 { label: "Investisseurs", category: "investisseurs" },30 { label: "Candidatures", category: "carrieres" },31 { label: "Légal", category: "legal" },32 { label: "Sécurité", category: "securite" },33 { label: "Médias", category: "medias" },34] as const;3536export default async function InboxPage({37 searchParams,38}: {39 searchParams: Promise<Record<string, string | string[] | undefined>>;40}) {41 const session = await getAdminSession();42 if (!session) redirect("/admin/connexion");43 const sp = await searchParams;44 const one = (k: string): string =>45 typeof sp[k] === "string" ? (sp[k] as string).slice(0, 200) : "";4647 const filters: InboxFilters = {48 category: one("category") || undefined,49 subCategory: one("sub") || undefined,50 status: one("status") || undefined,51 priority: one("priority") || undefined,52 site: one("site") || undefined,53 assigned: one("assigned") || undefined,54 kaId: one("ka_id") || undefined,55 q: one("q") || undefined,56 view: (["actives", "archivees", "spam", "toutes"].includes(one("view"))57 ? one("view")58 : "actives") as InboxFilters["view"],59 page: Math.max(1, Number(one("page")) || 1),60 };61 const { rows, total } = listSubmissions(session, filters);62 const byStatus = statusCounts(session, filters.category);63 const admins = listAdmins().filter((a) => a.active);64 const pages = Math.max(1, Math.ceil(total / 40));65 const currentCat = filters.category ? categoryBySlug(filters.category) : undefined;66 // libellés génériques prioritaires (« nouvelle » existe dans les deux jeux)67 const statusOptions = filters.category68 ? statusesFor(filters.category)69 : { ...JOB_STATUSES, ...STATUSES };7071 const qs = (patch: Record<string, string>) => {72 const params = new URLSearchParams();73 for (const [k, v] of Object.entries({74 category: filters.category ?? "",75 sub: filters.subCategory ?? "",76 status: filters.status ?? "",77 priority: filters.priority ?? "",78 site: filters.site ?? "",79 assigned: filters.assigned ?? "",80 ka_id: filters.kaId ?? "",81 q: filters.q ?? "",82 view: filters.view === "actives" ? "" : (filters.view ?? ""),83 ...patch,84 }))85 if (v) params.set(k, v);86 const s = params.toString();87 return s ? `/admin/inbox?${s}` : "/admin/inbox";88 };89 // champs cachés qui préservent les filtres dans le formulaire de recherche90 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][];9899 return (100 <>101 <div className="flex flex-wrap items-end justify-between gap-4">102 <div>103 <p className="kicker">Inbox</p>104 <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]">105 {total} demande{total > 1 ? "s" : ""}106 {currentCat ? ` · ${currentCat.title}` : ""}107 {filters.q ? ` · « ${filters.q} »` : ""}108 </h1>109 </div>110 <div className="hscroll max-w-full">111 {QUICK.map((qf) => (112 <a113 key={qf.category}114 href={qs({ category: filters.category === qf.category ? "" : qf.category, status: "", page: "" })}115 className={`adm-chip no-underline ${filters.category === qf.category ? "adm-chip--lime" : ""}`}116 >117 {qf.label}118 </a>119 ))}120 </div>121 </div>122123 {/* 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 <input133 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 <label144 htmlFor="adm-filtres"145 className="adm-filter-toggle btn btn-ghost !min-h-[42px] cursor-pointer !px-4 !text-[12.5px]"146 >147 Filtres148 </label>149 </div>150151 {/* filtres avancés — repliés sur mobile, toujours visibles desktop */}152 <form153 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}158 <div>159 <label className="klabel mb-[4px] block" htmlFor="f-cat">Catégorie</label>160 <select id="f-cat" name="category" defaultValue={filters.category ?? ""} className="field cursor-pointer !py-[8px]">161 <option value="">Toutes</option>162 {CATEGORIES.map((c) => (163 <option key={c.slug} value={c.slug}>{c.short}</option>164 ))}165 </select>166 </div>167 <div>168 <label className="klabel mb-[4px] block" htmlFor="f-status">Statut</label>169 <select id="f-status" name="status" defaultValue={filters.status ?? ""} className="field cursor-pointer !py-[8px]">170 <option value="">Tous</option>171 {Object.entries(statusOptions).map(([k, v]) => (172 <option key={k} value={k}>{v}</option>173 ))}174 </select>175 </div>176 <div>177 <label className="klabel mb-[4px] block" htmlFor="f-prio">Priorité</label>178 <select id="f-prio" name="priority" defaultValue={filters.priority ?? ""} className="field cursor-pointer !py-[8px]">179 <option value="">Toutes</option>180 {Object.entries(PRIORITIES).map(([k, v]) => (181 <option key={k} value={k}>{v}</option>182 ))}183 </select>184 </div>185 <div>186 <label className="klabel mb-[4px] block" htmlFor="f-site">Site concerné</label>187 <select id="f-site" name="site" defaultValue={filters.site ?? ""} className="field cursor-pointer !py-[8px]">188 <option value="">Tous</option>189 {SITES_KA.map((s) => (190 <option key={s} value={s}>{s}</option>191 ))}192 </select>193 </div>194 <div>195 <label className="klabel mb-[4px] block" htmlFor="f-ass">Assignée à</label>196 <select id="f-ass" name="assigned" defaultValue={filters.assigned ?? ""} className="field cursor-pointer !py-[8px]">197 <option value="">Peu importe</option>198 <option value="moi">Moi</option>199 <option value="personne">Personne</option>200 {admins.map((a) => (201 <option key={a.id} value={String(a.id)}>{a.display_name}</option>202 ))}203 </select>204 </div>205 <div>206 <label className="klabel mb-[4px] block" htmlFor="f-view">Vue</label>207 <select id="f-view" name="view" defaultValue={filters.view} className="field cursor-pointer !py-[8px]">208 <option value="actives">Actives</option>209 <option value="toutes">Toutes</option>210 <option value="archivees">Archivées</option>211 <option value="spam">Spam</option>212 </select>213 </div>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 filtres217 </button>218 <a href="/admin/inbox" className="btn btn-ghost !min-h-[38px] !px-4 !text-[13px]">219 Tout effacer220 </a>221 </div>222 </form>223224 {/* chips de statut avec compteurs */}225 <div className="hscroll mt-4">226 <a227 href={qs({ status: "", page: "" })}228 className={`adm-chip no-underline ${!filters.status ? "adm-chip--lime" : ""}`}229 >230 Tous statuts231 </a>232 {Object.entries(statusOptions).map(([k, v]) =>233 (byStatus[k] ?? 0) > 0 || filters.status === k ? (234 <a235 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>244245 {/* liste */}246 <div className="gk-card mt-4 overflow-hidden !shadow-none">247 {rows.length === 0 ? (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>254 ) : (255 rows.map((r) => {256 const cat = categoryBySlug(r.category);257 const who = r.is_anonymous258 ? "Anonyme"259 : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—";260 return (261 <a262 key={r.id}263 href={`/admin/demande/${r.reference}`}264 className={`adm-row !flex items-center gap-3 sm:gap-4 ${r.is_read ? "" : "adm-row--unread"}`}265 >266 <span267 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 ?? "?"}272 </span>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>287 </span>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>296 </span>297 </span>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}313 </span>314 </span>315 </a>316 );317 })318 )}319 </div>320321 {pages > 1 ? (322 <div className="gk-mono mt-5 flex items-center gap-4 text-[12px] font-bold">323 {filters.page! > 1 ? (324 <a href={qs({ page: String(filters.page! - 1) })} className="underline underline-offset-4">← Précédente</a>325 ) : null}326 <span className="text-ink-3">Page {filters.page} / {pages}</span>327 {filters.page! < pages ? (328 <a href={qs({ page: String(filters.page! + 1) })} className="underline underline-offset-4">Suivante →</a>329 ) : null}330 </div>331 ) : null}332 </>333 );334}335