mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Add patient dialog: use a date picker for date fields
Install COSS Calendar (@coss/calendar, react-day-picker) and add a co-located DatePicker (Popover + Button trigger + Calendar) in patient-form-dialog. It reads/writes the same formatted date string the form already uses (formatDate/ parseDate), so the Patient model is unchanged. Replace the free-text Visit 'Date' and Problem 'Since' inputs with the picker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, RefreshCw, X } from "lucide-react";
|
||||
import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverPopup,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -112,13 +118,76 @@ function initialsFromName(name: string): string {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
const today = () =>
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const today = () => formatDate(new Date());
|
||||
|
||||
// Patient dates are stored as formatted strings (e.g. "Jun 02, 2026"); parse one
|
||||
// back to a Date so the calendar can highlight the current selection.
|
||||
function parseDate(value: string): Date | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(trimmed);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
|
||||
// Calendar-backed date field that reads/writes the same formatted string the rest
|
||||
// of the form uses.
|
||||
function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
placeholder = "Pick a date",
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"justify-start font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CalendarIcon className="size-4" />
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverPopup className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
onSelect={(date) => {
|
||||
onChange(date ? formatDate(date) : "");
|
||||
setOpen(false);
|
||||
}}
|
||||
selected={parseDate(value)}
|
||||
/>
|
||||
</PopoverPopup>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatientFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -429,10 +498,10 @@ export function PatientFormDialog({
|
||||
placeholder="Diagnosis"
|
||||
value={row.label}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Since"
|
||||
className="w-28 shrink-0"
|
||||
onChange={(event) => set({ since: event.target.value })}
|
||||
<DatePicker
|
||||
ariaLabel="Since"
|
||||
className="w-40 shrink-0"
|
||||
onChange={(since) => set({ since })}
|
||||
placeholder="Since"
|
||||
value={row.since}
|
||||
/>
|
||||
@@ -488,10 +557,10 @@ export function PatientFormDialog({
|
||||
placeholder="Type"
|
||||
value={row.type}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Visit date"
|
||||
className="w-32 shrink-0"
|
||||
onChange={(event) => set({ date: event.target.value })}
|
||||
<DatePicker
|
||||
ariaLabel="Visit date"
|
||||
className="w-40 shrink-0"
|
||||
onChange={(date) => set({ date })}
|
||||
placeholder="Date"
|
||||
value={row.date}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsUpDownIcon,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonClassNames =
|
||||
"relative flex size-(--cell-size) text-base sm:text-sm items-center justify-center rounded-lg text-foreground not-in-data-selected:hover:bg-accent disabled:pointer-events-none disabled:opacity-64 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0";
|
||||
|
||||
export function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
components: userComponents,
|
||||
mode = "single",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>): React.ReactElement {
|
||||
const defaultClassNames = {
|
||||
button_next: buttonClassNames,
|
||||
button_previous: buttonClassNames,
|
||||
caption_label:
|
||||
"text-base sm:text-sm font-medium flex items-center gap-2 h-full",
|
||||
day: "size-(--cell-size) text-sm py-px",
|
||||
day_button: cn(
|
||||
buttonClassNames,
|
||||
"in-data-disabled:pointer-events-none in-[.range-middle]:rounded-none in-[.range-end:not(.range-start)]:rounded-s-none in-[.range-start:not(.range-end)]:rounded-e-none in-[.range-middle]:in-data-selected:bg-accent in-data-selected:bg-primary in-[.range-middle]:in-data-selected:text-foreground in-data-disabled:text-muted-foreground/72 in-data-outside:text-muted-foreground/72 in-data-selected:in-data-outside:text-primary-foreground in-data-selected:text-primary-foreground in-data-disabled:line-through outline-none in-[[data-selected]:not(.range-middle)]:transition-[color,background-color,border-radius,box-shadow] focus-visible:z-1 focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
),
|
||||
dropdown: "absolute bg-popover inset-0 opacity-0",
|
||||
dropdown_root:
|
||||
"relative has-focus:border-ring has-focus:ring-ring/50 has-focus:ring-[3px] border border-input shadow-xs/5 rounded-lg px-[calc(--spacing(3)-1px)] h-9 sm:h-8 [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-me-1",
|
||||
dropdowns:
|
||||
"w-full flex items-center text-base sm:text-sm justify-center h-(--cell-size) gap-1.5 *:[span]:font-medium",
|
||||
hidden: "invisible",
|
||||
month: "w-full",
|
||||
month_caption:
|
||||
"relative mx-(--cell-size) px-1 mb-1 flex h-(--cell-size) items-center justify-center z-2",
|
||||
months: "relative flex flex-col sm:flex-row gap-2",
|
||||
nav: "absolute top-0 flex w-full justify-between z-1",
|
||||
outside:
|
||||
"text-muted-foreground data-selected:bg-accent/50 data-selected:text-muted-foreground",
|
||||
range_end: "range-end",
|
||||
range_middle: "range-middle",
|
||||
range_start: "range-start",
|
||||
today:
|
||||
"*:after:pointer-events-none *:after:absolute *:after:bottom-1 *:after:start-1/2 *:after:z-1 *:after:size-[3px] *:after:-translate-x-1/2 *:after:rounded-full *:after:bg-primary [&[data-selected]:not(.range-middle)>*]:after:bg-background [&[data-disabled]>*]:after:bg-foreground/30 *:after:transition-colors",
|
||||
week_number:
|
||||
"size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72",
|
||||
weekday:
|
||||
"size-(--cell-size) p-0 text-xs font-medium text-muted-foreground/72",
|
||||
};
|
||||
const mergedClassNames: typeof defaultClassNames = Object.keys(
|
||||
defaultClassNames,
|
||||
).reduce(
|
||||
(acc, key) => {
|
||||
const userClass = classNames?.[key as keyof typeof classNames];
|
||||
const baseClass =
|
||||
defaultClassNames[key as keyof typeof defaultClassNames];
|
||||
|
||||
acc[key as keyof typeof defaultClassNames] = userClass
|
||||
? cn(baseClass, userClass)
|
||||
: baseClass;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ ...defaultClassNames } as typeof defaultClassNames,
|
||||
);
|
||||
|
||||
const defaultComponents = {
|
||||
Chevron: ({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
orientation?: "left" | "right" | "up" | "down";
|
||||
}): React.ReactElement => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon
|
||||
className={cn(className, "rtl:rotate-180")}
|
||||
{...props}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn(className, "rtl:rotate-180")}
|
||||
{...props}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronsUpDownIcon
|
||||
className={className}
|
||||
{...props}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const mergedComponents = {
|
||||
...defaultComponents,
|
||||
...userComponents,
|
||||
};
|
||||
|
||||
const dayPickerProps = {
|
||||
className: cn(
|
||||
"w-fit [--cell-size:--spacing(10)] sm:[--cell-size:--spacing(9)]",
|
||||
className,
|
||||
),
|
||||
classNames: mergedClassNames,
|
||||
components: mergedComponents,
|
||||
"data-slot": "calendar",
|
||||
formatters: {
|
||||
formatMonthDropdown: (date: Date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
} as React.ComponentProps<typeof DayPicker>["formatters"],
|
||||
mode,
|
||||
showOutsideDays,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
{...(dayPickerProps as React.ComponentProps<typeof DayPicker>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Generated
+43
@@ -33,6 +33,7 @@
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-jsx-parser": "^2.4.1",
|
||||
@@ -757,6 +758,12 @@
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.69.2",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.69.2.tgz",
|
||||
@@ -6170,6 +6177,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
@@ -12057,6 +12074,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
|
||||
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8.0",
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-jsx-parser": "^2.4.1",
|
||||
|
||||
Reference in New Issue
Block a user