diff --git a/web/package.json b/web/package.json index b5e9362a..b5b4b23c 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "pdfjs-dist": "^5.7.284", "radix-ui": "^1.4.3", "react": "^19.0.0", + "react-day-picker": "^10.0.1", "react-dom": "^19.0.0", "react-qr-code": "^2.0.18", "react-router": "^7.15.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 62c13ffb..ffc874fd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: react: specifier: ^19.0.0 version: 19.2.4 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.14)(react@19.2.4) react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) @@ -375,6 +378,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -2014,6 +2020,9 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2704,6 +2713,16 @@ packages: '@types/react-dom': optional: true + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3335,6 +3354,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -4890,6 +4911,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + date-fns@4.4.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5508,6 +5531,14 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 diff --git a/web/src/components/DateTimePicker.tsx b/web/src/components/DateTimePicker.tsx new file mode 100644 index 00000000..5b3c7af0 --- /dev/null +++ b/web/src/components/DateTimePicker.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { formatDateTime } from "@/lib/datetime"; +import { cn } from "@/lib/utils"; + +/** + * Calendar + time-of-day picker that speaks the same `YYYY-MM-DDTHH:mm` local + * string format as a native `datetime-local` input, so it can drop into + * existing filter state without conversions. + */ +export function DateTimePicker({ + id, + value, + onChange, + placeholder = "Any time", + className, +}: { + id?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +}) { + const [open, setOpen] = useState(false); + const selected = parseLocalDateTime(value); + const time = selected ? value.slice(11, 16) : ""; + + function handleSelectDay(day: Date | undefined) { + if (!day) { + onChange(""); + return; + } + onChange(`${toLocalDateString(day)}T${time || "00:00"}`); + } + + function handleTimeChange(nextTime: string) { + const day = selected ?? new Date(); + onChange(`${toLocalDateString(day)}T${nextTime || "00:00"}`); + } + + return ( + + + + + + +
+ handleTimeChange(event.target.value)} + className="h-8" + /> + +
+
+
+ ); +} + +function parseLocalDateTime(value: string) { + if (!value) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +function toLocalDateString(day: Date) { + const pad = (part: number) => String(part).padStart(2, "0"); + return `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`; +} diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx new file mode 100644 index 00000000..b2d3d753 --- /dev/null +++ b/web/src/components/ui/calendar.tsx @@ -0,0 +1,67 @@ +import type { ComponentProps } from "react"; +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: ComponentProps) { + return ( + button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground", + today: "rounded-md [&>button:not([aria-selected])]:bg-accent", + outside: "text-muted-foreground", + disabled: "text-muted-foreground opacity-50", + hidden: "invisible", + ...classNames, + }} + components={{ + Chevron: ({ orientation, className: chevronClassName }) => { + const Icon = + orientation === "left" + ? ChevronLeft + : orientation === "right" + ? ChevronRight + : orientation === "up" + ? ChevronUp + : ChevronDown; + return