mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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> & {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "فريق الرعاية",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user