SPB Git

Repository Public

dashboard.tsx 14.0 KB 206 lines tsx

1"use client";2import { useEffect, useState } from "react";3import Link from "next/link";4import { useLocale, useT } from "@/i18n";5import { adminAgenda } from "@/i18n/admin-agenda";6import { api } from "@/lib/client-api";7import { Avatar, Badge, Button, cx, Empty, Icon, Notice, toast, type IconName } from "@/components/ui";8import { Loadable, PageHeader, requestsChanged, useAdmin, useLoad } from "@/components/admin/shell";9import { addDays, fmtDay, fmtRange, minutesInTz, tzAbbr, ymdInTz } from "@/lib/format";10import { codeOf, TypeDot, useErr, When } from "@/components/admin/requests/common";11import { BlockSheet, type BlockDraft } from "@/components/admin/agenda/block-sheet";12import type { Bk, Dashboard as Data, Req } from "@/components/admin/requests/types";1314const ALERT_LINK: Record<string, string> = {15  request_conflict: "/admin/requests", mail_unconfigured: "/admin/settings?tab=notifications", mail_parked: "/admin/settings?tab=notifications", mail_dead: "/admin/settings?tab=notifications",16  external_stale: "/admin/settings?tab=integrations", worker_down: "/admin/settings?tab=notifications", single_passkey: "/admin/settings?tab=security",17};18type AlertKey = "al_request_conflict" | "al_mail_unconfigured" | "al_mail_parked" | "al_mail_dead" | "al_external_stale" | "al_worker_down" | "al_single_passkey";1920export function Dashboard() {21  const t = useT(adminAgenda);22  const err = useErr();23  const locale = useLocale();24  const { timezone, displayName } = useAdmin();25  const state = useLoad<Data>("/api/v1/admin/dashboard");26  const [busy, setBusy] = useState<string | null>(null);27  const [block, setBlock] = useState<BlockDraft | null>(null);28  const d = state.data;29  // The clock is only read in the browser (owner-zone date in the header, countdown to the next appointment).30  const [now, setNow] = useState<number | null>(null);31  useEffect(() => { setNow(Date.now()); const id = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(id); }, []);3233  const approve = async (r: Req) => {34    setBusy(r.id);35    try { await api(`/api/v1/admin/requests/${r.id}/approve`, { body: { allowOutsideHours: false } }); toast.ok(t("approvedOk")); }36    catch (e) {37      const c = codeOf(e);38      toast.error(c === "slot_taken" ? t("dashTaken") : c === "outside_hours" ? t("dashOutside") : err(e));39    } finally { setBusy(null); requestsChanged(); void state.reload(); }40  };41  const openBlock = () => {42    const now = new Date();43    const ymd = ymdInTz(now, timezone);44    const start = Math.min(23 * 60, (Math.floor(minutesInTz(now, timezone) / 60) + 1) * 60);45    setBlock({ startYmd: ymd, startMin: start, endYmd: start + 60 >= 1440 ? addDays(ymd, 1) : ymd, endMin: (start + 60) % 1440, title: "", note: "" });46  };4748  const copyPublic = async () => { try { await navigator.clipboard.writeText(window.location.origin); toast.ok(t("linkCopied")); } catch { /* clipboard refused */ } };49  const next = d?.upcoming[0] ?? null;5051  return (52    <>53      <PageHeader eyebrow={now ? fmtDay(new Date(now), timezone, locale, { weekday: "long", day: "numeric", month: "long" }) : "\u00a0"} title={t("dashTitle", { name: displayName.split(" ")[0] ?? "" })} lead={t("dashLead", { zone: timezone })} />54      <Loadable state={state} skeleton={<div className="grid gap-4"><div className="skeleton h-[168px] !rounded-[28px]" /><div className="grid grid-cols-2 gap-3"><div className="skeleton h-[104px]" /><div className="skeleton h-[104px]" /></div><div className="skeleton h-40" /></div>}>55        {d ? (56          <div className="grid gap-5 stagger">57            <section aria-label={t("dashNext")} className="hero p-5 sm:p-7" style={{ "--i": 0 } as React.CSSProperties}>58              {next ? <NextUp b={next} now={now} /> : (59                <div>60                  <p className="hero-chip"><Icon name="calendar" size={14} />{t("dashNext")}</p>61                  <p className="font-[750] text-[26px] leading-tight tracking-tight mt-3">{t("upEmpty")}</p>62                  <p className="opacity-90 mt-1">{t("upEmptyLead")}</p>63                </div>64              )}65              <div className="flex flex-wrap gap-2 mt-5">66                <Link className="hero-btn basis-full sm:basis-auto" href="/admin/agenda"><Icon name="calendar" size={18} />{t("openAgenda")}</Link>67                <button type="button" className="hero-btn hero-btn-ghost flex-1 sm:flex-none cursor-pointer" onClick={openBlock}><Icon name="block" size={18} />{t("blockQuick")}</button>68                <button type="button" className="hero-btn hero-btn-ghost cursor-pointer !px-3.5" onClick={copyPublic} aria-label={t("shareLink")} title={t("shareLink")}><Icon name="link" size={18} /></button>69              </div>70            </section>7172            {d.alerts.length ? (73              <section aria-label={t("alerts")} className="grid gap-2" style={{ "--i": 1 } as React.CSSProperties}>74                {d.alerts.map((a) => {75                  const key = `al_${a.code}` as AlertKey;76                  return (77                    <Notice key={a.code} tone={a.level === "danger" ? "danger" : "warn"}>78                      <span>{key in adminAgenda.fr ? t(key, { detail: a.detail ?? "" }) : a.code}</span>{" "}79                      {ALERT_LINK[a.code] ? <Link className="underline font-semibold whitespace-nowrap" href={ALERT_LINK[a.code]}>{t("alertOpen")}</Link> : null}80                    </Notice>81                  );82                })}83              </section>84            ) : null}8586            <section aria-label={t("indicators")} className="grid grid-cols-2 lg:grid-cols-4 gap-3" style={{ "--i": 2 } as React.CSSProperties}>87              <Tile icon="inbox" label={t("tileOpen")} value={d.indicators.openRequests} href="/admin/requests" tone={d.indicators.openRequests ? "pending" : undefined} />88              <Tile icon="calendar" label={t("tile7")} value={d.indicators.next7} href="/admin/agenda" tone="accent" />89              <Tile icon="clock" label={t("tile30")} value={d.indicators.next30} href="/admin/agenda" />90              <Tile icon="check" label={t("tile90")} value={`${d.indicators.approved90} / ${d.indicators.declined90}`} tone="ok" />91            </section>9293            <div className="grid lg:grid-cols-2 gap-5 items-start" style={{ "--i": 3 } as React.CSSProperties}>94              <section aria-labelledby="dash-open">95                <div className="flex items-center justify-between gap-2 mb-2 px-1">96                  <h2 id="dash-open" className="t-headline !text-[20px]">{t("dashOpen")}</h2>97                  {d.open.length ? <Link className="text-blue font-semibold text-sm min-h-[44px] inline-flex items-center" href="/admin/requests">{t("seeAll", { n: d.open.length })}</Link> : null}98                </div>99                {d.open.length === 0 ? <div className="card"><Empty icon="inbox" title={t("reqEmptyOpen")}>{t("reqEmptyOpenLead")}</Empty></div> : (100                  <ul className="grid gap-3">101                    {d.open.slice(0, 5).map((r) => {102                      const blocked = r.status === "pending" && (r.conflict === "taken" || r.conflict === "past");103                      return (104                        <li key={r.id} className="card p-4">105                          <div className="flex items-start gap-3">106                            <Avatar name={r.contact?.name ?? "?"} size={44} seed />107                            <div className="min-w-0 flex-1">108                              <p className="font-semibold text-[16.5px] leading-snug flex flex-wrap items-center gap-x-2 gap-y-1">{r.contact?.name ?? t("contactErased")}109                                <Badge tone={r.status === "pending" ? "pending" : "info"}>{t(`st_${r.status}`)}</Badge></p>110                              <p className="text-sm t-mute flex items-center gap-2 mt-0.5"><TypeDot color={r.type.color} /><span className="truncate">{r.type.title}</span></p>111                              <p className="text-[14.5px] font-medium mt-1 t-num"><When start={r.proposal?.startsAt ?? r.startsAt} end={r.proposal?.endsAt ?? r.endsAt} /></p>112                              {blocked ? <p className="text-sm text-danger font-semibold mt-1">{r.conflict === "taken" ? t("conflictTakenShort") : t("conflictPastShort")}</p> : null}113                            </div>114                          </div>115                          <div className="flex gap-2 mt-3.5">116                            {r.status === "pending" ? (117                              blocked ? <Link className="btn btn-secondary flex-1" href="/admin/requests">{t("handle")}</Link>118                                : <><Button variant="ok" icon="check" className="flex-1" busy={busy === r.id} onClick={() => approve(r)} aria-label={t("approveFor", { name: r.contact?.name ?? "" })}>{t("approve")}</Button>119                                    <Link className="btn btn-secondary" href="/admin/requests">{t("view")}</Link></>120                            ) : <Link className="btn btn-secondary flex-1" href="/admin/requests">{t("view")}</Link>}121                          </div>122                        </li>123                      );124                    })}125                  </ul>126                )}127              </section>128129              <section aria-labelledby="dash-up">130                <div className="flex items-center justify-between gap-2 mb-2 px-1">131                  <h2 id="dash-up" className="t-headline !text-[20px]">{t("dashUpcoming")}</h2>132                  <Link className="text-blue font-semibold text-sm min-h-[44px] inline-flex items-center" href="/admin/agenda">{t("openAgenda")}</Link>133                </div>134                {d.upcoming.length === 0 ? <div className="card"><Empty icon="calendar" title={t("upEmpty")}>{t("upEmptyLead")}</Empty></div> : <BookingList items={d.upcoming} />}135              </section>136            </div>137138            <section aria-labelledby="dash-cancel" style={{ "--i": 4 } as React.CSSProperties}>139              <h2 id="dash-cancel" className="t-headline !text-[20px] mb-2 px-1">{t("dashCancelled")}</h2>140              {d.cancelled.length === 0 ? <p className="t-mute text-sm py-2 px-1">{t("cancelEmpty")}</p> : (141                <ul className="lgroup">142                  {d.cancelled.map((b) => (143                    <li key={b.id} className="row !items-start flex-col !gap-1">144                      <p className="font-medium flex flex-wrap items-center gap-x-2 gap-y-1"><span className="line-through">{b.title}</span> <Badge tone="danger">{b.cancelledBy === "visitor" ? t("cancelledByVisitor") : t("cancelledByAdmin")}</Badge></p>145                      <p className="text-sm t-mute">{fmtDay(b.startsAt, timezone, locale, { weekday: "short", day: "numeric", month: "short" })} · {tzAbbr(b.startsAt, timezone, locale)}{b.cancelReason ? ` · « ${b.cancelReason} »` : ""}</p>146                    </li>147                  ))}148                </ul>149              )}150            </section>151          </div>152        ) : null}153      </Loadable>154      {block ? <BlockSheet draft={block} onClose={() => setBlock(null)} onSaved={() => void state.reload()} /> : null}155    </>156  );157}158159/** The next confirmed appointment, with how far away it is. */160function NextUp({ b, now }: { b: Bk; now: number | null }) {161  const t = useT(adminAgenda);162  const locale = useLocale();163  const { timezone } = useAdmin();164  const mins = Math.round((Date.parse(b.startsAt) - (now ?? Date.parse(b.startsAt))) / 60_000);165  const rtf = new Intl.RelativeTimeFormat(locale === "fr" ? "fr-CA" : "en-CA", { numeric: "auto" });166  const rel = now === null ? "" : mins <= 0 ? t("dashNow") : mins < 60 ? rtf.format(mins, "minute") : mins < 36 * 60 ? rtf.format(Math.round(mins / 60), "hour") : rtf.format(Math.round(mins / 1440), "day");167  return (168    <div>169      <p className="flex flex-wrap items-center gap-2"><span className="hero-chip"><span className="live-dot" />{t("dashNext")}</span>{rel ? <span className="hero-chip first-letter:uppercase">{rel}</span> : null}</p>170      <p className="font-[750] text-[28px] sm:text-[34px] leading-[1.1] tracking-tight mt-3.5 break-words">{b.contact?.name ?? b.title}</p>171      <p className="opacity-90 mt-1 truncate">{b.type?.title ?? b.title}</p>172      <p className="font-semibold text-[17px] mt-2 t-num first-letter:uppercase">{fmtDay(b.startsAt, timezone, locale, { weekday: "long", day: "numeric", month: "long" })} · {fmtRange(b.startsAt, b.endsAt, timezone, locale)} <span className="opacity-80 font-normal text-[14px]">({tzAbbr(b.startsAt, timezone, locale)})</span></p>173    </div>174  );175}176177function Tile({ icon, label, value, href, tone }: { icon: IconName; label: string; value: number | string; href?: string; tone?: "pending" | "accent" | "ok" }) {178  const body = (179    <>180      <span className={cx("tile-icon", tone === "pending" ? "!bg-warn-wash text-warn" : tone === "accent" ? "!bg-blue-wash text-blue" : tone === "ok" ? "!bg-emerald-wash text-emerald" : "t-mute")}><Icon name={icon} size={19} /></span>181      <span className="block mt-3 text-[32px] leading-none font-[750] tracking-tight t-num" style={{ fontFamily: "var(--font-display)" }}>{value}</span>182      <span className="block mt-1.5 t-mute text-[13px] font-medium leading-snug">{label}</span>183    </>184  );185  return href ? <Link href={href} className="card card-press p-4 no-underline block">{body}</Link> : <div className="card p-4">{body}</div>;186}187188function BookingList({ items }: { items: Bk[] }) {189  const locale = useLocale();190  const { timezone } = useAdmin();191  return (192    <ul className="lgroup">193      {items.map((b) => (194        <li key={b.id} className="row">195          <span className="dateblock !w-[48px]" aria-hidden><span>{fmtDay(b.startsAt, timezone, locale, { month: "short" }).replace(".", "")}</span><span className="!text-[20px]">{fmtDay(b.startsAt, timezone, locale, { day: "numeric" })}</span></span>196          <span className="min-w-0 flex-1">197            <span className="font-semibold flex items-center gap-2"><TypeDot color={b.type?.color ?? "var(--mute)"} /><span className="truncate">{b.contact?.name ?? b.title}</span></span>198            <span className="block text-sm t-mute truncate">{b.type?.title ?? b.title}</span>199            <span className="block text-sm t-num"><When start={b.startsAt} end={b.endsAt} /></span>200          </span>201        </li>202      ))}203    </ul>204  );205}206
Files main
Menu

Theme