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