fix(crm): localize scheduled-send + installment date + timezone picker
From dev testing: - BillEditor 'Geplanter Versand' was a native <input type=datetime-local> → rendered US date + 12h regardless of settings. Split into LocalizedDateInput + TimeField (honour general_date_format + general_time_format), recombined into the YYYY-MM-DDTHH:MM the payload/scheduler expect. - InstallmentsPanel 'Send on' native <input type=date> (browser-locale via a lang hint, wrong in Safari/Firefox) → LocalizedDateInput, consistent in every browser. (Luca approved converting it.) - Business-profile Timezone was a free-text input → dropdown of the full IANA list (Intl.supportedValuesOf, CH/LI fallback), blank = system default.
This commit is contained in:
@@ -26,10 +26,9 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { Button, Input } from '../common';
|
||||
import { Button, Input, LocalizedDateInput } from '../common';
|
||||
import type { PaymentTermInstallment } from '../../services/quotes.service';
|
||||
import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export type InstallmentPlan = PaymentTermInstallment[];
|
||||
|
||||
@@ -69,7 +68,6 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
value, onChange, onValidityChange, eventDate, disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { dateInputLang } = useLocalizedDate();
|
||||
const defaults = useInstallmentDefaults();
|
||||
const [advanced, setAdvanced] = React.useState(false);
|
||||
|
||||
@@ -230,12 +228,9 @@ export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
|
||||
'On delivery — admin releases manually. Switch to advanced to change.')}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
lang={dateInputLang}
|
||||
<LocalizedDateInput
|
||||
value={previewDate(row) || ''}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
onChange={(next) => {
|
||||
if (!next) return;
|
||||
const offset = daysBetween(todayIso(), next);
|
||||
update(idx, { trigger: 'fixed_date', offset_days: offset });
|
||||
|
||||
@@ -566,8 +566,31 @@ export const BillEditorPage: React.FC = () => {
|
||||
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
|
||||
</label>
|
||||
</div>
|
||||
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
|
||||
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('bills.field.scheduledSendAt', 'Scheduled send (optional)')}</label>
|
||||
{/* Localized date + time (honours general_date_format +
|
||||
general_time_format) instead of a native datetime-local, which
|
||||
renders in the browser locale (US date + 12h). Recombined into
|
||||
the "YYYY-MM-DDTHH:MM" the payload + scheduler expect. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<LocalizedDateInput
|
||||
value={scheduledSendAt ? scheduledSendAt.slice(0, 10) : ''}
|
||||
onChange={(iso) => {
|
||||
if (!iso) { setScheduledSendAt(''); return; }
|
||||
const time = scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : '09:00';
|
||||
setScheduledSendAt(`${iso}T${time}`);
|
||||
}}
|
||||
/>
|
||||
<TimeField
|
||||
value={scheduledSendAt.length >= 16 ? scheduledSendAt.slice(11, 16) : ''}
|
||||
onChange={(hhmm) => {
|
||||
const date = scheduledSendAt ? scheduledSendAt.slice(0, 10) : '';
|
||||
if (!date) return;
|
||||
setScheduledSendAt(`${date}T${hhmm || '09:00'}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('bills.field.qrFormat', 'Payment QR format')}</label>
|
||||
<select
|
||||
|
||||
@@ -21,6 +21,18 @@ import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../..
|
||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022
|
||||
// (all current browsers); fall back to a small CH/LI-relevant set on the rare
|
||||
// engine that lacks it.
|
||||
const IANA_TIMEZONES: string[] = (() => {
|
||||
try {
|
||||
// @ts-expect-error supportedValuesOf is ES2022, not yet in all TS lib defs
|
||||
return Intl.supportedValuesOf('timeZone') as string[];
|
||||
} catch {
|
||||
return ['UTC', 'Europe/Vaduz', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Paris', 'Europe/London'];
|
||||
}
|
||||
})();
|
||||
|
||||
export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -117,17 +129,26 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
|
||||
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
|
||||
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
|
||||
{/* Migration 137 — IANA timezone string for the admin calendar.
|
||||
Free-text; backend caps at 64 chars. When blank the calendar
|
||||
UI falls back to the browser's `Intl.DateTimeFormat()
|
||||
.resolvedOptions().timeZone`. */}
|
||||
<Input
|
||||
label={t('businessProfile.field.timezone', 'Timezone (IANA)') as string}
|
||||
value={profile.timezone || ''}
|
||||
maxLength={64}
|
||||
placeholder={Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
|
||||
/>
|
||||
{/* Migration 137 — IANA timezone for the admin calendar + the
|
||||
scheduled-email business-hours snapping. Dropdown of the full
|
||||
IANA list; blank = fall back to the server/browser tz. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('businessProfile.field.timezone', 'Timezone (IANA)')}
|
||||
</label>
|
||||
<select
|
||||
value={profile.timezone || ''}
|
||||
onChange={(e) => setProfile({ ...profile, timezone: e.target.value || null })}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">
|
||||
{t('businessProfile.field.timezoneSystemDefault', 'System default')} ({Intl.DateTimeFormat().resolvedOptions().timeZone})
|
||||
</option>
|
||||
{IANA_TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
|
||||
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
|
||||
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
|
||||
|
||||
Reference in New Issue
Block a user