frontend: UI fixes + settings/patients features

- Record history: replace fragile COSS Timeline separator math with a
  continuous-rail list so every audited change renders in full (RTL-safe).
- Prescriptions: new reusable COSS DatePicker (Popover + Calendar) replaces
  raw <input type="date"> for Start/End date.
- Update banner: rebuilt with COSS Alert variant="warning"; pinned physical
  bottom-right so it stays bottom-right under Arabic RTL.
- AI setup notice: thin attached warning banner with clearer copy; now shown
  above the input in active chats too, not just the empty state.
- Settings profile: wire the previously dead Specialty (Select) and
  Professional links (editable rows) into the persisted preferences.
- Patients: add a status filter and broaden search to conditions/allergies.
- Sidebar user menu: quick language switch submenu (RTL already wired).
- ai-elements: fix a subset of Base UI type drift (22 -> 12; remainder is
  cmdk->Autocomplete migration drift kept behind ignoreBuildErrors).
- i18n: new keys added across all five locales (en/de/fr/ar/so).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-12 21:06:38 +03:00
parent bffed5525d
commit 2c239fbd27
20 changed files with 455 additions and 133 deletions
@@ -369,15 +369,18 @@ export const AttachmentRemove = ({
// AttachmentHoverCard - Hover preview // AttachmentHoverCard - Hover preview
// ============================================================================ // ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>; export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard> & {
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
// back-compat but no longer forwarded to the Root.
openDelay?: number;
closeDelay?: number;
};
export const AttachmentHoverCard = ({ export const AttachmentHoverCard = ({
openDelay = 0, openDelay,
closeDelay = 0, closeDelay,
...props ...props
}: AttachmentHoverCardProps) => ( }: AttachmentHoverCardProps) => <HoverCard {...props} />;
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps< export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger typeof HoverCardTrigger
+1 -1
View File
@@ -56,7 +56,7 @@ export const Context = ({
return ( return (
<ContextContext.Provider value={contextValue}> <ContextContext.Provider value={contextValue}>
<HoverCard closeDelay={0} openDelay={0} {...props} /> <HoverCard {...props} />
</ContextContext.Provider> </ContextContext.Provider>
); );
}; };
@@ -50,7 +50,7 @@ export const InlineCitationText = ({
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>; export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => ( export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} /> <HoverCard {...props} />
); );
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & { export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
+14 -1
View File
@@ -128,5 +128,18 @@ export const PlanFooter = (props: PlanFooterProps) => (
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>; export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => ( export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger render={<Button className={cn("size-8", className)} data-slot="plan-trigger" size="icon" variant="ghost" {...props} />}><ChevronsUpDownIcon className="size-4" /><span className="sr-only">Toggle plan</span></CollapsibleTrigger> <CollapsibleTrigger
{...props}
render={
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
/>
}
>
<ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span>
</CollapsibleTrigger>
); );
@@ -1311,15 +1311,18 @@ export const PromptInputSelectValue = ({
<SelectValue className={cn(className)} {...props} /> <SelectValue className={cn(className)} {...props} />
); );
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>; export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard> & {
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
// back-compat but no longer forwarded to the Root.
openDelay?: number;
closeDelay?: number;
};
export const PromptInputHoverCard = ({ export const PromptInputHoverCard = ({
openDelay = 0, openDelay,
closeDelay = 0, closeDelay,
...props ...props
}: PromptInputHoverCardProps) => ( }: PromptInputHoverCardProps) => <HoverCard {...props} />;
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type PromptInputHoverCardTriggerProps = ComponentProps< export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger typeof HoverCardTrigger
@@ -107,7 +107,9 @@ export const SchemaDisplayPath = ({
<span <span
className={cn("font-mono text-sm", className)} className={cn("font-mono text-sm", className)}
// oxlint-disable-next-line eslint-plugin-react(no-danger) // oxlint-disable-next-line eslint-plugin-react(no-danger)
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }} dangerouslySetInnerHTML={{
__html: typeof children === "string" ? children : highlightedPath,
}}
{...props} {...props}
/> />
); );
+24 -20
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Sparkles, X } from "lucide-react"; import { TriangleAlert, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -8,9 +8,12 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getAiConfig } from "@/lib/ai-settings"; import { getAiConfig } from "@/lib/ai-settings";
// A single, dismissible heads-up shown above the chat input on a fresh chat when // A thin, dismissible warning bar shown flush above the chat input whenever no
// no AI provider is configured yet (no API key, and no local Ollama). It only // AI provider is wired up yet — either no API key for any cloud provider, or the
// renders on the empty state, so it naturally disappears once a message is sent. // app is in local mode with no Ollama endpoint set. Without this, sending a
// message just fails silently, so the banner spells out the missing setup and
// links straight to AI settings. Rendered in both the empty and active chat
// states so a user mid-conversation with no provider still sees why replies fail.
export function AiSetupNotice() { export function AiSetupNotice() {
const { t } = useTranslation(); const { t } = useTranslation();
const [needsSetup, setNeedsSetup] = useState(false); const [needsSetup, setNeedsSetup] = useState(false);
@@ -40,24 +43,25 @@ export function AiSetupNotice() {
return ( return (
<div <div
className="flex w-full items-start gap-3 rounded-2xl border border-info/30 bg-info/8 px-4 py-3 text-sm dark:bg-info/12" className="flex w-full items-center gap-2.5 rounded-2xl border border-warning/32 bg-warning/8 px-3.5 py-2 text-sm dark:bg-warning/12"
role="status" role="status"
> >
<Sparkles className="mt-0.5 size-4 shrink-0 text-info-foreground" /> <TriangleAlert className="size-4 shrink-0 text-warning" />
<div className="flex-1 space-y-0.5"> <p className="min-w-0 flex-1 truncate text-foreground">
<p className="font-medium text-foreground"> <span className="font-medium">{t("chat.setupNotice.title")}</span>
{t("chat.setupNotice.title")} <span className="text-muted-foreground max-sm:hidden">
</p> {" — "}
<p className="text-muted-foreground">{t("chat.setupNotice.body")}</p> {t("chat.setupNotice.body")}
<Button </span>
className="mt-1 px-0 text-info-foreground" </p>
render={<Link href="/settings?tab=ai" />} <Button
size="sm" className="shrink-0"
variant="link" render={<Link href="/settings?tab=ai" />}
> size="sm"
{t("chat.setupNotice.action")} variant="outline"
</Button> >
</div> {t("chat.setupNotice.action")}
</Button>
<button <button
aria-label={t("chat.setupNotice.dismiss")} aria-label={t("chat.setupNotice.dismiss")}
className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+4 -2
View File
@@ -723,8 +723,7 @@ export function ChatPanel() {
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
{errorAlert} {errorAlert}
{veilGate} {veilGate}
{/* One-time setup heads-up — only on the empty state, so it clears {/* Setup heads-up when no AI provider is configured. */}
itself once the first message is sent. */}
<AiSetupNotice /> <AiSetupNotice />
{promptInput} {promptInput}
<Suggestions className="justify-center pt-1"> <Suggestions className="justify-center pt-1">
@@ -761,6 +760,9 @@ export function ChatPanel() {
{errorAlert} {errorAlert}
{veilGate} {veilGate}
{queuePanel} {queuePanel}
{/* Also warn mid-conversation when no AI provider is configured, so
failing replies have a visible cause and a fix. */}
<AiSetupNotice />
{promptInput} {promptInput}
</div> </div>
</div> </div>
+28 -33
View File
@@ -21,16 +21,6 @@ import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline"; import { Sparkline } from "@/components/chat/sparkline";
import { AttachmentsSection } from "@/components/patients/patient-files"; import { AttachmentsSection } from "@/components/patients/patient-files";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/ui/timeline";
import { import {
type ActivityEntityType, type ActivityEntityType,
type ActivityEntry, type ActivityEntry,
@@ -182,36 +172,41 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
{t("patientCard.history.empty")} {t("patientCard.history.empty")}
</p> </p>
) : ( ) : (
// Every entry is a past, audited event, so mark them all completed // A plain vertical rail so EVERY audited change renders in full — the
// (filled indicators) by seeding the active step past the last item. // icon column draws a connector that stretches to the next entry, and
<Timeline defaultValue={entries.length}> // the flex layout mirrors correctly under RTL.
<ol className="space-y-0">
{entries.map((e, i) => { {entries.map((e, i) => {
const Icon = historyIcon[e.entityType] ?? Pencil; const Icon = historyIcon[e.entityType] ?? Pencil;
const isLast = i === entries.length - 1;
return ( return (
<TimelineItem <li className="flex gap-3" key={e.id}>
className="group-data-[orientation=vertical]/timeline:ms-10" <div className="flex flex-col items-center">
key={e.id} <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
step={i + 1}
>
<TimelineHeader>
<TimelineSeparator className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5" />
<TimelineTitle className="mt-0.5">
{e.actorName}
</TimelineTitle>
<TimelineIndicator className="group-data-[orientation=vertical]/timeline:-left-7 flex size-6 items-center justify-center border-none bg-primary/10 text-primary group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground">
<Icon size={14} /> <Icon size={14} />
</TimelineIndicator> </span>
</TimelineHeader> {!isLast && (
<TimelineContent> <span
{e.action} aria-hidden="true"
<TimelineDate className="mt-1 mb-0"> className="my-1 w-px flex-1 bg-primary/15"
/>
)}
</div>
<div
className={`min-w-0 flex-1 ${isLast ? "" : "pb-5"}`}
>
<p className="font-medium text-foreground text-sm">
{e.actorName}
</p>
<p className="text-muted-foreground text-sm">{e.action}</p>
<time className="mt-0.5 block font-medium text-muted-foreground text-xs">
{new Date(e.createdAt).toLocaleString()} {new Date(e.createdAt).toLocaleString()}
</TimelineDate> </time>
</TimelineContent> </div>
</TimelineItem> </li>
); );
})} })}
</Timeline> </ol>
)} )}
</Section> </Section>
); );
+51 -3
View File
@@ -13,8 +13,23 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ListPagination } from "@/components/ui/list-pagination"; import { ListPagination } from "@/components/ui/list-pagination";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { listPatients, type Patient } from "@/lib/patients"; import { listPatients, type Patient } from "@/lib/patients";
type StatusFilter = "all" | Patient["status"];
const STATUS_FILTERS: StatusFilter[] = [
"all",
"active",
"inpatient",
"discharged",
];
// Rows shown per page on the patients table before paginating. // Rows shown per page on the patients table before paginating.
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -32,6 +47,7 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
export function PatientsView() { export function PatientsView() {
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
// Bumped on open so the create dialog remounts with a fresh file # / form. // Bumped on open so the create dialog remounts with a fresh file # / form.
@@ -69,9 +85,18 @@ export function PatientsView() {
}, []); }, []);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const patients = allPatients.filter( // Search matches name, MRN, problem/condition labels, and allergy substances;
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q) // the status filter narrows to one clinical status.
); const patients = allPatients.filter((p) => {
if (statusFilter !== "all" && p.status !== statusFilter) return false;
if (!q) return true;
return (
p.name.toLowerCase().includes(q) ||
p.fileNumber.includes(q) ||
p.problems.some((problem) => problem.label.toLowerCase().includes(q)) ||
p.allergies.some((a) => a.substance.toLowerCase().includes(q))
);
});
// Client-side pagination over the filtered list (10/page). Searching resets to // Client-side pagination over the filtered list (10/page). Searching resets to
// the first page (done in the search handler); `page` is clamped at render so a // the first page (done in the search handler); `page` is clamped at render so a
@@ -133,6 +158,29 @@ export function PatientsView() {
value={query} value={query}
/> />
</div> </div>
<Select
onValueChange={(value) => {
setStatusFilter((value ?? "all") as StatusFilter);
setPage(1);
}}
value={statusFilter}
>
<SelectTrigger
aria-label={t("patients.filterStatus")}
className="w-full sm:w-40"
>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{STATUS_FILTERS.map((value) => (
<SelectItem key={value} value={value}>
{value === "all"
? t("patients.allStatuses")
: t(`patients.status.${value}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
<Button <Button
className="rounded-3xl" className="rounded-3xl"
onClick={() => setImportOpen(true)} onClick={() => setImportOpen(true)}
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@@ -578,19 +579,17 @@ export function AddPrescriptionDialog({
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label={t("prescriptions.dialog.startDate")}> <Field label={t("prescriptions.dialog.startDate")}>
<input <DatePicker
className={controlClass} onChange={setStartDate}
onChange={(event) => setStartDate(event.target.value)} placeholder={t("prescriptions.dialog.selectDate")}
type="date"
value={startDate} value={startDate}
/> />
</Field> </Field>
<Field label={t("prescriptions.dialog.endDate")}> <Field label={t("prescriptions.dialog.endDate")}>
<input <DatePicker
className={controlClass}
min={startDate || undefined} min={startDate || undefined}
onChange={(event) => setEndDate(event.target.value)} onChange={setEndDate}
type="date" placeholder={t("prescriptions.dialog.selectDate")}
value={endDate} value={endDate}
/> />
</Field> </Field>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ChevronDown, Plus } from "lucide-react"; import { Plus, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -26,6 +26,7 @@ import {
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config"; import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language"; import { persistLanguage } from "@/lib/language";
import { SPECIALTIES, specialtyLabel } from "@/lib/staff";
import { import {
getSettings, getSettings,
saveSettings, saveSettings,
@@ -60,8 +61,21 @@ const DEFAULT_PREFS: UserPreferences = {
"notif.recordsShared": true, "notif.recordsShared": true,
clinic: "", clinic: "",
contactEmail: "", contactEmail: "",
specialty: "",
// Professional links stored as a JSON array string (values are boolean|string).
links: "",
}; };
// Parse the stored `links` preference (a JSON array string) into an array.
function parseLinks(value: unknown): string[] {
try {
const parsed = JSON.parse(String(value || "[]"));
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
export function ProfilePanel() { export function ProfilePanel() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
@@ -110,13 +124,23 @@ export function ProfilePanel() {
const setPref = (key: string, value: boolean | string) => const setPref = (key: string, value: boolean | string) =>
setPrefs((prev) => ({ ...prev, [key]: value })); setPrefs((prev) => ({ ...prev, [key]: value }));
const links = parseLinks(prefs.links);
const setLinks = (next: string[]) =>
setPref("links", JSON.stringify(next));
const dirty = const dirty =
name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline); name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline);
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
try { try {
const saved = await saveSettings(prefs); // Drop blank link rows before persisting.
const cleanedLinks = links.map((l) => l.trim()).filter(Boolean);
const toSave: UserPreferences = {
...prefs,
links: cleanedLinks.length ? JSON.stringify(cleanedLinks) : "",
};
const saved = await saveSettings(toSave);
const trimmed = name.trim(); const trimmed = name.trim();
if (trimmed && trimmed !== baselineName) { if (trimmed && trimmed !== baselineName) {
const { error } = await authClient.updateUser({ name: trimmed }); const { error } = await authClient.updateUser({ name: trimmed });
@@ -182,13 +206,31 @@ export function ProfilePanel() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel> <FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
<button <Select
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70" onValueChange={(value) => setPref("specialty", value ?? "")}
type="button" value={String(prefs.specialty ?? "")}
> >
{t("settings.profile.selectSpecialty")} <SelectTrigger>
<ChevronDown className="size-4" /> <SelectValue>
</button> {(value: string) =>
value ? (
specialtyLabel(t, value)
) : (
<span className="text-muted-foreground">
{t("settings.profile.selectSpecialty")}
</span>
)
}
</SelectValue>
</SelectTrigger>
<SelectPopup>
{SPECIALTIES.map((s) => (
<SelectItem key={s} value={s}>
{t(`settings.careTeam.specialties.${s}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@@ -221,7 +263,43 @@ export function ProfilePanel() {
{t("settings.profile.professionalLinksHint")} {t("settings.profile.professionalLinksHint")}
</p> </p>
</div> </div>
<Button className="rounded-lg" size="sm" variant="outline"> {links.length > 0 ? (
<div className="space-y-2">
{links.map((link, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rows are positional
<div className="flex items-center gap-2" key={index}>
<Input
inputMode="url"
onChange={(event) =>
setLinks(
links.map((value, i) =>
i === index ? event.target.value : value,
),
)
}
placeholder={t("settings.profile.linkPlaceholder")}
value={link}
/>
<Button
aria-label={t("settings.profile.removeLink")}
onClick={() =>
setLinks(links.filter((_, i) => i !== index))
}
size="icon-sm"
variant="ghost"
>
<X className="size-4" />
</Button>
</div>
))}
</div>
) : null}
<Button
className="rounded-lg"
onClick={() => setLinks([...links, ""])}
size="sm"
variant="outline"
>
<Plus className="size-4" /> <Plus className="size-4" />
{t("settings.profile.addLink")} {t("settings.profile.addLink")}
</Button> </Button>
+33 -1
View File
@@ -4,6 +4,7 @@ import {
Building2, Building2,
Check, Check,
ChevronsUpDown, ChevronsUpDown,
Languages,
LogOut, LogOut,
Moon, Moon,
Plus, Plus,
@@ -49,6 +50,8 @@ import {
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import { useActiveRole } from "@/lib/roles"; import { useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast"; import { notify } from "@/lib/toast";
@@ -82,7 +85,13 @@ function GitHubIcon({ className }: { className?: string }) {
// shortcut hint (below Theme) and a clinic switcher submenu (below that) whose // shortcut hint (below Theme) and a clinic switcher submenu (below that) whose
// popup reveals the active clinic's details on hover. // popup reveals the active clinic's details on hover.
export function NavUser() { export function NavUser() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const activeLang = i18n.resolvedLanguage ?? i18n.language;
const changeLanguage = (lng: string) => {
if (lng === activeLang) return;
void i18n.changeLanguage(lng);
void persistLanguage(lng);
};
const { isMobile, state } = useSidebar(); const { isMobile, state } = useSidebar();
const isCollapsed = state === "collapsed"; const isCollapsed = state === "collapsed";
const router = useRouter(); const router = useRouter();
@@ -197,6 +206,29 @@ export function NavUser() {
</MenuShortcut> </MenuShortcut>
</MenuItem> </MenuItem>
{/* Language: quick switch (also available in Settings). Applies
immediately and roams via the backend preferences. */}
<MenuSub>
<MenuSubTrigger>
<Languages />
<span className="truncate">{t("userMenu.language")}</span>
</MenuSubTrigger>
<MenuSubPopup className="min-w-44" sideOffset={8}>
{supportedLanguages.map((lng) => (
<MenuItem
className="gap-2"
key={lng}
onClick={() => changeLanguage(lng)}
>
<span className="flex-1 truncate">
{t(`settings.profile.language.${lng}`)}
</span>
{lng === activeLang && <Check className="size-4" />}
</MenuItem>
))}
</MenuSubPopup>
</MenuSub>
{/* Command palette: hint + shortcut, sits below Theme. */} {/* Command palette: hint + shortcut, sits below Theme. */}
<MenuItem onClick={openCommand}> <MenuItem onClick={openCommand}>
<Search /> <Search />
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { CalendarIcon } from "lucide-react";
import type { Matcher } from "react-day-picker";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverPopup,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// Parse a `YYYY-MM-DD` string as a LOCAL date. `new Date("YYYY-MM-DD")` parses
// as UTC and can shift the day across timezones, so build it from parts.
function parseISODate(value?: string): Date | undefined {
if (!value) return undefined;
const [y, m, d] = value.split("-").map(Number);
if (!y || !m || !d) return undefined;
return new Date(y, m - 1, d);
}
function toISODate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export interface DatePickerProps {
/** Selected date as `YYYY-MM-DD` (matches native date-input semantics). */
value?: string;
onChange: (value: string) => void;
/** Earliest selectable date, `YYYY-MM-DD`. */
min?: string;
/** Latest selectable date, `YYYY-MM-DD`. */
max?: string;
placeholder?: string;
id?: string;
className?: string;
disabled?: boolean;
}
// A COSS date picker: an outline Button trigger opening a Popover with the COSS
// Calendar. Drop-in replacement for `<input type="date">`, keeping the same
// string value shape so callers don't change their state handling.
export function DatePicker({
value,
onChange,
min,
max,
placeholder,
id,
className,
disabled,
}: DatePickerProps): React.ReactElement {
const [open, setOpen] = useState(false);
const selected = parseISODate(value);
const minDate = parseISODate(min);
const maxDate = parseISODate(max);
const disabledMatchers: Matcher[] = [
...(minDate ? [{ before: minDate }] : []),
...(maxDate ? [{ after: maxDate }] : []),
];
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
render={
<Button
className={cn(
"w-full justify-between font-normal",
!selected && "text-muted-foreground",
className,
)}
disabled={disabled}
id={id}
variant="outline"
/>
}
>
{selected ? selected.toLocaleDateString() : (placeholder ?? "")}
<CalendarIcon className="opacity-80" />
</PopoverTrigger>
<PopoverPopup align="start" className="w-auto">
<Calendar
autoFocus
defaultMonth={selected ?? minDate}
disabled={disabledMatchers.length ? disabledMatchers : undefined}
mode="single"
onSelect={(date?: Date) => {
onChange(date ? toISODate(date) : "");
if (date) setOpen(false);
}}
selected={selected}
/>
</PopoverPopup>
</Popover>
);
}
+31 -21
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { X } from "lucide-react"; import { ArrowUpCircle, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { getVersionInfo } from "@/lib/version"; import { getVersionInfo } from "@/lib/version";
const DISMISS_KEY = "temetro:update-dismissed"; const DISMISS_KEY = "temetro:update-dismissed";
@@ -39,28 +41,36 @@ export function UpdateBanner() {
setLatest(null); setLatest(null);
}; };
// Pinned to the physical bottom-right in both LTR and RTL. The user wants it
// in the bottom-right corner in Arabic too, so we use physical `right`/`bottom`
// rather than the logical `end` (which would flip to the left under RTL).
return ( return (
<div className="fixed end-4 bottom-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 shadow-lg"> <div className="fixed right-4 bottom-4 z-50 w-full max-w-sm" dir="auto">
<div className="space-y-1.5"> <Alert className="bg-card shadow-lg" variant="warning">
<p className="text-sm text-foreground"> <ArrowUpCircle />
<AlertTitle className="text-foreground">
{t("settings.version.banner", { version: latest })} {t("settings.version.banner", { version: latest })}
</p> </AlertTitle>
<Link <AlertAction>
className="text-sm font-medium text-primary underline-offset-4 hover:underline" <Button
href="/settings?tab=version" render={
onClick={dismiss} <Link href="/settings?tab=version" onClick={dismiss} />
> }
{t("settings.version.bannerUpdate")} size="sm"
</Link> variant="outline"
</div> >
<button {t("settings.version.bannerUpdate")}
aria-label={t("settings.version.bannerDismiss")} </Button>
className="-me-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" <Button
onClick={dismiss} aria-label={t("settings.version.bannerDismiss")}
type="button" onClick={dismiss}
> size="icon-sm"
<X className="size-4" /> variant="ghost"
</button> >
<X className="size-4" />
</Button>
</AlertAction>
</Alert>
</div> </div>
); );
} }
+10 -4
View File
@@ -263,7 +263,8 @@
"createDialogDescription": "أضف عيادة جديدة وانتقل إليها.", "createDialogDescription": "أضف عيادة جديدة وانتقل إليها.",
"signOutFailed": "فشل تسجيل الخروج", "signOutFailed": "فشل تسجيل الخروج",
"tryAgain": "يرجى المحاولة مرة أخرى.", "tryAgain": "يرجى المحاولة مرة أخرى.",
"signedOut": "تم تسجيل الخروج" "signedOut": "تم تسجيل الخروج",
"language": "اللغة"
}, },
"clinic": { "clinic": {
"create": "إنشاء عيادة", "create": "إنشاء عيادة",
@@ -374,7 +375,9 @@
"doneTitle": "تم حذف المريض", "doneTitle": "تم حذف المريض",
"failedTitle": "تعذّر حذف المريض", "failedTitle": "تعذّر حذف المريض",
"failedBody": "يرجى المحاولة مرة أخرى." "failedBody": "يرجى المحاولة مرة أخرى."
} },
"filterStatus": "تصفية حسب الحالة",
"allStatuses": "كل الحالات"
}, },
"appointments": { "appointments": {
"title": "المواعيد والجدول", "title": "المواعيد والجدول",
@@ -615,7 +618,8 @@
"removeDates": "إزالة", "removeDates": "إزالة",
"startDate": "تاريخ البدء", "startDate": "تاريخ البدء",
"endDate": "تاريخ الانتهاء", "endDate": "تاريخ الانتهاء",
"addDates": "إضافة تاريخ البدء/الانتهاء" "addDates": "إضافة تاريخ البدء/الانتهاء",
"selectDate": "اختر التاريخ"
} }
}, },
"pharmacy": { "pharmacy": {
@@ -1886,7 +1890,9 @@
"pendingApprovalsDesc": "أبلغني عند موافقة مريض على تغيير معلّق أو رفضه", "pendingApprovalsDesc": "أبلغني عند موافقة مريض على تغيير معلّق أو رفضه",
"recordsShared": "سجلات شُوركت معي", "recordsShared": "سجلات شُوركت معي",
"recordsSharedDesc": "أبلغني عند مشاركة مريض سجلًا معي" "recordsSharedDesc": "أبلغني عند مشاركة مريض سجلًا معي"
} },
"linkPlaceholder": "https://example.com",
"removeLink": "إزالة الرابط"
}, },
"careTeam": { "careTeam": {
"title": "فريق الرعاية", "title": "فريق الرعاية",
+10 -4
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Fügen Sie eine neue Klinik hinzu und wechseln Sie zu ihr.", "createDialogDescription": "Fügen Sie eine neue Klinik hinzu und wechseln Sie zu ihr.",
"signOutFailed": "Abmeldung fehlgeschlagen", "signOutFailed": "Abmeldung fehlgeschlagen",
"tryAgain": "Bitte versuchen Sie es erneut.", "tryAgain": "Bitte versuchen Sie es erneut.",
"signedOut": "Abgemeldet" "signedOut": "Abgemeldet",
"language": "Sprache"
}, },
"clinic": { "clinic": {
"create": "Klinik erstellen", "create": "Klinik erstellen",
@@ -362,7 +363,9 @@
"doneTitle": "Patient gelöscht", "doneTitle": "Patient gelöscht",
"failedTitle": "Patient konnte nicht gelöscht werden", "failedTitle": "Patient konnte nicht gelöscht werden",
"failedBody": "Bitte versuchen Sie es erneut." "failedBody": "Bitte versuchen Sie es erneut."
} },
"filterStatus": "Nach Status filtern",
"allStatuses": "Alle Status"
}, },
"appointments": { "appointments": {
"title": "Termine & Zeitplan", "title": "Termine & Zeitplan",
@@ -599,7 +602,8 @@
"removeDates": "Entfernen", "removeDates": "Entfernen",
"startDate": "Startdatum", "startDate": "Startdatum",
"endDate": "Enddatum", "endDate": "Enddatum",
"addDates": "Start-/Enddatum hinzufügen" "addDates": "Start-/Enddatum hinzufügen",
"selectDate": "Datum wählen"
} }
}, },
"pharmacy": { "pharmacy": {
@@ -1866,7 +1870,9 @@
"pendingApprovalsDesc": "Benachrichtigen Sie mich, wenn ein Patient eine ausstehende Änderung genehmigt oder ablehnt", "pendingApprovalsDesc": "Benachrichtigen Sie mich, wenn ein Patient eine ausstehende Änderung genehmigt oder ablehnt",
"recordsShared": "Mit mir geteilte Datensätze", "recordsShared": "Mit mir geteilte Datensätze",
"recordsSharedDesc": "Benachrichtigen Sie mich, wenn ein Patient einen Datensatz mit mir teilt" "recordsSharedDesc": "Benachrichtigen Sie mich, wenn ein Patient einen Datensatz mit mir teilt"
} },
"linkPlaceholder": "https://example.com",
"removeLink": "Link entfernen"
}, },
"careTeam": { "careTeam": {
"title": "Behandlungsteam", "title": "Behandlungsteam",
+10 -4
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Add a new clinic and switch to it.", "createDialogDescription": "Add a new clinic and switch to it.",
"signOutFailed": "Sign out failed", "signOutFailed": "Sign out failed",
"tryAgain": "Please try again.", "tryAgain": "Please try again.",
"signedOut": "Signed out" "signedOut": "Signed out",
"language": "Language"
}, },
"clinic": { "clinic": {
"create": "Create clinic", "create": "Create clinic",
@@ -362,7 +363,9 @@
"doneTitle": "Patient deleted", "doneTitle": "Patient deleted",
"failedTitle": "Couldn't delete patient", "failedTitle": "Couldn't delete patient",
"failedBody": "Please try again." "failedBody": "Please try again."
} },
"filterStatus": "Filter by status",
"allStatuses": "All statuses"
}, },
"appointments": { "appointments": {
"title": "Appointments & Schedule", "title": "Appointments & Schedule",
@@ -599,7 +602,8 @@
"removeDates": "Remove", "removeDates": "Remove",
"startDate": "Start date", "startDate": "Start date",
"endDate": "End date", "endDate": "End date",
"addDates": "Add start/end dates" "addDates": "Add start/end dates",
"selectDate": "Select date"
} }
}, },
"pharmacy": { "pharmacy": {
@@ -1866,7 +1870,9 @@
"pendingApprovalsDesc": "Notify me when a patient approves or rejects a pending change", "pendingApprovalsDesc": "Notify me when a patient approves or rejects a pending change",
"recordsShared": "Records shared with me", "recordsShared": "Records shared with me",
"recordsSharedDesc": "Notify me when a patient shares a record with me" "recordsSharedDesc": "Notify me when a patient shares a record with me"
} },
"linkPlaceholder": "https://example.com",
"removeLink": "Remove link"
}, },
"careTeam": { "careTeam": {
"title": "Care team", "title": "Care team",
+10 -4
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Ajoutez une nouvelle clinique et passez-y.", "createDialogDescription": "Ajoutez une nouvelle clinique et passez-y.",
"signOutFailed": "Échec de la déconnexion", "signOutFailed": "Échec de la déconnexion",
"tryAgain": "Veuillez réessayer.", "tryAgain": "Veuillez réessayer.",
"signedOut": "Déconnecté" "signedOut": "Déconnecté",
"language": "Langue"
}, },
"clinic": { "clinic": {
"create": "Créer une clinique", "create": "Créer une clinique",
@@ -362,7 +363,9 @@
"doneTitle": "Patient supprimé", "doneTitle": "Patient supprimé",
"failedTitle": "Impossible de supprimer le patient", "failedTitle": "Impossible de supprimer le patient",
"failedBody": "Veuillez réessayer." "failedBody": "Veuillez réessayer."
} },
"filterStatus": "Filtrer par statut",
"allStatuses": "Tous les statuts"
}, },
"appointments": { "appointments": {
"title": "Rendez-vous & Planning", "title": "Rendez-vous & Planning",
@@ -599,7 +602,8 @@
"removeDates": "Retirer", "removeDates": "Retirer",
"startDate": "Date de début", "startDate": "Date de début",
"endDate": "Date de fin", "endDate": "Date de fin",
"addDates": "Ajouter les dates de début/fin" "addDates": "Ajouter les dates de début/fin",
"selectDate": "Choisir une date"
} }
}, },
"pharmacy": { "pharmacy": {
@@ -1866,7 +1870,9 @@
"pendingApprovalsDesc": "Me notifier lorsqu'un patient approuve ou rejette une modification en attente", "pendingApprovalsDesc": "Me notifier lorsqu'un patient approuve ou rejette une modification en attente",
"recordsShared": "Dossiers partagés avec moi", "recordsShared": "Dossiers partagés avec moi",
"recordsSharedDesc": "Me notifier lorsqu'un patient partage un dossier avec moi" "recordsSharedDesc": "Me notifier lorsqu'un patient partage un dossier avec moi"
} },
"linkPlaceholder": "https://example.com",
"removeLink": "Supprimer le lien"
}, },
"careTeam": { "careTeam": {
"title": "Équipe soignante", "title": "Équipe soignante",
+10 -4
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Ku dar rug cusub oo u beddel.", "createDialogDescription": "Ku dar rug cusub oo u beddel.",
"signOutFailed": "Ka-bixitaanka waa fashilmay", "signOutFailed": "Ka-bixitaanka waa fashilmay",
"tryAgain": "Fadlan isku day mar kale.", "tryAgain": "Fadlan isku day mar kale.",
"signedOut": "Waa laga baxay" "signedOut": "Waa laga baxay",
"language": "Luqadda"
}, },
"clinic": { "clinic": {
"create": "Samee rug", "create": "Samee rug",
@@ -362,7 +363,9 @@
"doneTitle": "Bukaanka waa la tirtiray", "doneTitle": "Bukaanka waa la tirtiray",
"failedTitle": "Bukaanka lama tirtiri karin", "failedTitle": "Bukaanka lama tirtiri karin",
"failedBody": "Fadlan isku day mar kale." "failedBody": "Fadlan isku day mar kale."
} },
"filterStatus": "Ku kala sooc xaaladda",
"allStatuses": "Dhammaan xaaladaha"
}, },
"appointments": { "appointments": {
"title": "Ballamaha & Jadwalka", "title": "Ballamaha & Jadwalka",
@@ -599,7 +602,8 @@
"removeDates": "Ka saar", "removeDates": "Ka saar",
"startDate": "Taariikhda bilowga", "startDate": "Taariikhda bilowga",
"endDate": "Taariikhda dhammaadka", "endDate": "Taariikhda dhammaadka",
"addDates": "Ku dar taariikhaha bilow/dhammaad" "addDates": "Ku dar taariikhaha bilow/dhammaad",
"selectDate": "Dooro taariikhda"
} }
}, },
"pharmacy": { "pharmacy": {
@@ -1866,7 +1870,9 @@
"pendingApprovalsDesc": "I ogeysii marka bukaan ansixiyo ama diido isbeddel la sugayo", "pendingApprovalsDesc": "I ogeysii marka bukaan ansixiyo ama diido isbeddel la sugayo",
"recordsShared": "Diiwaanno la ila wadaagay", "recordsShared": "Diiwaanno la ila wadaagay",
"recordsSharedDesc": "I ogeysii marka bukaan diiwaan ila wadaago" "recordsSharedDesc": "I ogeysii marka bukaan diiwaan ila wadaago"
} },
"linkPlaceholder": "https://example.com",
"removeLink": "Ka saar linkiga"
}, },
"careTeam": { "careTeam": {
"title": "Kooxda daryeelka", "title": "Kooxda daryeelka",