mirror of
https://github.com/temetro/temetro.git
synced 2026-08-27 10:56:58 +00:00
90e6ec4cc0
Chat: history pill now shows a History icon + a Start-new-chat (SquarePen) button; removed the duplicate chat-history list from the sidebar. Email: deployment-wide email provider config (Resend/Postmark/SendGrid/SMTP) in Settings → Developers, with encrypted API key and a Send-test action. sendEmail dispatches via the chosen provider (REST via fetch; SMTP via nodemailer). Forgot password with no provider: alert the clinic admin(s) via a "System" message card in Messages + a bell notification (seeded system user + per-clinic System conversation); clicking deep-links to /settings?tab=careTeam&member=<id>. Admins can set a member's password directly from the employee dialog (PATCH /api/staff/:id/password via Better Auth's internal context — no admin plugin needed). Patient Portal: "New patient" booking path registers a demographics-only patient then books; bookings reject double-booked slots (409). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowLeft,
|
|
CalendarCheck,
|
|
CalendarPlus,
|
|
CheckCircle2,
|
|
ChevronRight,
|
|
FlaskConical,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { type FormEvent, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Empty,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from "@/components/ui/empty";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
bookPortalAppointment,
|
|
createPortalPatient,
|
|
getPortalClinic,
|
|
lookupPortalResults,
|
|
type PortalBookingResult,
|
|
type PortalResults,
|
|
} from "@/lib/portal";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Step = "choose" | "book" | "results";
|
|
|
|
const todayKey = () => new Date().toISOString().slice(0, 10);
|
|
|
|
function Field({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<label className="flex flex-col gap-1.5 text-left">
|
|
<span className="font-medium text-foreground text-sm">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function PortalKiosk({ clinic }: { clinic: string }) {
|
|
const { t } = useTranslation();
|
|
const [clinicName, setClinicName] = useState<string | null>(null);
|
|
const [notFound, setNotFound] = useState(false);
|
|
const [step, setStep] = useState<Step>("choose");
|
|
|
|
useEffect(() => {
|
|
getPortalClinic(clinic)
|
|
.then((c) => setClinicName(c.name))
|
|
.catch(() => setNotFound(true));
|
|
}, [clinic]);
|
|
|
|
if (notFound) {
|
|
return (
|
|
<div className="flex min-h-dvh items-center justify-center p-6">
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<CalendarCheck />
|
|
</EmptyMedia>
|
|
<EmptyTitle>{t("portal.notFoundTitle")}</EmptyTitle>
|
|
<EmptyDescription>{t("portal.notFoundBody")}</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col items-center justify-center gap-8 px-6 py-12">
|
|
<header className="flex flex-col items-center gap-2 text-center">
|
|
<span className="font-medium text-muted-foreground text-sm uppercase tracking-wide">
|
|
{t("portal.kicker")}
|
|
</span>
|
|
<h1 className="font-semibold text-3xl tracking-tight sm:text-4xl">
|
|
{clinicName ?? "…"}
|
|
</h1>
|
|
</header>
|
|
|
|
{step === "choose" ? (
|
|
<ChooseStep onPick={setStep} />
|
|
) : step === "book" ? (
|
|
<BookStep clinic={clinic} onBack={() => setStep("choose")} />
|
|
) : (
|
|
<ResultsStep clinic={clinic} onBack={() => setStep("choose")} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Card-style radio: two large, touch-friendly choices.
|
|
function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
|
|
const { t } = useTranslation();
|
|
const cards: { step: Step; icon: React.ReactNode; title: string; desc: string }[] =
|
|
[
|
|
{
|
|
step: "book",
|
|
icon: <CalendarPlus className="size-7" />,
|
|
title: t("portal.choose.bookTitle"),
|
|
desc: t("portal.choose.bookDesc"),
|
|
},
|
|
{
|
|
step: "results",
|
|
icon: <FlaskConical className="size-7" />,
|
|
title: t("portal.choose.resultsTitle"),
|
|
desc: t("portal.choose.resultsDesc"),
|
|
},
|
|
];
|
|
return (
|
|
<div className="grid w-full gap-4 sm:grid-cols-2">
|
|
{cards.map((c) => (
|
|
<button
|
|
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-left transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
key={c.step}
|
|
onClick={() => onPick(c.step)}
|
|
type="button"
|
|
>
|
|
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
{c.icon}
|
|
</span>
|
|
<span className="flex flex-col gap-1">
|
|
<span className="flex items-center gap-1 font-semibold text-lg text-foreground">
|
|
{c.title}
|
|
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
|
</span>
|
|
<span className="text-muted-foreground text-sm">{c.desc}</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BackButton({ onBack }: { onBack: () => void }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<button
|
|
className="flex items-center gap-1.5 self-start text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
onClick={onBack}
|
|
type="button"
|
|
>
|
|
<ArrowLeft className="size-4" />
|
|
{t("portal.back")}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
|
|
const { t } = useTranslation();
|
|
// "returning" = has a file number; "new" = register first, then book.
|
|
const [mode, setMode] = useState<"returning" | "new">("returning");
|
|
const [name, setName] = useState("");
|
|
const [fileNumber, setFileNumber] = useState("");
|
|
const [sex, setSex] = useState("M");
|
|
const [age, setAge] = useState("");
|
|
const [date, setDate] = useState("");
|
|
const [time, setTime] = useState("09:00");
|
|
const [type, setType] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [done, setDone] = useState<PortalBookingResult | null>(null);
|
|
// The file number assigned to a freshly-registered patient (shown on success).
|
|
const [newFile, setNewFile] = useState<string | null>(null);
|
|
|
|
const submit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
// A new patient is registered first to obtain a file number.
|
|
let file = fileNumber.trim();
|
|
if (mode === "new") {
|
|
const created = await createPortalPatient(clinic, {
|
|
name: name.trim(),
|
|
sex,
|
|
age: age ? Number(age) : undefined,
|
|
});
|
|
file = created.fileNumber;
|
|
setNewFile(created.fileNumber);
|
|
}
|
|
const result = await bookPortalAppointment(clinic, {
|
|
name: name.trim(),
|
|
fileNumber: file,
|
|
date,
|
|
time,
|
|
type: type.trim() || undefined,
|
|
});
|
|
setDone(result);
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : t("portal.book.errorGeneric"),
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (done) {
|
|
return (
|
|
<div className="flex w-full flex-col items-center gap-6">
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<CheckCircle2 />
|
|
</EmptyMedia>
|
|
<EmptyTitle>{t("portal.book.successTitle")}</EmptyTitle>
|
|
<EmptyDescription>
|
|
{t("portal.book.successBody", {
|
|
date: done.date,
|
|
time: done.time,
|
|
})}
|
|
{newFile
|
|
? ` ${t("portal.book.newFileNote", { file: newFile })}`
|
|
: ""}
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
<Button onClick={onBack} variant="outline">
|
|
{t("portal.done")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
|
|
<BackButton onBack={onBack} />
|
|
<h2 className="font-semibold text-xl">{t("portal.book.title")}</h2>
|
|
|
|
{/* Returning vs new patient. */}
|
|
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-muted/40 p-1">
|
|
{(["returning", "new"] as const).map((m) => (
|
|
<button
|
|
className={cn(
|
|
"rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
|
mode === m
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
key={m}
|
|
onClick={() => setMode(m)}
|
|
type="button"
|
|
>
|
|
{t(`portal.book.mode.${m}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<Field label={t("portal.field.name")}>
|
|
<Input onChange={(e) => setName(e.target.value)} required value={name} />
|
|
</Field>
|
|
|
|
{mode === "returning" ? (
|
|
<Field label={t("portal.field.fileNumber")}>
|
|
<Input
|
|
onChange={(e) => setFileNumber(e.target.value)}
|
|
required
|
|
value={fileNumber}
|
|
/>
|
|
</Field>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label={t("portal.field.sex")}>
|
|
<select
|
|
className="h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
|
|
onChange={(e) => setSex(e.target.value)}
|
|
value={sex}
|
|
>
|
|
<option value="M">{t("portal.field.sexMale")}</option>
|
|
<option value="F">{t("portal.field.sexFemale")}</option>
|
|
</select>
|
|
</Field>
|
|
<Field label={t("portal.field.age")}>
|
|
<Input
|
|
max={150}
|
|
min={0}
|
|
onChange={(e) => setAge(e.target.value)}
|
|
type="number"
|
|
value={age}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label={t("portal.field.date")}>
|
|
<Input
|
|
min={todayKey()}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
required
|
|
type="date"
|
|
value={date}
|
|
/>
|
|
</Field>
|
|
<Field label={t("portal.field.time")}>
|
|
<Input
|
|
onChange={(e) => setTime(e.target.value)}
|
|
required
|
|
type="time"
|
|
value={time}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label={t("portal.field.reason")}>
|
|
<Input
|
|
onChange={(e) => setType(e.target.value)}
|
|
placeholder={t("portal.field.reasonPlaceholder")}
|
|
value={type}
|
|
/>
|
|
</Field>
|
|
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
|
<Button disabled={busy} size="lg" type="submit">
|
|
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
|
|
{t("portal.book.submit")}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function ResultsStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
|
|
const { t } = useTranslation();
|
|
const [name, setName] = useState("");
|
|
const [fileNumber, setFileNumber] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [data, setData] = useState<PortalResults | null>(null);
|
|
|
|
const submit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
setData(
|
|
await lookupPortalResults(clinic, fileNumber.trim(), name.trim()),
|
|
);
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : t("portal.results.errorGeneric"),
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (data) {
|
|
return (
|
|
<div className="flex w-full flex-col gap-4">
|
|
<BackButton onBack={onBack} />
|
|
<h2 className="font-semibold text-xl">
|
|
{t("portal.results.greeting", { name: data.name })}
|
|
</h2>
|
|
|
|
<div className="rounded-2xl border bg-card/40 p-4">
|
|
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
{t("portal.results.upcoming")}
|
|
</p>
|
|
{data.upcoming.length === 0 ? (
|
|
<p className="mt-2 text-muted-foreground text-sm">
|
|
{t("portal.results.noUpcoming")}
|
|
</p>
|
|
) : (
|
|
<ul className="mt-2 flex flex-col gap-2">
|
|
{data.upcoming.map((a) => (
|
|
<li
|
|
className="flex items-center justify-between gap-3 rounded-xl border bg-card px-3 py-2"
|
|
key={`${a.date}-${a.time}`}
|
|
>
|
|
<span className="font-medium text-foreground text-sm tabular-nums">
|
|
{a.date} · {a.time}
|
|
</span>
|
|
<span className="truncate text-muted-foreground text-sm">
|
|
{a.type} · {a.provider}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-2xl border bg-card/40 p-4">
|
|
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
{t("portal.results.resultsLabel")}
|
|
</p>
|
|
<p className="mt-2 text-foreground text-sm">
|
|
{data.hasResults
|
|
? t("portal.results.ready", { count: data.resultCount })
|
|
: t("portal.results.none")}
|
|
</p>
|
|
{data.hasResults ? (
|
|
<p className="mt-1 text-muted-foreground text-xs">
|
|
{t("portal.results.askStaff")}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<Button onClick={() => setData(null)} variant="outline">
|
|
{t("portal.results.lookupAnother")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
|
|
<BackButton onBack={onBack} />
|
|
<h2 className="font-semibold text-xl">{t("portal.results.title")}</h2>
|
|
<p className="text-muted-foreground text-sm">{t("portal.results.subtitle")}</p>
|
|
<Field label={t("portal.field.name")}>
|
|
<Input onChange={(e) => setName(e.target.value)} required value={name} />
|
|
</Field>
|
|
<Field label={t("portal.field.fileNumber")}>
|
|
<Input
|
|
onChange={(e) => setFileNumber(e.target.value)}
|
|
required
|
|
value={fileNumber}
|
|
/>
|
|
</Field>
|
|
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
|
<Button disabled={busy} size="lg" type="submit">
|
|
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
|
|
{t("portal.results.submit")}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|