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:
Luca
2026-06-06 02:22:46 +02:00
parent ea09a86d05
commit 43b10f91c0
3 changed files with 60 additions and 21 deletions
@@ -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}
{/* 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 || ''}
maxLength={64}
placeholder={Intl.DateTimeFormat().resolvedOptions().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}