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:
@@ -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,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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user