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 = () => {