SPB Git

Repository Public

common.tsx 6.5 KB 111 lines tsx

1"use client";2import { useEffect, useState } from "react";3import { useLocale, useT } from "@/i18n";4import { adminAgenda } from "@/i18n/admin-agenda";5import { api, ApiClientError } from "@/lib/client-api";6import { Badge, Button, cx, errorText, Field, Notice, Spinner } from "@/components/ui";7import { useAdmin } from "@/components/admin/shell";8import { addDays, fmtDateTime, fmtTime, fmtYmd, hhmm, minutesInTz, parseHhmm, tzAbbr, ymdInTz } from "@/lib/format";9import { zonedToIso } from "@/components/admin/agenda/zoned";10import type { HistoryItem, Req } from "./types";1112/** Extra API error codes of the admin screens. */13export const ERR: Record<string, { fr: string; en: string }> = {14  outside_hours: { fr: "Ce moment est en dehors des heures d'ouverture.", en: "This time is outside opening hours." },15  not_found: { fr: "Cet élément n'existe plus. Actualisez la page.", en: "This item no longer exists. Refresh the page." },16  invalid_range: { fr: "La fin doit être après le début.", en: "The end must be after the start." },17};1819export function useErr() {20  const locale = useLocale();21  return (e: unknown) => errorText(e, locale, ERR);22}2324export function TypeDot({ color }: { color: string }) {25  return <span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: color }} aria-hidden />;26}2728export function RequestStatus({ status }: { status: Req["status"] }) {29  const t = useT(adminAgenda);30  const tone = status === "pending" ? "pending" : status === "approved" ? "ok" : status === "reschedule_requested" ? "info" : status === "declined" ? "danger" : "mute";31  return <Badge tone={tone}>{t(`st_${status}`)}</Badge>;32}3334/** Owner-zone time, with the zone abbreviation always visible. */35export function When({ start, end }: { start: string; end?: string | null }) {36  const { timezone } = useAdmin();37  const locale = useLocale();38  return <span>{fmtDateTime(start, timezone, locale)}{end ? ` – ${fmtTime(end, timezone, locale)}` : ""} <span className="t-mute">({tzAbbr(start, timezone, locale)})</span></span>;39}4041export function HistoryList({ items }: { items: HistoryItem[] }) {42  const t = useT(adminAgenda);43  const { timezone } = useAdmin();44  const locale = useLocale();45  if (!items.length) return <p className="t-mute text-sm">{t("historyEmpty")}</p>;46  const label = (a: string) => { const k = ("h_" + a.replace(/\./g, "_")) as keyof typeof adminAgenda.fr; return k in adminAgenda.fr ? t(k) : a; };47  const actor = (a: string) => (a === "admin" ? t("actorAdmin") : a === "visitor" ? t("actorVisitor") : t("actorSystem"));48  return (49    <ol className="grid gap-1.5 text-sm">50      {items.map((h, i) => <li key={i} className="flex flex-wrap gap-x-2"><span className="t-mute">{fmtDateTime(h.at, timezone, locale)}</span><span>{label(h.action)}</span><span className="t-mute">· {actor(h.actor)}</span></li>)}51    </ol>52  );53}5455/**56 * Picks a start instant: a day, then one of the slots the engine reports free that day (owner rules: no public57 * notice period or limits). With `allowCustom`, any time may be typed; the server still refuses taken time.58 */59export function SlotPicker({ eventTypeId, ignoreRequestId, ignoreBookingId, value, onChange, initialDate, allowCustom }: {60  eventTypeId: string | null; ignoreRequestId?: string | null; ignoreBookingId?: string | null; value: string | null; onChange: (iso: string | null) => void; initialDate: string; allowCustom?: boolean;61}) {62  const t = useT(adminAgenda);63  const { timezone } = useAdmin();64  const locale = useLocale();65  const [date, setDate] = useState(initialDate);66  const [slots, setSlots] = useState<string[] | null>(null);67  const [error, setError] = useState<string | null>(null);68  const [custom, setCustom] = useState("");69  useEffect(() => {70    if (!eventTypeId || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { setSlots([]); return; }71    let stop = false;72    setSlots(null); setError(null);73    const q = new URLSearchParams({ eventTypeId, from: date, to: date });74    if (ignoreRequestId) q.set("ignoreRequestId", ignoreRequestId);75    if (ignoreBookingId) q.set("ignoreBookingId", ignoreBookingId);76    api<{ slots: string[] }>(`/api/v1/admin/slots?${q}`).then((d) => { if (!stop) setSlots(d.slots); })77      .catch((e) => { if (!stop) { setSlots([]); setError(errorText(e, locale, ERR)); } });78    return () => { stop = true; };79  }, [date, eventTypeId, ignoreRequestId, ignoreBookingId, locale]);80  const move = (n: number) => { setDate(addDays(date, n)); onChange(null); setCustom(""); };81  return (82    <div className="grid gap-3">83      <Field label={t("pickDate")}>{(p) => (84        <div className="flex gap-2">85          <Button small icon="left" aria-label={t("prevDay")} onClick={() => move(-1)} />86          <input {...p} type="date" className="input" value={date} onChange={(e) => { setDate(e.target.value); onChange(null); setCustom(""); }} />87          <Button small icon="right" aria-label={t("nextDay")} onClick={() => move(1)} />88        </div>89      )}</Field>90      <div>91        <p className="label">{t("freeSlots")} <span className="t-mute font-normal">· {/^\d{4}-\d{2}-\d{2}$/.test(date) ? fmtYmd(date, locale, { weekday: "long", day: "numeric", month: "long" }) : ""} ({timezone})</span></p>92        {error ? <Notice tone="danger">{error}</Notice> : slots === null ? <div className="flex items-center gap-2 t-mute text-sm py-2" aria-busy="true"><Spinner size={16} />{t("loadingSlots")}</div>93          : slots.length === 0 ? <p className="t-mute text-sm py-1">{eventTypeId ? t("noFreeSlots") : t("noTypeSlots")}</p>94          : <div className="flex flex-wrap gap-2" role="group" aria-label={t("freeSlots")}>95              {slots.map((s) => <button key={s} type="button" aria-pressed={value === s} onClick={() => { onChange(s); setCustom(""); }}96                className={cx("btn btn-sm", value === s ? "btn-primary" : "btn-secondary")}>{fmtTime(s, timezone, locale)}</button>)}97            </div>}98      </div>99      {allowCustom ? (100        <Field label={t("customTime")} hint={t("customTimeHint")}>{(p) => <input {...p} type="time" step={300} className="input max-w-[160px]" value={custom}101          onChange={(e) => { setCustom(e.target.value); onChange(e.target.value && /^\d{4}-\d{2}-\d{2}$/.test(date) ? zonedToIso(date, parseHhmm(e.target.value), timezone) : null); }} />}</Field>102      ) : null}103    </div>104  );105}106107/** Owner-zone date + minutes of an instant, for form defaults. */108export const partsOf = (iso: string, tz: string) => ({ ymd: ymdInTz(iso, tz), min: minutesInTz(iso, tz), hhmm: hhmm(minutesInTz(iso, tz)) });109110export const codeOf = (e: unknown) => (e instanceof ApiClientError ? e.code : "internal");111
Files main
Menu

Theme