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:
Khalid Abdi
2026-06-03 19:21:09 +03:00
parent 848eab27df
commit 415961ab52
4 changed files with 263 additions and 11 deletions
@@ -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}
/>
+139
View File
@@ -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>)}
/>
);
}
+43
View File
@@ -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",
+1
View File
@@ -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",