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
+28 -33
View File
@@ -21,16 +21,6 @@ import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
import { AttachmentsSection } from "@/components/patients/patient-files";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/ui/timeline";
import {
type ActivityEntityType,
type ActivityEntry,
@@ -182,36 +172,41 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
{t("patientCard.history.empty")}
</p>
) : (
// Every entry is a past, audited event, so mark them all completed
// (filled indicators) by seeding the active step past the last item.
<Timeline defaultValue={entries.length}>
// A plain vertical rail so EVERY audited change renders in full — the
// icon column draws a connector that stretches to the next entry, and
// the flex layout mirrors correctly under RTL.
<ol className="space-y-0">
{entries.map((e, i) => {
const Icon = historyIcon[e.entityType] ?? Pencil;
const isLast = i === entries.length - 1;
return (
<TimelineItem
className="group-data-[orientation=vertical]/timeline:ms-10"
key={e.id}
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">
<li className="flex gap-3" key={e.id}>
<div className="flex flex-col items-center">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Icon size={14} />
</TimelineIndicator>
</TimelineHeader>
<TimelineContent>
{e.action}
<TimelineDate className="mt-1 mb-0">
</span>
{!isLast && (
<span
aria-hidden="true"
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()}
</TimelineDate>
</TimelineContent>
</TimelineItem>
</time>
</div>
</li>
);
})}
</Timeline>
</ol>
)}
</Section>
);
+51 -3
View File
@@ -13,8 +13,23 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ListPagination } from "@/components/ui/list-pagination";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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.
const PAGE_SIZE = 10;
@@ -32,6 +47,7 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
export function PatientsView() {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [addOpen, setAddOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
// 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 patients = allPatients.filter(
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
);
// Search matches name, MRN, problem/condition labels, and allergy substances;
// 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
// the first page (done in the search handler); `page` is clamped at render so a
@@ -133,6 +158,29 @@ export function PatientsView() {
value={query}
/>
</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
className="rounded-3xl"
onClick={() => setImportOpen(true)}