SPB Git

Repository Public

page.tsx 13.9 KB 335 lines tsx

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
Files main
Menu

Theme