fix(crm): respect general_date_format on all admin date inputs

Admin date inputs were inconsistent: raw <input type="date"> 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).
This commit is contained in:
Luca
2026-06-02 01:15:40 +02:00
parent 3b2c74803b
commit 840df52581
6 changed files with 188 additions and 137 deletions
@@ -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 `<input type="date">` 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<LocalizedDateInputProps> = ({
label,
value,
onChange,
error,
min,
max,
disabled,
}) => {
const { dateFormat } = useLocalizedDate();
const nativeRef = React.useRef<HTMLInputElement>(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 (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5">
{label}
</label>
)}
<div className="relative">
<input
id={inputId}
value={text}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => 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}
/>
<button
type="button"
onClick={openPicker}
disabled={disabled}
tabIndex={-1}
aria-label={label}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 disabled:opacity-50"
>
<Calendar className="w-5 h-5" />
</button>
{/* 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. */}
<input
ref={nativeRef}
type="date"
value={value || ''}
min={min}
max={max}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
/>
</div>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
);
};
+1
View File
@@ -1,6 +1,7 @@
export { Button } from './Button'; export { Button } from './Button';
export { CMSContentBlock } from './CMSContentBlock'; export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input'; export { Input } from './Input';
export { LocalizedDateInput } from './LocalizedDateInput';
export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading'; export { Loading, LoadingSkeleton } from './Loading';
export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary'; export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary';
+3 -5
View File
@@ -15,7 +15,7 @@ import {
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { toast } from 'react-toastify'; 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 { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
@@ -587,13 +587,11 @@ export const CreateEventPage: React.FC = () => {
leftIcon={<Calendar className="w-5 h-5" />} leftIcon={<Calendar className="w-5 h-5" />}
/> />
<Input <LocalizedDateInput
type="date"
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`} label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
value={formData.event_date} value={formData.event_date}
onChange={handleInputChange('event_date')} onChange={(iso) => setFormData(prev => ({ ...prev, event_date: iso }))}
error={errors.event_date} error={errors.event_date}
leftIcon={<Calendar className="w-5 h-5" />}
/> />
</div> </div>
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react'; 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 { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service';
import { quotesService } from '../../../services/quotes.service'; import { quotesService } from '../../../services/quotes.service';
import { contractsService } from '../../../services/contracts.service'; import { contractsService } from '../../../services/contracts.service';
@@ -488,8 +488,8 @@ export const BillEditorPage: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('bills.field.eventName', 'Event') as string} <Input label={t('bills.field.eventName', 'Event') as string}
value={eventName} onChange={(e) => setEventName(e.target.value)} /> value={eventName} onChange={(e) => setEventName(e.target.value)} />
<Input type="date" label={t('bills.field.eventDate', 'Event date') as string} <LocalizedDateInput label={t('bills.field.eventDate', 'Event date') as string}
value={eventDate} onChange={(e) => setEventDate(e.target.value)} /> value={eventDate} onChange={setEventDate} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string} <Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string}
value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} /> value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string} <Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string}
@@ -500,8 +500,8 @@ export const BillEditorPage: React.FC = () => {
<Card> <Card>
<h3 className="font-semibold mb-2">{t('bills.section.details', 'Details')}</h3> <h3 className="font-semibold mb-2">{t('bills.section.details', 'Details')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input type="date" label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={(e) => setIssueDate(e.target.value)} /> <LocalizedDateInput label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={setIssueDate} />
<Input type="date" label={t('bills.field.dueDate', 'Due date') as string} value={dueDate} onChange={(e) => setDueDate(e.target.value)} /> <LocalizedDateInput label={t('bills.field.dueDate', 'Due date') as string} value={dueDate} onChange={setDueDate} />
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string} <Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} /> value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
<div> <div>
@@ -8,7 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Upload, X } from 'lucide-react'; import { Plus, Search, Upload, X } from 'lucide-react';
import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service'; 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 { formatMoney } from '../../../components/admin/LineItemsTable';
import { customerAdminService } from '../../../services/customerAdmin.service'; import { customerAdminService } from '../../../services/customerAdmin.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
@@ -329,12 +329,12 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
value={currency} value={currency}
maxLength={3} maxLength={3}
onChange={(e) => setCurrency(e.target.value.toUpperCase())} /> onChange={(e) => setCurrency(e.target.value.toUpperCase())} />
<LocalizedDateField <LocalizedDateInput
label={t('bills.field.issueDate', 'Issued') as string} label={t('bills.field.issueDate', 'Issued') as string}
value={issueDate} value={issueDate}
onChange={setIssueDate} onChange={setIssueDate}
/> />
<LocalizedDateField <LocalizedDateInput
label={t('bills.field.dueDate', 'Due') as string} label={t('bills.field.dueDate', 'Due') as string}
value={dueDate} value={dueDate}
onChange={setDueDate} onChange={setDueDate}
@@ -392,113 +392,3 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
</div> </div>
); );
}; };
/**
* 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 `<input type="date">` 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<LocalizedDateFieldProps> = ({ 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 (
<div>
<label className="block text-sm font-medium mb-1">{label}</label>
<div className="flex gap-2">
<Input
value={text}
placeholder={placeholder}
onChange={(e) => 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. */}
<input
type="date"
value={value || ''}
onChange={(e) => 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 }}
/>
</div>
<p className="text-xs text-neutral-500 mt-1">{placeholder}</p>
</div>
);
};
@@ -18,7 +18,7 @@ import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react'; 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 // Lightweight native select styled to match Input — the common barrel
// doesn't export a Select component, and the form pieces here are // 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 = () => { export const TaxReportPage: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { format: fmtDate, dateInputLang } = useLocalizedDate(); const { format: fmtDate } = useLocalizedDate();
const [preset, setPreset] = useState<PeriodPreset>('thisYear'); const [preset, setPreset] = useState<PeriodPreset>('thisYear');
const initialPeriod = useMemo(() => periodForPreset('thisYear'), []); const initialPeriod = useMemo(() => periodForPreset('thisYear'), []);
const [from, setFrom] = useState(initialPeriod.from); const [from, setFrom] = useState(initialPeriod.from);
@@ -196,27 +196,21 @@ export const TaxReportPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label htmlFor="period-from" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.from', 'From')} {t('taxReport.filters.from', 'From')}
</label> </label>
<Input <LocalizedDateInput
id="period-from"
type="date"
lang={dateInputLang}
value={from} value={from}
onChange={(e) => { setFrom(e.target.value); setPreset('custom'); }} onChange={(iso) => { setFrom(iso); setPreset('custom'); }}
/> />
</div> </div>
<div> <div>
<label htmlFor="period-to" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.to', 'To')} {t('taxReport.filters.to', 'To')}
</label> </label>
<Input <LocalizedDateInput
id="period-to"
type="date"
lang={dateInputLang}
value={to} value={to}
onChange={(e) => { setTo(e.target.value); setPreset('custom'); }} onChange={(iso) => { setTo(iso); setPreset('custom'); }}
/> />
</div> </div>
</div> </div>