From 840df525817f1bcfa65dbf1fd47e4adbe349c13c Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:15:40 +0200 Subject: [PATCH] fix(crm): respect general_date_format on all admin date inputs Admin date inputs were inconsistent: raw on event creation and the bill editor rendered in the browser locale (en-US users saw MM/DD/YYYY regardless of Settings -> General), while the historical- invoice import modal used a private LocalizedDateField that displayed the configured format but showed a text box plus a tiny native date stub side-by-side ("two date fields, looks corrupted"). Extract a single shared LocalizedDateInput that displays/parses in the configured general_date_format on every browser and opens the native picker via a calendar icon button (showPicker on a visually-hidden native input), so there is one date field, not two. Wire it into event creation, the bill editor (event/issue/due dates), the import modal, and the tax- report range filters (dropping the Chromium-only lang={dateInputLang} workaround there). --- .../components/common/LocalizedDateInput.tsx | 168 ++++++++++++++++++ frontend/src/components/common/index.ts | 1 + frontend/src/pages/admin/CreateEventPage.tsx | 8 +- .../src/pages/admin/bills/BillEditorPage.tsx | 10 +- .../src/pages/admin/bills/BillsListPage.tsx | 116 +----------- .../src/pages/admin/clients/TaxReportPage.tsx | 22 +-- 6 files changed, 188 insertions(+), 137 deletions(-) create mode 100644 frontend/src/components/common/LocalizedDateInput.tsx diff --git a/frontend/src/components/common/LocalizedDateInput.tsx b/frontend/src/components/common/LocalizedDateInput.tsx new file mode 100644 index 00000000..03fe0003 --- /dev/null +++ b/frontend/src/components/common/LocalizedDateInput.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { clsx } from 'clsx'; +import { Calendar } from 'lucide-react'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; + +/** + * Date input that displays + accepts values in the admin-configured + * format from Settings → General (`general_date_format`), independent + * of the browser locale. Stores + emits ISO (YYYY-MM-DD) so the rest + * of the form / API surface keeps the canonical shape. + * + * A native `` always renders in the browser's own + * locale (en-US users see MM/DD/YYYY) no matter what the app is + * configured for, so it can't be used directly. This component shows + * a plain text input in the configured format and parses on blur. A + * calendar icon button opens the native date picker (via showPicker()) + * off a visually-hidden native input, giving the click-to-pick + * affordance without rendering a second visible date box. + */ +interface LocalizedDateInputProps { + label?: string; + value: string; + onChange: (iso: string) => void; + error?: string; + /** Forwarded to the native picker so min/max date constraints work. */ + min?: string; + max?: string; + disabled?: boolean; +} + +export const LocalizedDateInput: React.FC = ({ + label, + value, + onChange, + error, + min, + max, + disabled, +}) => { + const { dateFormat } = useLocalizedDate(); + const nativeRef = React.useRef(null); + const inputId = React.useId(); + + // Normalise the configured format down to the four shapes the parser + // understands. Defaults to DD.MM.YYYY (the operator's primary locale) + // when unknown. + const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => { + const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase(); + if (f.startsWith('mm/dd')) return 'MM/DD/YYYY'; + if (f.startsWith('yyyy')) return 'YYYY-MM-DD'; + if (f.includes('/')) return 'DD/MM/YYYY'; + return 'DD.MM.YYYY'; + })(); + const placeholder = normalisedFormat.toLowerCase(); + + // ISO → display + const toDisplay = (iso: string): string => { + if (!iso) return ''; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) return iso; + const [, y, mo, d] = m; + switch (normalisedFormat) { + case 'MM/DD/YYYY': return `${mo}/${d}/${y}`; + case 'YYYY-MM-DD': return `${y}-${mo}-${d}`; + case 'DD/MM/YYYY': return `${d}/${mo}/${y}`; + case 'DD.MM.YYYY': + default: return `${d}.${mo}.${y}`; + } + }; + + // display → ISO (accepts variant separators leniently) + const toIso = (raw: string): string => { + const s = raw.trim(); + if (!s) return ''; + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + const parts = s.split(/[./-]/); + if (parts.length !== 3) return ''; + const [a, b, c] = parts; + let y: string, mo: string, d: string; + if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) { + [y, mo, d] = [a, b, c]; + } else if (normalisedFormat === 'MM/DD/YYYY') { + [mo, d, y] = [a, b, c]; + } else { + [d, mo, y] = [a, b, c]; + } + if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return ''; + return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; + }; + + const [text, setText] = React.useState(toDisplay(value)); + React.useEffect(() => { + setText(toDisplay(value)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + const openPicker = () => { + const el = nativeRef.current; + if (!el) return; + try { + el.showPicker(); + } catch { + // showPicker throws on unsupported browsers / outside a user + // gesture — the text field stays fully usable for typing. + } + }; + + return ( +
+ {label && ( + + )} +
+ setText(e.target.value)} + onBlur={() => { + const iso = toIso(text); + if (iso) { + onChange(iso); + setText(toDisplay(iso)); + } else if (!text.trim()) { + onChange(''); + } + }} + className={clsx('input pr-10', error && 'border-red-500 focus-visible:ring-red-500')} + aria-invalid={error ? 'true' : 'false'} + aria-describedby={error ? `${inputId}-error` : undefined} + /> + + {/* Visually hidden native picker — its only job is to provide + the calendar popup the icon button triggers. Value stays in + ISO so it's always parseable. */} + onChange(e.target.value)} + tabIndex={-1} + aria-hidden="true" + className="sr-only" + /> +
+ {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 46702452..dc5984ea 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -1,6 +1,7 @@ export { Button } from './Button'; export { CMSContentBlock } from './CMSContentBlock'; export { Input } from './Input'; +export { LocalizedDateInput } from './LocalizedDateInput'; export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Loading, LoadingSkeleton } from './Loading'; export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 99cd4422..e5df23f7 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -15,7 +15,7 @@ import { import { addDays } from 'date-fns'; import { toast } from 'react-toastify'; -import { Button, Input, Card, PasswordGenerator } from '../../components/common'; +import { Button, Input, Card, PasswordGenerator, LocalizedDateInput } from '../../components/common'; import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -587,13 +587,11 @@ export const CreateEventPage: React.FC = () => { leftIcon={} /> - setFormData(prev => ({ ...prev, event_date: iso }))} error={errors.event_date} - leftIcon={} /> diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index ff6d0258..38edc81e 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common'; import { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service'; import { quotesService } from '../../../services/quotes.service'; import { contractsService } from '../../../services/contracts.service'; @@ -488,8 +488,8 @@ export const BillEditorPage: React.FC = () => {
setEventName(e.target.value)} /> - setEventDate(e.target.value)} /> + setEventTimeStart(e.target.value)} /> {

{t('bills.section.details', 'Details')}

- setIssueDate(e.target.value)} /> - setDueDate(e.target.value)} /> + + setScheduledSendAt(e.target.value)} />
diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index cba0e2e5..a4c413f8 100644 --- a/frontend/src/pages/admin/bills/BillsListPage.tsx +++ b/frontend/src/pages/admin/bills/BillsListPage.tsx @@ -8,7 +8,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Search, Upload, X } from 'lucide-react'; import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service'; -import { Button, Card, Input, Loading } from '../../../components/common'; +import { Button, Card, Input, Loading, LocalizedDateInput } from '../../../components/common'; import { formatMoney } from '../../../components/admin/LineItemsTable'; import { customerAdminService } from '../../../services/customerAdmin.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; @@ -329,12 +329,12 @@ const ImportHistoricalInvoiceModal: React.FC = ({ onClose }) = value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /> - - = ({ onClose }) =
); }; - -/** - * Date field that displays + accepts values in the admin-configured - * format from Settings → General (`general_date_format`). Stores - * + emits ISO (YYYY-MM-DD) so the rest of the form / API surface - * keeps the canonical shape. - * - * Native `` always renders in the browser's - * locale (en-US users see MM/DD/YYYY), which mismatched what - * customers + the rest of the app see elsewhere. This component - * uses a plain text input + parses on blur, with the configured - * format shown as both placeholder and helper text. A small - * shadow native date input next to the field gives the click-to- - * open calendar without affecting the displayed format. - */ -interface LocalizedDateFieldProps { - label: string; - value: string; - onChange: (iso: string) => void; -} -const LocalizedDateField: React.FC = ({ label, value, onChange }) => { - const { dateFormat } = useLocalizedDate(); - // Normalise the configured format down to the four shapes our - // parser understands. Defaults to DD.MM.YYYY (the maintainer's - // primary locale) when unknown. - const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => { - const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase(); - if (f.startsWith('mm/dd')) return 'MM/DD/YYYY'; - if (f.startsWith('yyyy')) return 'YYYY-MM-DD'; - if (f.includes('/')) return 'DD/MM/YYYY'; - return 'DD.MM.YYYY'; - })(); - const placeholder = normalisedFormat.toLowerCase(); - - // ISO → display - const toDisplay = (iso: string): string => { - if (!iso) return ''; - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); - if (!m) return iso; - const [, y, mo, d] = m; - switch (normalisedFormat) { - case 'MM/DD/YYYY': return `${mo}/${d}/${y}`; - case 'YYYY-MM-DD': return `${y}-${mo}-${d}`; - case 'DD/MM/YYYY': return `${d}/${mo}/${y}`; - case 'DD.MM.YYYY': - default: return `${d}.${mo}.${y}`; - } - }; - - // display → ISO (accepts variant separators leniently) - const toIso = (raw: string): string => { - const s = raw.trim(); - if (!s) return ''; - // Already ISO? - if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; - // Split on . / - - const parts = s.split(/[./-]/); - if (parts.length !== 3) return ''; - let [a, b, c] = parts; - let y: string, mo: string, d: string; - if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) { - [y, mo, d] = [a, b, c]; - } else if (normalisedFormat === 'MM/DD/YYYY') { - [mo, d, y] = [a, b, c]; - } else { - // DD.MM.YYYY or DD/MM/YYYY - [d, mo, y] = [a, b, c]; - } - if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return ''; - return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; - }; - - const [text, setText] = React.useState(toDisplay(value)); - React.useEffect(() => { setText(toDisplay(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value]); - - return ( -
- -
- setText(e.target.value)} - onBlur={() => { - const iso = toIso(text); - if (iso) { - onChange(iso); - setText(toDisplay(iso)); - } else if (!text.trim()) { - onChange(''); - } - }} - /> - {/* Tiny native date picker shortcut — gives the calendar - without polluting the visible text input. Hidden value - stays in ISO so it's always parseable. */} - onChange(e.target.value)} - aria-label={label} - className="text-sm px-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800" - style={{ width: 36 }} - /> -
-

{placeholder}

-
- ); -}; - diff --git a/frontend/src/pages/admin/clients/TaxReportPage.tsx b/frontend/src/pages/admin/clients/TaxReportPage.tsx index 958ef04c..d60c3ade 100644 --- a/frontend/src/pages/admin/clients/TaxReportPage.tsx +++ b/frontend/src/pages/admin/clients/TaxReportPage.tsx @@ -18,7 +18,7 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common'; // Lightweight native select styled to match Input — the common barrel // doesn't export a Select component, and the form pieces here are @@ -89,7 +89,7 @@ function triggerBrowserDownload(url: string, filename: string) { export const TaxReportPage: React.FC = () => { const { t, i18n } = useTranslation(); - const { format: fmtDate, dateInputLang } = useLocalizedDate(); + const { format: fmtDate } = useLocalizedDate(); const [preset, setPreset] = useState('thisYear'); const initialPeriod = useMemo(() => periodForPreset('thisYear'), []); const [from, setFrom] = useState(initialPeriod.from); @@ -196,27 +196,21 @@ export const TaxReportPage: React.FC = () => {
-
-