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:
@@ -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.
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user