diff --git a/package-lock.json b/package-lock.json index 3577629..04b0f31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "otpauth": "^9.5.1", "qrcode": "^1.5.4", "react": "^19.2.8", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.8", "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", @@ -309,6 +310,12 @@ "node": ">=6.9.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/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1922,6 +1929,15 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -3321,6 +3337,22 @@ "node": ">=12" } }, + "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/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5501,6 +5533,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", diff --git a/package.json b/package.json index aeb456e..6ebeec3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "otpauth": "^9.5.1", "qrcode": "^1.5.4", "react": "^19.2.8", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.8", "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", @@ -67,4 +68,4 @@ "vite": "^8.1.5", "vitest": "^4.1.10" } -} \ No newline at end of file +} diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index 5ba3b52..38f68c6 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -import { useState, useEffect, type KeyboardEvent } from 'react'; +import { useState, useEffect, useMemo, type KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue'; import ReactMarkdown from 'react-markdown'; @@ -21,8 +21,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Combobox, type ComboboxOption } from '@/components/ui/combobox'; +import { Calendar } from '@/components/ui/calendar'; -import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react'; +import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -40,6 +41,7 @@ import { DURATION_UNITS, } from '@/lib/durationFormat'; import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver'; +import { cn } from '@/lib/utils'; import { useAccountStore } from '@/stores/accountStore'; import { useEffectiveEdition } from '@/components/forms/FormEditionContext'; import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions'; @@ -835,28 +837,25 @@ interface DateTimeFieldProps { function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) { const { t } = useTranslation(); const strValue = typeof value === 'string' ? value : ''; + const [open, setOpen] = useState(false); - const toLocal = (iso: string): string => { - if (!iso) return ''; - try { - const d = new Date(iso); - if (isNaN(d.getTime())) return ''; - return d.toISOString().slice(0, 16); - } catch { - return ''; - } - }; + const parsed = useMemo(() => { + if (!strValue) return null; + const d = new Date(strValue); + return isNaN(d.getTime()) ? null : d; + }, [strValue]); - const toIso = (local: string): string | null => { - if (!local) return nullable ? null : ''; - return new Date(local).toISOString(); - }; + const timeValue = parsed + ? `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}` + : ''; - const [local, setLocal] = useBufferedValue(strValue, toLocal); - - const commit = () => { - const iso = toIso(local); - if (iso !== strValue) onChange(iso); + // Date and time are committed together so the field never holds a + // half-entered value the way a free-form datetime input does. + const commit = (date: Date, time: string) => { + const [hours, minutes] = time.split(':').map(Number); + const next = new Date(date); + next.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0); + onChange(next.toISOString()); }; if (readOnly) { @@ -879,13 +878,43 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro return (
- setLocal(e.target.value)} - onBlur={commit} - className="flex-1" - /> + + + + + + { + if (!day) return; + commit(day, timeValue || '00:00'); + setOpen(false); + }} + autoFocus + /> +
+ { + if (parsed && e.target.value) commit(parsed, e.target.value); + }} + aria-label={t('field.time', 'Time')} + /> +
+
+
{nullable && strValue && (