Repository Public
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