Files
temetro/frontend/components/sidebar-02/nav-user.tsx
T
Khalid Abdi 2c239fbd27 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>
2026-07-12 21:06:38 +03:00

318 lines
11 KiB
TypeScript

"use client";
import {
Building2,
Check,
ChevronsUpDown,
Languages,
LogOut,
Moon,
Plus,
Search,
Settings as SettingsIcon,
Sun,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useCommandPalette } from "@/components/command-palette";
import { CreateClinicForm } from "@/components/clinic/create-clinic-form";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
Menu,
MenuGroup,
MenuGroupLabel,
MenuItem,
MenuPopup,
MenuSeparator,
MenuShortcut,
MenuSub,
MenuSubPopup,
MenuSubTrigger,
MenuTrigger,
} from "@/components/ui/menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import { useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
// Open-source repo (placeholder).
const REPO_URL = "https://github.com/temetro/temetro";
function initialsFromName(name: string): string {
const letters = name
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.join("")
.slice(0, 2);
return (letters || "?").toUpperCase();
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 .5C5.37.5 0 5.78 0 12.29c0 5.2 3.44 9.6 8.21 11.16.6.11.82-.25.82-.55 0-.27-.01-1.16-.02-2.1-3.34.73-4.04-1.42-4.04-1.42-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.21.09 1.84 1.25 1.84 1.25 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.61-2.67-.31-5.47-1.34-5.47-5.95 0-1.31.47-2.39 1.24-3.23-.12-.31-.54-1.54.12-3.21 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.67.24 2.9.12 3.21.77.84 1.24 1.92 1.24 3.23 0 4.62-2.81 5.64-5.49 5.94.43.37.82 1.1.82 2.22 0 1.6-.02 2.89-.02 3.29 0 .31.21.69.83.57A12.02 12.02 0 0 0 24 12.29C24 5.78 18.63.5 12 .5Z" />
</svg>
);
}
// The sidebar's single footer row. Its menu also hosts the command-palette
// shortcut hint (below Theme) and a clinic switcher submenu (below that) whose
// popup reveals the active clinic's details on hover.
export function NavUser() {
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 isCollapsed = state === "collapsed";
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const { data } = authClient.useSession();
const { open: openCommand } = useCommandPalette();
const { data: orgs } = authClient.useListOrganizations();
const { data: activeOrg } = authClient.useActiveOrganization();
const role = useActiveRole();
// Only clinic owners/admins may spin up additional clinics. New users with no
// clinic still onboard via /onboarding (a separate flow).
const canCreateClinic = role === "owner" || role === "admin";
const [createOpen, setCreateOpen] = useState(false);
const name = data?.user?.name ?? t("userMenu.defaultName");
const email = data?.user?.email ?? "";
// Admin-provisioned staff sign in by username and may only have a synthetic
// `username@slug.temetro.local` address — show their @username instead of an
// email that looks broken.
const username = data?.user?.displayUsername ?? data?.user?.username ?? null;
const isSyntheticEmail = /@.+\.temetro\.local$/i.test(email);
const secondary = !email || isSyntheticEmail ? (username ? `@${username}` : email) : email;
const initials = initialsFromName(name);
const activeName = activeOrg?.name ?? t("userMenu.selectClinic");
const setActive = async (organizationId: string) => {
if (organizationId === activeOrg?.id) return;
await authClient.organization.setActive({ organizationId });
};
const signOut = async () => {
const { error } = await authClient.signOut();
if (error) {
notify.error(
t("userMenu.signOutFailed"),
error.message ?? t("userMenu.tryAgain"),
);
return;
}
notify.success(t("userMenu.signedOut"));
router.push("/login");
};
return (
<SidebarMenu>
<SidebarMenuItem>
<Menu>
<MenuTrigger
render={
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
size="lg"
tooltip={name}
/>
}
>
<Avatar className="size-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
{!isCollapsed && (
<>
<div className="grid flex-1 text-start text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-muted-foreground text-xs">
{secondary}
</span>
</div>
<ChevronsUpDown className="ms-auto size-4" />
</>
)}
</MenuTrigger>
<MenuPopup
align="start"
className="min-w-60"
side={isMobile ? "bottom" : isCollapsed ? "right" : "top"}
sideOffset={8}
>
<MenuGroup>
<MenuGroupLabel className="flex items-center gap-2 py-2 text-foreground">
<Avatar className="size-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-start text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-muted-foreground text-xs">
{secondary}
</span>
</div>
</MenuGroupLabel>
</MenuGroup>
<MenuSeparator />
<MenuItem render={<Link href="/settings" />}>
<SettingsIcon />
{t("userMenu.settings")}
</MenuItem>
<MenuItem
render={<a href={REPO_URL} rel="noreferrer" target="_blank" />}
>
<GitHubIcon className="size-4" />
{t("userMenu.docs")}
</MenuItem>
<MenuItem
closeOnClick={false}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Moon /> : <Sun />}
{t("userMenu.theme")}
<MenuShortcut>
{isDark ? t("userMenu.dark") : t("userMenu.light")}
</MenuShortcut>
</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. */}
<MenuItem onClick={openCommand}>
<Search />
{t("userMenu.search")}
<KbdGroup className="ms-auto">
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</MenuItem>
{/* Clinic switcher: hover reveals the active clinic's details. */}
<MenuSub>
<MenuSubTrigger>
<Building2 />
<span className="truncate">{activeName}</span>
</MenuSubTrigger>
<MenuSubPopup className="min-w-64" sideOffset={8}>
<div className="px-2 py-1.5">
<p className="truncate font-medium text-foreground text-sm">
{activeOrg?.name ?? t("userMenu.noClinic")}
</p>
<p className="truncate text-muted-foreground text-xs">
{activeOrg?.slug ? `/${activeOrg.slug}` : "—"} ·{" "}
{t("userMenu.clinicCount", { count: orgs?.length ?? 0 })}
</p>
</div>
<MenuSeparator />
<MenuGroup>
<MenuGroupLabel className="text-muted-foreground text-xs">
{t("userMenu.switchClinic")}
</MenuGroupLabel>
{(orgs ?? []).map((org) => (
<MenuItem
className="gap-2"
key={org.id}
onClick={() => setActive(org.id)}
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Building2 className="size-4 shrink-0" />
</div>
<span className="flex-1 truncate">{org.name}</span>
{org.id === activeOrg?.id && <Check className="size-4" />}
</MenuItem>
))}
</MenuGroup>
{canCreateClinic && (
<>
<MenuSeparator />
<MenuItem
className="gap-2"
onClick={() => setCreateOpen(true)}
>
<Plus />
{t("userMenu.createClinic")}
</MenuItem>
</>
)}
</MenuSubPopup>
</MenuSub>
<MenuSeparator />
<MenuItem onClick={signOut} variant="destructive">
<LogOut />
{t("userMenu.logout")}
</MenuItem>
</MenuPopup>
</Menu>
</SidebarMenuItem>
{/* Create a new clinic */}
<Dialog onOpenChange={setCreateOpen} open={createOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("userMenu.createDialogTitle")}</DialogTitle>
<DialogDescription>
{t("userMenu.createDialogDescription")}
</DialogDescription>
</DialogHeader>
<DialogPanel>
<CreateClinicForm onCreated={() => setCreateOpen(false)} />
</DialogPanel>
</DialogPopup>
</Dialog>
</SidebarMenu>
);
}