mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17209a2cf1 | |||
| bd8fdfadda | |||
| 7846dacd42 | |||
| 2c239fbd27 |
@@ -7,6 +7,57 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.13.1] — 2026-07-13
|
||||
|
||||
### Changed
|
||||
- **New Invoice checkboxes.** The Back-date / Due-date toggles now use the COSS `Checkbox`
|
||||
(`frontend/components/ui/checkbox.tsx`) instead of raw, misshapen native checkboxes.
|
||||
- **Patients page toolbar & table.** The list is now a COSS **Table in a `CardFrame`**
|
||||
(`frontend/components/ui/table.tsx`), and the secondary "Import from a patient app" action moved
|
||||
into a `⋯` overflow menu so the toolbar keeps a single primary "Add patient" CTA.
|
||||
- **Patient detail sheet header.** The five action buttons plus delete collapse into a primary
|
||||
**Edit** button and a `⋯ More` menu (Download summary, Record visit, Transfer, Push to wallet, and
|
||||
a destructive Delete).
|
||||
|
||||
### Wallet app
|
||||
- **Home quick actions open sheets.** "Share record" now opens a bottom sheet with a scannable **QR**
|
||||
of the wallet number (`react-native-qrcode-svg`); "My wallet" opens a bottom sheet with copyable
|
||||
wallet number / fingerprint / algorithm; and the duplicate "Notifications" action (the header
|
||||
already has a bell) is replaced with **Scan**.
|
||||
|
||||
## [0.13.0] — 2026-07-12
|
||||
|
||||
### Added
|
||||
- **Prescription date pickers.** The New Prescription dialog's Start/End date now use a proper COSS
|
||||
date picker (`frontend/components/ui/date-picker.tsx`, Popover + Calendar) instead of the raw
|
||||
native date input.
|
||||
- **Working profile fields.** Settings → Profile **Specialty** (a Select) and **Professional links**
|
||||
(editable rows) are now wired to persisted preferences — previously they were inert stubs.
|
||||
- **Patient filters.** The Patients page gains a status filter (all / active / inpatient /
|
||||
discharged) and its search now also matches conditions and allergies.
|
||||
- **Language quick-switch.** A language submenu in the sidebar user menu (alongside Theme), applied
|
||||
immediately with Arabic RTL.
|
||||
- **Wallet app — home & settings.** The patient app home screen adds a quick-action row (Share
|
||||
record / My wallet / Notifications) and a recent-activity feed; the settings screen adds an About
|
||||
section (version, docs, blog) and a privacy footer.
|
||||
|
||||
### Changed
|
||||
- **Record history renders in full.** The patient sheet's Record history replaces the fragile
|
||||
timeline-separator layout with a continuous-rail list, so every audited change shows completely
|
||||
(and mirrors correctly under RTL).
|
||||
- **Update banner + AI setup notice.** The "update available" banner now uses the COSS warning Alert
|
||||
and stays bottom-right under Arabic; the "connect an AI provider" notice is a clearer, thinner bar
|
||||
shown above the chat input in active conversations too.
|
||||
- **Wallet app — bottom sheets.** Polished the shared sheet building blocks (spacing, radii, close
|
||||
and action affordances).
|
||||
|
||||
### Fixed
|
||||
- **Blog reachable.** `blog.temetro.com` was returning 502 due to a custom-domain target-port
|
||||
mismatch (Ghost listens on 2368); documented and corrected.
|
||||
- **Docs freshness.** Corrected stale `CLAUDE.md` claims (the AI chat is real and `@ai-sdk/react` is
|
||||
installed; the signing/approval flow is built) and reduced the vendored `ai-elements` Base UI
|
||||
type-drift errors.
|
||||
|
||||
## [0.12.1] — 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -31,9 +31,12 @@ repository (published as `temetro`).
|
||||
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
|
||||
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
|
||||
>
|
||||
> **Also built:** the **AI chat is real** — the frontend streams from the backend's tool-using
|
||||
> agent (`POST /api/chat`), not mock replies (see `frontend/CLAUDE.md`).
|
||||
>
|
||||
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
|
||||
> shares. The AI chat is still **mock replies**. Email verification is wired but currently **not
|
||||
> enforced** at sign-in (see `backend/CLAUDE.md`).
|
||||
> shares. Email verification is wired but currently **not enforced** at sign-in (see
|
||||
> `backend/CLAUDE.md`).
|
||||
|
||||
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
|
||||
+8
-6
@@ -12,8 +12,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
`temetro` — an **open-source** clinical "AI middleman" (see the project vision in the root
|
||||
`../CLAUDE.md`). This app is the clinician-facing chat UI. It is now **wired to the real
|
||||
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo); the AI
|
||||
chat replies are **still mocked** (no LLM call yet).
|
||||
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo). The AI
|
||||
chat is **real**: `components/chat/chat-panel.tsx` streams from the backend's tool-using agent
|
||||
(`POST /api/chat`) via `useChat` and renders the record data parts it streams back.
|
||||
|
||||
- The chat parses `/patient <file#>` (or a bare `/<file#>`) in `components/chat/chat-panel.tsx` and
|
||||
renders the record as a horizontal row of cards (`components/chat/patient-cards.tsx`), with small
|
||||
@@ -21,7 +22,7 @@ chat replies are **still mocked** (no LLM call yet).
|
||||
dialog; the Summary card has an **Edit record** button.
|
||||
- Patients can be **created and edited** via the shared `components/chat/patient-form-dialog.tsx`
|
||||
(mode `create` | `edit`). The "Add patient" pill in `chat-input.tsx` opens it.
|
||||
- Non-command messages still get a mock assistant reply.
|
||||
- Non-command messages stream a real reply from the backend agent (`POST /api/chat`).
|
||||
|
||||
### Auth & data (talks to `../backend`)
|
||||
|
||||
@@ -42,7 +43,8 @@ chat replies are **still mocked** (no LLM call yet).
|
||||
`components/settings/settings-care-team.tsx` manages members + invitations.
|
||||
- `.env.local` / `.env.example` hold `NEXT_PUBLIC_API_URL`.
|
||||
|
||||
The signing / patient-owned-storage / approval features from the root vision are **still not built**.
|
||||
The signing / patient-owned-storage / approval features from the root vision **are built** (clinic
|
||||
signing key, encrypted share/import, patient approval, clinic→wallet update push, QR pairing).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -77,8 +79,8 @@ trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit poli
|
||||
`components.json` (baseColor `neutral`).
|
||||
- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`,
|
||||
`Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`,
|
||||
`ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state
|
||||
is managed with local React state.
|
||||
`ChatStatus`, `FileUIPart`). `@ai-sdk/react` (`useChat`) **is installed** and drives the live chat
|
||||
(`chat-panel.tsx`) with a custom transport pointed at `POST /api/chat`.
|
||||
- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` /
|
||||
`SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav config (New chat
|
||||
· Patients · Settings) + notifications; `team-switcher.tsx` is the `OrgSwitcher` (clinic switch).
|
||||
|
||||
@@ -369,15 +369,18 @@ export const AttachmentRemove = ({
|
||||
// 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 = ({
|
||||
openDelay = 0,
|
||||
closeDelay = 0,
|
||||
openDelay,
|
||||
closeDelay,
|
||||
...props
|
||||
}: AttachmentHoverCardProps) => (
|
||||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||
);
|
||||
}: AttachmentHoverCardProps) => <HoverCard {...props} />;
|
||||
|
||||
export type AttachmentHoverCardTriggerProps = ComponentProps<
|
||||
typeof HoverCardTrigger
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Context = ({
|
||||
|
||||
return (
|
||||
<ContextContext.Provider value={contextValue}>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
<HoverCard {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export const InlineCitationText = ({
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
<HoverCard {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
|
||||
@@ -128,5 +128,18 @@ export const PlanFooter = (props: PlanFooterProps) => (
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
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} />
|
||||
);
|
||||
|
||||
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 = ({
|
||||
openDelay = 0,
|
||||
closeDelay = 0,
|
||||
openDelay,
|
||||
closeDelay,
|
||||
...props
|
||||
}: PromptInputHoverCardProps) => (
|
||||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||
);
|
||||
}: PromptInputHoverCardProps) => <HoverCard {...props} />;
|
||||
|
||||
export type PromptInputHoverCardTriggerProps = ComponentProps<
|
||||
typeof HoverCardTrigger
|
||||
|
||||
@@ -107,7 +107,9 @@ export const SchemaDisplayPath = ({
|
||||
<span
|
||||
className={cn("font-mono text-sm", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(no-danger)
|
||||
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: typeof children === "string" ? children : highlightedPath,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import { TriangleAlert, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,9 +8,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAiConfig } from "@/lib/ai-settings";
|
||||
|
||||
// A single, dismissible heads-up shown above the chat input on a fresh chat when
|
||||
// no AI provider is configured yet (no API key, and no local Ollama). It only
|
||||
// renders on the empty state, so it naturally disappears once a message is sent.
|
||||
// A thin, dismissible warning bar shown flush above the chat input whenever no
|
||||
// AI provider is wired up yet — either no API key for any cloud provider, or the
|
||||
// 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() {
|
||||
const { t } = useTranslation();
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
@@ -40,24 +43,25 @@ export function AiSetupNotice() {
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-info-foreground" />
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<p className="font-medium text-foreground">
|
||||
{t("chat.setupNotice.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("chat.setupNotice.body")}</p>
|
||||
<Button
|
||||
className="mt-1 px-0 text-info-foreground"
|
||||
render={<Link href="/settings?tab=ai" />}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{t("chat.setupNotice.action")}
|
||||
</Button>
|
||||
</div>
|
||||
<TriangleAlert className="size-4 shrink-0 text-warning" />
|
||||
<p className="min-w-0 flex-1 truncate text-foreground">
|
||||
<span className="font-medium">{t("chat.setupNotice.title")}</span>
|
||||
<span className="text-muted-foreground max-sm:hidden">
|
||||
{" — "}
|
||||
{t("chat.setupNotice.body")}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
render={<Link href="/settings?tab=ai" />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("chat.setupNotice.action")}
|
||||
</Button>
|
||||
<button
|
||||
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"
|
||||
|
||||
@@ -723,8 +723,7 @@ export function ChatPanel() {
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{errorAlert}
|
||||
{veilGate}
|
||||
{/* One-time setup heads-up — only on the empty state, so it clears
|
||||
itself once the first message is sent. */}
|
||||
{/* Setup heads-up when no AI provider is configured. */}
|
||||
<AiSetupNotice />
|
||||
{promptInput}
|
||||
<Suggestions className="justify-center pt-1">
|
||||
@@ -761,6 +760,9 @@ export function ChatPanel() {
|
||||
{errorAlert}
|
||||
{veilGate}
|
||||
{queuePanel}
|
||||
{/* Also warn mid-conversation when no AI provider is configured, so
|
||||
failing replies have a visible cause and a fix. */}
|
||||
<AiSetupNotice />
|
||||
{promptInput}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -367,11 +368,14 @@ export function InvoiceFormDialog({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
|
||||
{t("invoices.dialog.issued")}
|
||||
<label className="flex items-center gap-1 text-[11px]">
|
||||
<input
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-[11px]"
|
||||
htmlFor="invoice-backdate"
|
||||
>
|
||||
<Checkbox
|
||||
checked={allowBackdate}
|
||||
onChange={(e) => setAllowBackdate(e.target.checked)}
|
||||
type="checkbox"
|
||||
id="invoice-backdate"
|
||||
onCheckedChange={(checked) => setAllowBackdate(checked)}
|
||||
/>
|
||||
{t("invoices.dialog.backdate")}
|
||||
</label>
|
||||
@@ -385,11 +389,10 @@ export function InvoiceFormDialog({
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center justify-between text-muted-foreground text-xs">
|
||||
{t("invoices.dialog.due")}
|
||||
<input
|
||||
<Checkbox
|
||||
aria-label={t("invoices.dialog.due")}
|
||||
checked={hasDue}
|
||||
onChange={(e) => setHasDue(e.target.checked)}
|
||||
type="checkbox"
|
||||
onCheckedChange={(checked) => setHasDue(checked)}
|
||||
/>
|
||||
</span>
|
||||
{hasDue ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type LucideIcon,
|
||||
ListTodo,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Pencil,
|
||||
@@ -21,16 +22,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,
|
||||
@@ -39,6 +30,13 @@ import {
|
||||
import { printPatientSummary } from "@/lib/patient-pdf";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuPopup,
|
||||
MenuSeparator,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -182,36 +180,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>
|
||||
);
|
||||
@@ -320,63 +323,63 @@ export function PatientDetail({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions — their own wrapping row beneath the identity. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => printPatientSummary(patient, t)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</Button>
|
||||
{onTransfer && (
|
||||
<Button
|
||||
onClick={onTransfer}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onScribe && (
|
||||
<Button onClick={onScribe} size="sm" type="button" variant="outline">
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Actions — one primary (Edit) with the rest tucked into an overflow
|
||||
menu so the header stays uncluttered. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{onEdit && (
|
||||
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
||||
<Pencil className="size-4" />
|
||||
{t("patientCard.edit")}
|
||||
</Button>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<Button
|
||||
onClick={onWalletPush}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patientCard.moreActions")}
|
||||
className="ms-auto"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
aria-label={t("patients.delete.action")}
|
||||
className="ms-auto"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="end">
|
||||
<MenuItem onClick={() => printPatientSummary(patient, t)}>
|
||||
<FileDown className="size-4" />
|
||||
{t("patientCard.exportPdf")}
|
||||
</MenuItem>
|
||||
{onScribe && (
|
||||
<MenuItem onClick={onScribe}>
|
||||
<Mic className="size-4" />
|
||||
{t("scribe.recordVisit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onTransfer && (
|
||||
<MenuItem onClick={onTransfer}>
|
||||
<ArrowLeftRight className="size-4" />
|
||||
{t("patients.transfer.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onWalletPush && (
|
||||
<MenuItem onClick={onWalletPush}>
|
||||
<Send className="size-4" />
|
||||
{t("walletPush.action")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem onClick={onDelete} variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("patients.delete.action")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Search, Smartphone } from "lucide-react";
|
||||
import { MoreHorizontal, Plus, Search, Smartphone } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,10 +11,40 @@ import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet
|
||||
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardFrame } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ListPagination } from "@/components/ui/list-pagination";
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuPopup,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectPopup,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
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 +62,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 +100,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,15 +173,29 @@ export function PatientsView() {
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setImportOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter((value ?? "all") as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
value={statusFilter}
|
||||
>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</Button>
|
||||
<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={() => {
|
||||
@@ -153,58 +207,88 @@ export function PatientsView() {
|
||||
<Plus className="size-4" />
|
||||
{t("patients.add")}
|
||||
</Button>
|
||||
{/* Secondary actions tuck into an overflow menu so the toolbar keeps a
|
||||
single primary CTA. */}
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("patients.moreActions")}
|
||||
className="rounded-full"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="end">
|
||||
<MenuItem onClick={() => setImportOpen(true)}>
|
||||
<Smartphone className="size-4" />
|
||||
{t("patients.importFromApp")}
|
||||
</MenuItem>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-border border-b text-start text-xs text-muted-foreground uppercase">
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
|
||||
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
<CardFrame className="mt-8 w-full">
|
||||
<Table variant="card">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="ps-4 text-xs uppercase">
|
||||
{t("patients.columns.name")}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.mrn")}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.ageSex")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.status")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="text-xs uppercase">
|
||||
{t("patients.columns.lastSeen")}
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium">
|
||||
</TableHead>
|
||||
<TableHead className="pe-4 text-xs uppercase">
|
||||
{t("patients.columns.allergies")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-muted-foreground"
|
||||
colSpan={6}
|
||||
>
|
||||
{t("patients.loading")}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : loadError ? (
|
||||
<tr>
|
||||
<td className="px-4 py-10 text-center text-destructive" colSpan={6}>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-destructive"
|
||||
colSpan={6}
|
||||
>
|
||||
{loadError}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : patients.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
<TableRow>
|
||||
<TableCell
|
||||
className="py-10 text-center text-muted-foreground"
|
||||
colSpan={6}
|
||||
>
|
||||
{t("patients.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageRows.map((p) => (
|
||||
<tr
|
||||
className="cursor-pointer border-border/50 border-b transition-colors last:border-0 hover:bg-accent/50"
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={p.fileNumber}
|
||||
onClick={() => open(p.fileNumber)}
|
||||
onKeyDown={(event) => {
|
||||
@@ -216,7 +300,7 @@ export function PatientsView() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-foreground">
|
||||
<TableCell className="ps-4 py-3 font-medium text-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
{p.name}
|
||||
<AiBadge source={p.source} />
|
||||
@@ -226,30 +310,30 @@ export function PatientsView() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.fileNumber}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.age} · {p.sex}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<Badge variant={statusVariant[p.status]}>
|
||||
{t(`patients.status.${p.status}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="py-3 text-muted-foreground">
|
||||
{p.encounters[0]?.date ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
</TableCell>
|
||||
<TableCell className="pe-4 py-3 text-muted-foreground">
|
||||
{p.allergies.length || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardFrame>
|
||||
|
||||
{!loading && !loadError ? (
|
||||
<ListPagination
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -578,19 +579,17 @@ export function AddPrescriptionDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t("prescriptions.dialog.startDate")}>
|
||||
<input
|
||||
className={controlClass}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
type="date"
|
||||
<DatePicker
|
||||
onChange={setStartDate}
|
||||
placeholder={t("prescriptions.dialog.selectDate")}
|
||||
value={startDate}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("prescriptions.dialog.endDate")}>
|
||||
<input
|
||||
className={controlClass}
|
||||
<DatePicker
|
||||
min={startDate || undefined}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
type="date"
|
||||
onChange={setEndDate}
|
||||
placeholder={t("prescriptions.dialog.selectDate")}
|
||||
value={endDate}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { supportedLanguages } from "@/lib/i18n/config";
|
||||
import { persistLanguage } from "@/lib/language";
|
||||
import { SPECIALTIES, specialtyLabel } from "@/lib/staff";
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
@@ -60,8 +61,21 @@ const DEFAULT_PREFS: UserPreferences = {
|
||||
"notif.recordsShared": true,
|
||||
clinic: "",
|
||||
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() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
@@ -110,13 +124,23 @@ export function ProfilePanel() {
|
||||
const setPref = (key: string, value: boolean | string) =>
|
||||
setPrefs((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const links = parseLinks(prefs.links);
|
||||
const setLinks = (next: string[]) =>
|
||||
setPref("links", JSON.stringify(next));
|
||||
|
||||
const dirty =
|
||||
name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
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();
|
||||
if (trimmed && trimmed !== baselineName) {
|
||||
const { error } = await authClient.updateUser({ name: trimmed });
|
||||
@@ -182,13 +206,31 @@ export function ProfilePanel() {
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
|
||||
<button
|
||||
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"
|
||||
type="button"
|
||||
<Select
|
||||
onValueChange={(value) => setPref("specialty", value ?? "")}
|
||||
value={String(prefs.specialty ?? "")}
|
||||
>
|
||||
{t("settings.profile.selectSpecialty")}
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{(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 className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -221,7 +263,43 @@ export function ProfilePanel() {
|
||||
{t("settings.profile.professionalLinksHint")}
|
||||
</p>
|
||||
</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" />
|
||||
{t("settings.profile.addLink")}
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Building2,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Moon,
|
||||
Plus,
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
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";
|
||||
|
||||
@@ -82,7 +85,13 @@ function GitHubIcon({ className }: { className?: string }) {
|
||||
// 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 } = 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 isCollapsed = state === "collapsed";
|
||||
const router = useRouter();
|
||||
@@ -197,6 +206,29 @@ export function NavUser() {
|
||||
</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 />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: CheckboxPrimitive.Root.Props): React.ReactElement {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
"relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-[.25rem] border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none ring-ring transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[3px] not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-background aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 data-disabled:cursor-not-allowed data-disabled:opacity-64 sm:size-4 dark:not-data-checked:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
|
||||
className,
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className="absolute -inset-px flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground"
|
||||
data-slot="checkbox-indicator"
|
||||
render={(
|
||||
props: React.ComponentProps<"span">,
|
||||
state: CheckboxPrimitive.Indicator.State,
|
||||
) => (
|
||||
<span {...props}>
|
||||
{state.indeterminate ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-3.5 sm:size-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5.252 12h13.496" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-3.5 sm:size-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { CheckboxPrimitive };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TableVariant = "default" | "card";
|
||||
|
||||
export type TableProps = React.ComponentProps<"table"> & {
|
||||
variant?: TableVariant;
|
||||
render?: useRender.ComponentProps<"div">["render"];
|
||||
};
|
||||
|
||||
export function Table({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: TableProps): React.ReactElement {
|
||||
const defaultProps = {
|
||||
children: (
|
||||
<table
|
||||
className={cn(
|
||||
"w-full caption-bottom in-data-[variant=card]:border-separate in-data-[variant=card]:border-spacing-0 text-sm",
|
||||
className,
|
||||
)}
|
||||
data-slot="table"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
className: "relative w-full overflow-x-auto",
|
||||
"data-slot": "table-container",
|
||||
"data-variant": variant,
|
||||
};
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(defaultProps, {}),
|
||||
render,
|
||||
});
|
||||
}
|
||||
|
||||
export function TableHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"thead">): React.ReactElement {
|
||||
return (
|
||||
<thead
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
data-slot="table-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableBody({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tbody">): React.ReactElement {
|
||||
return (
|
||||
<tbody
|
||||
className={cn(
|
||||
"relative in-data-[variant=card]:rounded-xl in-data-[variant=card]:shadow-xs/5 before:pointer-events-none before:absolute before:inset-px not-in-data-[variant=card]:before:hidden before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/8%)] [&_tr:last-child]:border-0 in-data-[variant=card]:*:[tr]:border-0 in-data-[variant=card]:*:[tr]:*:[td]:border-b in-data-[variant=card]:*:[tr]:*:[td]:bg-card in-data-[variant=card]:*:[tr]:first:*:[td]:first:rounded-ss-xl in-data-[variant=card]:*:[tr]:*:[td]:first:border-s in-data-[variant=card]:*:[tr]:first:*:[td]:border-t in-data-[variant=card]:*:[tr]:last:*:[td]:last:rounded-ee-xl in-data-[variant=card]:*:[tr]:*:[td]:last:border-e in-data-[variant=card]:*:[tr]:first:*:[td]:last:rounded-se-xl in-data-[variant=card]:*:[tr]:last:*:[td]:first:rounded-es-xl in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_4%)] dark:in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_4%)] dark:in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)]",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-body"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tfoot">): React.ReactElement {
|
||||
return (
|
||||
<tfoot
|
||||
className={cn(
|
||||
"border-t in-data-[variant=card]:border-none bg-transparent not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] font-medium dark:not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)] [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-footer"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"tr">): React.ReactElement {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"relative border-b not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-black)_2%)] not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-black)_4%)] dark:not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-white)_4%)] dark:not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-white)_2%)]",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-row"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"th">): React.ReactElement {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-10 whitespace-nowrap px-2.5 text-left align-middle font-medium text-muted-foreground leading-none has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-head"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCell({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"td">): React.ReactElement {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"whitespace-nowrap bg-clip-padding p-2.5 in-data-[slot=table-footer]:py-3.5 align-middle leading-none in-data-[variant=card]:first:ps-[calc(--spacing(2.5)-1px)] in-data-[variant=card]:last:pe-[calc(--spacing(2.5)-1px)] has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-cell"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">): React.ReactElement {
|
||||
return (
|
||||
<caption
|
||||
className={cn(
|
||||
"in-data-[variant=card]:my-4 mt-4 text-muted-foreground text-sm",
|
||||
className,
|
||||
)}
|
||||
data-slot="table-caption"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { X } from "lucide-react";
|
||||
import { ArrowUpCircle, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getVersionInfo } from "@/lib/version";
|
||||
|
||||
const DISMISS_KEY = "temetro:update-dismissed";
|
||||
@@ -39,28 +41,36 @@ export function UpdateBanner() {
|
||||
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 (
|
||||
<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="space-y-1.5">
|
||||
<p className="text-sm text-foreground">
|
||||
<div className="fixed right-4 bottom-4 z-50 w-full max-w-sm" dir="auto">
|
||||
<Alert className="bg-card shadow-lg" variant="warning">
|
||||
<ArrowUpCircle />
|
||||
<AlertTitle className="text-foreground">
|
||||
{t("settings.version.banner", { version: latest })}
|
||||
</p>
|
||||
<Link
|
||||
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
href="/settings?tab=version"
|
||||
onClick={dismiss}
|
||||
>
|
||||
{t("settings.version.bannerUpdate")}
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("settings.version.bannerDismiss")}
|
||||
className="-me-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={dismiss}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</AlertTitle>
|
||||
<AlertAction>
|
||||
<Button
|
||||
render={
|
||||
<Link href="/settings?tab=version" onClick={dismiss} />
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.version.bannerUpdate")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("settings.version.bannerDismiss")}
|
||||
onClick={dismiss}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -263,7 +263,8 @@
|
||||
"createDialogDescription": "أضف عيادة جديدة وانتقل إليها.",
|
||||
"signOutFailed": "فشل تسجيل الخروج",
|
||||
"tryAgain": "يرجى المحاولة مرة أخرى.",
|
||||
"signedOut": "تم تسجيل الخروج"
|
||||
"signedOut": "تم تسجيل الخروج",
|
||||
"language": "اللغة"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "إنشاء عيادة",
|
||||
@@ -374,7 +375,10 @@
|
||||
"doneTitle": "تم حذف المريض",
|
||||
"failedTitle": "تعذّر حذف المريض",
|
||||
"failedBody": "يرجى المحاولة مرة أخرى."
|
||||
}
|
||||
},
|
||||
"filterStatus": "تصفية حسب الحالة",
|
||||
"allStatuses": "كل الحالات",
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "المواعيد والجدول",
|
||||
@@ -615,7 +619,8 @@
|
||||
"removeDates": "إزالة",
|
||||
"startDate": "تاريخ البدء",
|
||||
"endDate": "تاريخ الانتهاء",
|
||||
"addDates": "إضافة تاريخ البدء/الانتهاء"
|
||||
"addDates": "إضافة تاريخ البدء/الانتهاء",
|
||||
"selectDate": "اختر التاريخ"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1551,7 +1556,8 @@
|
||||
"mild": "خفيف",
|
||||
"moderate": "متوسط",
|
||||
"severe": "شديد"
|
||||
}
|
||||
},
|
||||
"moreActions": "إجراءات إضافية"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "تعديل السجل",
|
||||
@@ -1886,7 +1892,9 @@
|
||||
"pendingApprovalsDesc": "أبلغني عند موافقة مريض على تغيير معلّق أو رفضه",
|
||||
"recordsShared": "سجلات شُوركت معي",
|
||||
"recordsSharedDesc": "أبلغني عند مشاركة مريض سجلًا معي"
|
||||
}
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "إزالة الرابط"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "فريق الرعاية",
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Fügen Sie eine neue Klinik hinzu und wechseln Sie zu ihr.",
|
||||
"signOutFailed": "Abmeldung fehlgeschlagen",
|
||||
"tryAgain": "Bitte versuchen Sie es erneut.",
|
||||
"signedOut": "Abgemeldet"
|
||||
"signedOut": "Abgemeldet",
|
||||
"language": "Sprache"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Klinik erstellen",
|
||||
@@ -362,7 +363,10 @@
|
||||
"doneTitle": "Patient gelöscht",
|
||||
"failedTitle": "Patient konnte nicht gelöscht werden",
|
||||
"failedBody": "Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"filterStatus": "Nach Status filtern",
|
||||
"allStatuses": "Alle Status",
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Termine & Zeitplan",
|
||||
@@ -599,7 +603,8 @@
|
||||
"removeDates": "Entfernen",
|
||||
"startDate": "Startdatum",
|
||||
"endDate": "Enddatum",
|
||||
"addDates": "Start-/Enddatum hinzufügen"
|
||||
"addDates": "Start-/Enddatum hinzufügen",
|
||||
"selectDate": "Datum wählen"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1531,7 +1536,8 @@
|
||||
"mild": "Leicht",
|
||||
"moderate": "Mäßig",
|
||||
"severe": "Schwer"
|
||||
}
|
||||
},
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Datensatz bearbeiten",
|
||||
@@ -1866,7 +1872,9 @@
|
||||
"pendingApprovalsDesc": "Benachrichtigen Sie mich, wenn ein Patient eine ausstehende Änderung genehmigt oder ablehnt",
|
||||
"recordsShared": "Mit mir geteilte Datensätze",
|
||||
"recordsSharedDesc": "Benachrichtigen Sie mich, wenn ein Patient einen Datensatz mit mir teilt"
|
||||
}
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Link entfernen"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Behandlungsteam",
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Add a new clinic and switch to it.",
|
||||
"signOutFailed": "Sign out failed",
|
||||
"tryAgain": "Please try again.",
|
||||
"signedOut": "Signed out"
|
||||
"signedOut": "Signed out",
|
||||
"language": "Language"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Create clinic",
|
||||
@@ -362,7 +363,10 @@
|
||||
"doneTitle": "Patient deleted",
|
||||
"failedTitle": "Couldn't delete patient",
|
||||
"failedBody": "Please try again."
|
||||
}
|
||||
},
|
||||
"filterStatus": "Filter by status",
|
||||
"allStatuses": "All statuses",
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments & Schedule",
|
||||
@@ -599,7 +603,8 @@
|
||||
"removeDates": "Remove",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"addDates": "Add start/end dates"
|
||||
"addDates": "Add start/end dates",
|
||||
"selectDate": "Select date"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1531,7 +1536,8 @@
|
||||
"mild": "Mild",
|
||||
"moderate": "Moderate",
|
||||
"severe": "Severe"
|
||||
}
|
||||
},
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Edit record",
|
||||
@@ -1866,7 +1872,9 @@
|
||||
"pendingApprovalsDesc": "Notify me when a patient approves or rejects a pending change",
|
||||
"recordsShared": "Records shared with me",
|
||||
"recordsSharedDesc": "Notify me when a patient shares a record with me"
|
||||
}
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Remove link"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Care team",
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Ajoutez une nouvelle clinique et passez-y.",
|
||||
"signOutFailed": "Échec de la déconnexion",
|
||||
"tryAgain": "Veuillez réessayer.",
|
||||
"signedOut": "Déconnecté"
|
||||
"signedOut": "Déconnecté",
|
||||
"language": "Langue"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Créer une clinique",
|
||||
@@ -362,7 +363,10 @@
|
||||
"doneTitle": "Patient supprimé",
|
||||
"failedTitle": "Impossible de supprimer le patient",
|
||||
"failedBody": "Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"filterStatus": "Filtrer par statut",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Rendez-vous & Planning",
|
||||
@@ -599,7 +603,8 @@
|
||||
"removeDates": "Retirer",
|
||||
"startDate": "Date de début",
|
||||
"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": {
|
||||
@@ -1531,7 +1536,8 @@
|
||||
"mild": "Légère",
|
||||
"moderate": "Modérée",
|
||||
"severe": "Sévère"
|
||||
}
|
||||
},
|
||||
"moreActions": "Plus d’actions"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Modifier le dossier",
|
||||
@@ -1866,7 +1872,9 @@
|
||||
"pendingApprovalsDesc": "Me notifier lorsqu'un patient approuve ou rejette une modification en attente",
|
||||
"recordsShared": "Dossiers partagés avec moi",
|
||||
"recordsSharedDesc": "Me notifier lorsqu'un patient partage un dossier avec moi"
|
||||
}
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Supprimer le lien"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Équipe soignante",
|
||||
|
||||
@@ -259,7 +259,8 @@
|
||||
"createDialogDescription": "Ku dar rug cusub oo u beddel.",
|
||||
"signOutFailed": "Ka-bixitaanka waa fashilmay",
|
||||
"tryAgain": "Fadlan isku day mar kale.",
|
||||
"signedOut": "Waa laga baxay"
|
||||
"signedOut": "Waa laga baxay",
|
||||
"language": "Luqadda"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Samee rug",
|
||||
@@ -362,7 +363,10 @@
|
||||
"doneTitle": "Bukaanka waa la tirtiray",
|
||||
"failedTitle": "Bukaanka lama tirtiri karin",
|
||||
"failedBody": "Fadlan isku day mar kale."
|
||||
}
|
||||
},
|
||||
"filterStatus": "Ku kala sooc xaaladda",
|
||||
"allStatuses": "Dhammaan xaaladaha",
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Ballamaha & Jadwalka",
|
||||
@@ -599,7 +603,8 @@
|
||||
"removeDates": "Ka saar",
|
||||
"startDate": "Taariikhda bilowga",
|
||||
"endDate": "Taariikhda dhammaadka",
|
||||
"addDates": "Ku dar taariikhaha bilow/dhammaad"
|
||||
"addDates": "Ku dar taariikhaha bilow/dhammaad",
|
||||
"selectDate": "Dooro taariikhda"
|
||||
}
|
||||
},
|
||||
"pharmacy": {
|
||||
@@ -1531,7 +1536,8 @@
|
||||
"mild": "Fudud",
|
||||
"moderate": "Dhexdhexaad",
|
||||
"severe": "Daran"
|
||||
}
|
||||
},
|
||||
"moreActions": "Ficillo dheeraad ah"
|
||||
},
|
||||
"patientForm": {
|
||||
"editTitle": "Wax ka beddel diiwaanka",
|
||||
@@ -1866,7 +1872,9 @@
|
||||
"pendingApprovalsDesc": "I ogeysii marka bukaan ansixiyo ama diido isbeddel la sugayo",
|
||||
"recordsShared": "Diiwaanno la ila wadaagay",
|
||||
"recordsSharedDesc": "I ogeysii marka bukaan diiwaan ila wadaago"
|
||||
}
|
||||
},
|
||||
"linkPlaceholder": "https://example.com",
|
||||
"removeLink": "Ka saar linkiga"
|
||||
},
|
||||
"careTeam": {
|
||||
"title": "Kooxda daryeelka",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.12.1",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user