refactor(time): app-wide setting-aware TimeField for all time inputs

Add shared components/common/TimeField (displays per general_time_format,
stores canonical HH:MM, parses tolerant free-text, browser-independent)
and migrate every native <input type="time"> to it: business hours,
HoursSection, HourEntryInlinePopover, CreateEventPage, Quote/Bill/Contract
editors. Removes the unreliable lang-hint plumbing.
This commit is contained in:
Luca
2026-06-02 17:24:27 +02:00
parent 2149f38cd1
commit 626ab45e0b
11 changed files with 180 additions and 137 deletions
@@ -0,0 +1,37 @@
/**
* Migration: backfill paid_amount_minor for imported PAID invoices that
* stored 0.
*
* Background: the historical-invoice import only sent paidAmountMinor when
* the admin separately filled the "paid amount" field. Left blank (easy to
* miss — the total was already entered), it stored paid_amount_minor = 0
* even with status='paid'. The dashboard revenue windows sum
* paid_amount_minor (not total), so those paid imports contributed NOTHING
* to revenue. The import route now defaults a blank paid amount to the
* total; this fixes the rows already created before that change.
*
* Scope: imported (imported_pdf_path set) + status='paid' + paid_amount_minor
* 0/null → set paid_amount_minor = total_amount_minor (fully paid). Operational
* payment field, not immutable legal content (same reasoning as migration 111).
*
* Idempotent: re-running sets the same value; rows already > 0 are untouched.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (!(await knex.schema.hasColumn('invoices', 'imported_pdf_path'))) return;
if (!(await knex.schema.hasColumn('invoices', 'paid_amount_minor'))) return;
await knex('invoices')
.whereNotNull('imported_pdf_path')
.where('status', 'paid')
.andWhere(function() {
this.where('paid_amount_minor', 0).orWhereNull('paid_amount_minor');
})
.update({ paid_amount_minor: knex.raw('total_amount_minor') });
};
exports.down = async function() {
// Irreversible data backfill — we can't tell which rows we changed apart
// from legitimately-full payments. No-op.
};
+8 -1
View File
@@ -471,8 +471,15 @@ router.post(
}
const totalMinor = parseInt(req.body.totalAmountMinor, 10);
const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0;
const status = req.body.status || 'sent';
// A paid import with no explicit paid amount means FULLY paid — default
// paid_amount_minor to the total. The dashboard revenue windows sum
// paid_amount_minor (not total), so a blank paid amount used to store 0
// and the paid invoice contributed nothing to revenue.
const explicitPaid = req.body.paidAmountMinor != null && String(req.body.paidAmountMinor) !== '';
const paidMinor = explicitPaid
? (parseInt(req.body.paidAmountMinor, 10) || 0)
: (status === 'paid' ? totalMinor : 0);
const issueDate = req.body.issueDate;
const dueDate = req.body.dueDate || issueDate;
// Imported docs are historical: their real send/payment dates are
+4 -12
View File
@@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Clock, AlertTriangle } from 'lucide-react';
import { Button, Card, LocalizedDateInput } from '../common';
import { Button, Card, LocalizedDateInput, TimeField } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
@@ -46,13 +46,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
}) => {
const { t } = useTranslation();
const qc = useQueryClient();
const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate();
// `lang` hint on <input type="time"> nudges Chrome/Edge to render the
// picker in the matching clock convention (de-DE → 24h, en-US → 12h).
// Safari/Firefox follow OS locale and ignore this — that's a browser
// limitation, not something we can fix in the page. The underlying
// value stays HH:mm (24h) regardless of how the picker presents it.
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
@@ -296,15 +290,13 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.start', 'Start')}
</label>
<input type="time" lang={timeInputLang} value={startTime}
onChange={(e) => setStartTime(e.target.value)} className="input w-full" />
<TimeField value={startTime} onChange={setStartTime} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.end', 'End')}
</label>
<input type="time" lang={timeInputLang} value={endTime}
onChange={(e) => setEndTime(e.target.value)} className="input w-full" />
<TimeField value={endTime} onChange={setEndTime} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import { clsx } from 'clsx';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
/**
* Parse a free-typed time into canonical 24h "HH:MM", or null if
* unparseable. Tolerant of: "13:00", "1300", "9:5", "9", "1:00 PM",
* "1pm", "12 am". Lets the field accept input in whichever format it is
* displaying (24h or 12h) and normalise it back to storage form.
*/
export const parseTimeToHHMM = (raw: string): string | null => {
const s = raw.trim().toLowerCase();
if (!s) return null;
let ampm: 'am' | 'pm' | null = null;
let core = s;
const am = s.match(/(a|p)\.?m?\.?\s*$/);
if (am) {
ampm = am[1] === 'p' ? 'pm' : 'am';
core = s.slice(0, am.index).trim();
}
let h: number;
let mi: number;
const colon = core.match(/^(\d{1,2})\s*[:.]\s*(\d{1,2})$/);
if (colon) {
h = parseInt(colon[1], 10);
mi = parseInt(colon[2], 10);
} else {
const digits = core.replace(/\D/g, '');
if (!digits) return null;
if (digits.length <= 2) { h = parseInt(digits, 10); mi = 0; }
else if (digits.length === 3) { h = parseInt(digits.slice(0, 1), 10); mi = parseInt(digits.slice(1), 10); }
else { h = parseInt(digits.slice(0, 2), 10); mi = parseInt(digits.slice(2, 4), 10); }
}
if (Number.isNaN(h) || Number.isNaN(mi)) return null;
if (ampm === 'pm' && h < 12) h += 12;
if (ampm === 'am' && h === 12) h = 0;
h = Math.min(23, Math.max(0, h));
mi = Math.min(59, Math.max(0, mi));
return `${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
};
interface TimeFieldProps {
/** Canonical 24h "HH:MM" (or '' for empty). */
value: string;
/** Emits canonical 24h "HH:MM". */
onChange: (v: string) => void;
/** Optional label rendered above the field (matches the `Input` component). */
label?: string;
ariaLabel?: string;
/** Tailwind width/extra classes for the input; defaults to w-full. */
className?: string;
disabled?: boolean;
}
/**
* Time field that DISPLAYS in the admin's `general_time_format` (24h →
* "13:00", 12h → "01:00 PM") but always stores/emits canonical 24h
* "HH:MM". A plain text input, so the rendered format is identical in
* EVERY browser — native <input type="time"> ignores our setting (its
* 12h/24h chrome is browser-locale-controlled and Safari ignores the
* `lang` hint). Free text while typing; parsed + reformatted on blur,
* reverting to the last good value if unparseable.
*/
export const TimeField: React.FC<TimeFieldProps> = ({
value, onChange, label, ariaLabel, className, disabled,
}) => {
const { formatTime: fmtTime, timeFormat } = useLocalizedDate();
const display = (v: string) => (/^\d{1,2}:\d{2}/.test(v) ? fmtTime(v) : (v || ''));
const [text, setText] = useState(() => display(value));
// Re-sync when the external value or the format setting changes.
useEffect(() => { setText(display(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value, timeFormat]);
const commit = () => {
const parsed = parseTimeToHHMM(text);
if (parsed) {
setText(display(parsed));
if (parsed !== value) onChange(parsed);
} else {
setText(display(value));
}
};
const input = (
<input
type="text"
inputMode={timeFormat === '12h' ? 'text' : 'numeric'}
aria-label={ariaLabel || label}
placeholder={timeFormat === '12h' ? '1:00 PM' : 'HH:MM'}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
className={clsx('input', className || 'w-full')}
/>
);
if (!label) return input;
return (
<div className="w-full">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5">
{label}
</label>
{input}
</div>
);
};
+1
View File
@@ -3,6 +3,7 @@ export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input';
export { CountrySelect } from './CountrySelect';
export { LocalizedDateInput } from './LocalizedDateInput';
export { TimeField, parseTimeToHHMM } from './TimeField';
export { SortableHeader, useColumnSort } from './SortableHeader';
export type { SortDir, SortPair, SortColumnMap } from './SortableHeader';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
+5 -9
View File
@@ -15,7 +15,7 @@ import {
import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, PasswordGenerator, LocalizedDateInput } from '../../components/common';
import { Button, Input, Card, PasswordGenerator, LocalizedDateInput, TimeField } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -613,19 +613,15 @@ export const CreateEventPage: React.FC = () => {
</label>
{!formData.is_full_day && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="time"
step={900}
<TimeField
label={t('events.eventTimeStart', 'Start time') as string}
value={formData.event_time_start}
onChange={handleInputChange('event_time_start')}
onChange={(v) => setFormData(prev => ({ ...prev, event_time_start: v }))}
/>
<Input
type="time"
step={900}
<TimeField
label={t('events.eventTimeEnd', 'End time') as string}
value={formData.event_time_end}
onChange={handleInputChange('event_time_end')}
onChange={(v) => setFormData(prev => ({ ...prev, event_time_end: v }))}
/>
</div>
)}
@@ -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, LocalizedDateInput } from '../../../components/common';
import { Button, Card, Loading, Input, LocalizedDateInput, TimeField } 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';
@@ -20,7 +20,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
import { settingsService } from '../../../services/settings.service';
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
function toMinor(amount: number) {
@@ -29,8 +28,6 @@ function toMinor(amount: number) {
export const BillEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -519,10 +516,10 @@ export const BillEditorPage: React.FC = () => {
value={eventName} onChange={(e) => setEventName(e.target.value)} />
<LocalizedDateInput label={t('bills.field.eventDate', 'Event date') as string}
value={eventDate} onChange={setEventDate} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string}
value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string}
value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
<TimeField label={t('bills.field.eventTimeStart', 'Start time') as string}
value={eventTimeStart} onChange={setEventTimeStart} />
<TimeField label={t('bills.field.eventTimeEnd', 'End time') as string}
value={eventTimeEnd} onChange={setEventTimeEnd} />
</div>
</Card>
@@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Lock, Trash2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input } from '../../../components/common';
import { Button, Card, Input, TimeField } from '../../../components/common';
import { customerAdminService } from '../../../services/customerAdmin.service';
import type { CalendarHoursItem } from '../../../services/calendar.service';
@@ -176,23 +176,13 @@ export const HourEntryInlinePopover: React.FC<HourEntryInlinePopoverProps> = ({
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.startLabel', 'Start')}
</label>
<Input
type="time"
step={900}
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
<TimeField value={startTime} onChange={setStartTime} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.endLabel', 'End')}
</label>
<Input
type="time"
step={900}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
<TimeField value={endTime} onChange={setEndTime} />
</div>
</div>
<div>
@@ -17,14 +17,13 @@ import { useNavigate, useParams, Link } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Eye, Save } from 'lucide-react';
import { Button, Card, Input, Loading, LocalizedDateInput } from '../../../components/common';
import { Button, Card, Input, Loading, LocalizedDateInput, TimeField } from '../../../components/common';
import {
contractsService,
type ContractBlockSection,
CONTRACT_SECTIONS,
} from '../../../services/contracts.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
interface BlockRow {
blockId: number;
@@ -38,8 +37,6 @@ interface BlockRow {
export const ContractEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const isEdit = Boolean(id);
@@ -426,13 +423,13 @@ export const ContractEditorPage: React.FC = () => {
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventTimeStart', 'Start')}
</label>
<Input type="time" lang={timeInputLang} value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
<TimeField value={eventTimeStart} onChange={setEventTimeStart} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventTimeEnd', 'End')}
</label>
<Input type="time" lang={timeInputLang} value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
<TimeField value={eventTimeEnd} onChange={setEventTimeEnd} />
</div>
</div>
</div>
@@ -16,7 +16,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send } from 'lucide-react';
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
import { Button, Card, Loading, Input, LocalizedDateInput, TimeField } from '../../../components/common';
import {
quotesService,
type QuoteCreatePayload,
@@ -29,7 +29,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
import { settingsService } from '../../../services/settings.service';
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
interface FormState {
@@ -140,8 +139,6 @@ function buildPayload(f: FormState): QuoteCreatePayload {
export const QuoteEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -492,10 +489,10 @@ export const QuoteEditorPage: React.FC = () => {
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
onChange={(e) => setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} />
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
onChange={(e) => setForm((f) => ({ ...f, eventTimeEnd: e.target.value }))} />
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
onChange={(v) => setForm((f) => ({ ...f, eventTimeStart: v }))} />
<TimeField label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
onChange={(v) => setForm((f) => ({ ...f, eventTimeEnd: v }))} />
<Input type="number" step="0.5" label={t('quotes.field.expectedDuration', 'Expected duration (h)') as string}
value={form.expectedDurationHours}
onChange={(e) => setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} />
@@ -17,9 +17,8 @@ import {
type BusinessHoursBlock,
type QrFormat,
} from '../../../services/businessProfile.service';
import { Button, Card, Loading, Input, CountrySelect } from '../../../components/common';
import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
export const SettingsBusinessProfilePage: React.FC = () => {
@@ -350,84 +349,6 @@ const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled
*/
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
/**
* Parse a free-typed time into the canonical 24h "HH:MM", or null if
* unparseable. Tolerant of: "13:00", "1300", "9:5", "9", "1:00 PM",
* "1pm", "12 am". Used to accept input in whichever format the field is
* displaying (24h or 12h) and normalise it back to storage form.
*/
const parseTimeToHHMM = (raw: string): string | null => {
const s = raw.trim().toLowerCase();
if (!s) return null;
let ampm: 'am' | 'pm' | null = null;
let core = s;
const am = s.match(/(a|p)\.?m?\.?\s*$/);
if (am) {
ampm = am[1] === 'p' ? 'pm' : 'am';
core = s.slice(0, am.index).trim();
}
let h: number;
let mi: number;
const colon = core.match(/^(\d{1,2})\s*[:.]\s*(\d{1,2})$/);
if (colon) {
h = parseInt(colon[1], 10);
mi = parseInt(colon[2], 10);
} else {
const digits = core.replace(/\D/g, '');
if (!digits) return null;
if (digits.length <= 2) { h = parseInt(digits, 10); mi = 0; }
else if (digits.length === 3) { h = parseInt(digits.slice(0, 1), 10); mi = parseInt(digits.slice(1), 10); }
else { h = parseInt(digits.slice(0, 2), 10); mi = parseInt(digits.slice(2, 4), 10); }
}
if (Number.isNaN(h) || Number.isNaN(mi)) return null;
if (ampm === 'pm' && h < 12) h += 12;
if (ampm === 'am' && h === 12) h = 0;
h = Math.min(23, Math.max(0, h));
mi = Math.min(59, Math.max(0, mi));
return `${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
};
/**
* Time field that DISPLAYS in the admin's `general_time_format` (24h
* "13:00", 12h "01:00 PM") but always stores/emits canonical 24h
* "HH:MM". A plain text input, so the rendered format is identical in
* every browser (native <input type="time"> ignores our setting its
* 12h/24h chrome is browser-locale-controlled and Safari ignores the
* `lang` hint entirely). Free text while typing; parsed + reformatted on
* blur, reverting to the last good value if unparseable.
*/
const TimeField: React.FC<{
value: string;
onChange: (v: string) => void;
ariaLabel?: string;
}> = ({ value, onChange, ariaLabel }) => {
const { formatTime: fmtTime, timeFormat } = useLocalizedDate();
const display = (v: string) => (/^\d{1,2}:\d{2}/.test(v) ? fmtTime(v) : (v || ''));
const [text, setText] = useState(() => display(value));
useEffect(() => { setText(display(value)); /* eslint-disable-next-line */ }, [value, timeFormat]);
const commit = () => {
const parsed = parseTimeToHHMM(text);
if (parsed) {
setText(display(parsed));
if (parsed !== value) onChange(parsed);
} else {
setText(display(value));
}
};
return (
<input
type="text"
inputMode={timeFormat === '12h' ? 'text' : 'numeric'}
aria-label={ariaLabel}
placeholder={timeFormat === '12h' ? '1:00 PM' : 'HH:MM'}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
className="input w-32 shrink-0 tabular-nums"
/>
);
};
const BusinessHoursEditor: React.FC<{
value: BusinessHours | null;
onChange: (next: BusinessHours) => void;
@@ -508,12 +429,14 @@ const BusinessHoursEditor: React.FC<{
value={block.start}
onChange={(v) => updateBlock(iso, idx, { start: v })}
ariaLabel={t('businessProfile.businessHours.startTime', 'Opening time') as string}
className="w-32 shrink-0"
/>
<span className="text-neutral-400"></span>
<TimeField
value={block.end}
onChange={(v) => updateBlock(iso, idx, { end: v })}
ariaLabel={t('businessProfile.businessHours.endTime', 'Closing time') as string}
className="w-32 shrink-0"
/>
<button
type="button"