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 totalMinor = parseInt(req.body.totalAmountMinor, 10);
|
||||||
const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0;
|
|
||||||
const status = req.body.status || 'sent';
|
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 issueDate = req.body.issueDate;
|
||||||
const dueDate = req.body.dueDate || issueDate;
|
const dueDate = req.body.dueDate || issueDate;
|
||||||
// Imported docs are historical: their real send/payment dates are
|
// 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 { Link } from 'react-router-dom';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Clock, AlertTriangle } from 'lucide-react';
|
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 { DecimalInput } from '../common/DecimalInput';
|
||||||
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
||||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||||
@@ -46,13 +46,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate();
|
const { format: fmtDate, formatTime: fmtTime } = 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 [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
|
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [startTime, setStartTime] = useState('09:00');
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
const [endTime, setEndTime] = useState('10: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">
|
<label className="block text-xs text-muted-theme mb-1">
|
||||||
{t('customers.hours.form.start', 'Start')}
|
{t('customers.hours.form.start', 'Start')}
|
||||||
</label>
|
</label>
|
||||||
<input type="time" lang={timeInputLang} value={startTime}
|
<TimeField value={startTime} onChange={setStartTime} />
|
||||||
onChange={(e) => setStartTime(e.target.value)} className="input w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted-theme mb-1">
|
<label className="block text-xs text-muted-theme mb-1">
|
||||||
{t('customers.hours.form.end', 'End')}
|
{t('customers.hours.form.end', 'End')}
|
||||||
</label>
|
</label>
|
||||||
<input type="time" lang={timeInputLang} value={endTime}
|
<TimeField value={endTime} onChange={setEndTime} />
|
||||||
onChange={(e) => setEndTime(e.target.value)} className="input w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted-theme mb-1">
|
<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 { Input } from './Input';
|
||||||
export { CountrySelect } from './CountrySelect';
|
export { CountrySelect } from './CountrySelect';
|
||||||
export { LocalizedDateInput } from './LocalizedDateInput';
|
export { LocalizedDateInput } from './LocalizedDateInput';
|
||||||
|
export { TimeField, parseTimeToHHMM } from './TimeField';
|
||||||
export { SortableHeader, useColumnSort } from './SortableHeader';
|
export { SortableHeader, useColumnSort } from './SortableHeader';
|
||||||
export type { SortDir, SortPair, SortColumnMap } from './SortableHeader';
|
export type { SortDir, SortPair, SortColumnMap } from './SortableHeader';
|
||||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||||
|
|||||||
@@ -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, LocalizedDateInput } from '../../components/common';
|
import { Button, Input, Card, PasswordGenerator, LocalizedDateInput, TimeField } 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';
|
||||||
@@ -613,19 +613,15 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
{!formData.is_full_day && (
|
{!formData.is_full_day && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<TimeField
|
||||||
type="time"
|
|
||||||
step={900}
|
|
||||||
label={t('events.eventTimeStart', 'Start time') as string}
|
label={t('events.eventTimeStart', 'Start time') as string}
|
||||||
value={formData.event_time_start}
|
value={formData.event_time_start}
|
||||||
onChange={handleInputChange('event_time_start')}
|
onChange={(v) => setFormData(prev => ({ ...prev, event_time_start: v }))}
|
||||||
/>
|
/>
|
||||||
<Input
|
<TimeField
|
||||||
type="time"
|
|
||||||
step={900}
|
|
||||||
label={t('events.eventTimeEnd', 'End time') as string}
|
label={t('events.eventTimeEnd', 'End time') as string}
|
||||||
value={formData.event_time_end}
|
value={formData.event_time_end}
|
||||||
onChange={handleInputChange('event_time_end')}
|
onChange={(v) => setFormData(prev => ({ ...prev, event_time_end: v }))}
|
||||||
/>
|
/>
|
||||||
</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, 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 { 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';
|
||||||
@@ -20,7 +20,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
|
|||||||
import { userManagementService } from '../../../services/userManagement.service';
|
import { userManagementService } from '../../../services/userManagement.service';
|
||||||
import { settingsService } from '../../../services/settings.service';
|
import { settingsService } from '../../../services/settings.service';
|
||||||
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
|
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
function toMinor(amount: number) {
|
function toMinor(amount: number) {
|
||||||
@@ -29,8 +28,6 @@ function toMinor(amount: number) {
|
|||||||
|
|
||||||
export const BillEditorPage: React.FC = () => {
|
export const BillEditorPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { timeFormat } = useLocalizedDate();
|
|
||||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -519,10 +516,10 @@ export const BillEditorPage: React.FC = () => {
|
|||||||
value={eventName} onChange={(e) => setEventName(e.target.value)} />
|
value={eventName} onChange={(e) => setEventName(e.target.value)} />
|
||||||
<LocalizedDateInput label={t('bills.field.eventDate', 'Event date') as string}
|
<LocalizedDateInput label={t('bills.field.eventDate', 'Event date') as string}
|
||||||
value={eventDate} onChange={setEventDate} />
|
value={eventDate} onChange={setEventDate} />
|
||||||
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string}
|
<TimeField label={t('bills.field.eventTimeStart', 'Start time') as string}
|
||||||
value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
|
value={eventTimeStart} onChange={setEventTimeStart} />
|
||||||
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string}
|
<TimeField label={t('bills.field.eventTimeEnd', 'End time') as string}
|
||||||
value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
|
value={eventTimeEnd} onChange={setEventTimeEnd} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Lock, Trash2 } from 'lucide-react';
|
import { Lock, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
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 { customerAdminService } from '../../../services/customerAdmin.service';
|
||||||
import type { CalendarHoursItem } from '../../../services/calendar.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">
|
<label className="block text-sm font-medium mb-1">
|
||||||
{t('calendar.hourEntry.startLabel', 'Start')}
|
{t('calendar.hourEntry.startLabel', 'Start')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<TimeField value={startTime} onChange={setStartTime} />
|
||||||
type="time"
|
|
||||||
step={900}
|
|
||||||
value={startTime}
|
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
{t('calendar.hourEntry.endLabel', 'End')}
|
{t('calendar.hourEntry.endLabel', 'End')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<TimeField value={endTime} onChange={setEndTime} />
|
||||||
type="time"
|
|
||||||
step={900}
|
|
||||||
value={endTime}
|
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import { useNavigate, useParams, Link } from 'react-router-dom';
|
|||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { ArrowLeft, Eye, Save } from 'lucide-react';
|
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 {
|
import {
|
||||||
contractsService,
|
contractsService,
|
||||||
type ContractBlockSection,
|
type ContractBlockSection,
|
||||||
CONTRACT_SECTIONS,
|
CONTRACT_SECTIONS,
|
||||||
} from '../../../services/contracts.service';
|
} from '../../../services/contracts.service';
|
||||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
|
||||||
|
|
||||||
interface BlockRow {
|
interface BlockRow {
|
||||||
blockId: number;
|
blockId: number;
|
||||||
@@ -38,8 +37,6 @@ interface BlockRow {
|
|||||||
|
|
||||||
export const ContractEditorPage: React.FC = () => {
|
export const ContractEditorPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { timeFormat } = useLocalizedDate();
|
|
||||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
@@ -426,13 +423,13 @@ export const ContractEditorPage: React.FC = () => {
|
|||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
{t('contracts.editor.eventTimeStart', 'Start')}
|
{t('contracts.editor.eventTimeStart', 'Start')}
|
||||||
</label>
|
</label>
|
||||||
<Input type="time" lang={timeInputLang} value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
|
<TimeField value={eventTimeStart} onChange={setEventTimeStart} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
{t('contracts.editor.eventTimeEnd', 'End')}
|
{t('contracts.editor.eventTimeEnd', 'End')}
|
||||||
</label>
|
</label>
|
||||||
<Input type="time" lang={timeInputLang} value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
|
<TimeField value={eventTimeEnd} onChange={setEventTimeEnd} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,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, Send } from 'lucide-react';
|
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 {
|
import {
|
||||||
quotesService,
|
quotesService,
|
||||||
type QuoteCreatePayload,
|
type QuoteCreatePayload,
|
||||||
@@ -29,7 +29,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
|
|||||||
import { userManagementService } from '../../../services/userManagement.service';
|
import { userManagementService } from '../../../services/userManagement.service';
|
||||||
import { settingsService } from '../../../services/settings.service';
|
import { settingsService } from '../../../services/settings.service';
|
||||||
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
|
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -140,8 +139,6 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
|||||||
|
|
||||||
export const QuoteEditorPage: React.FC = () => {
|
export const QuoteEditorPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { timeFormat } = useLocalizedDate();
|
|
||||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -492,10 +489,10 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
|
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
|
||||||
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
|
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
|
||||||
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
|
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
|
||||||
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
|
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} />
|
onChange={(v) => setForm((f) => ({ ...f, eventTimeStart: v }))} />
|
||||||
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
|
<TimeField label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, eventTimeEnd: e.target.value }))} />
|
onChange={(v) => setForm((f) => ({ ...f, eventTimeEnd: v }))} />
|
||||||
<Input type="number" step="0.5" label={t('quotes.field.expectedDuration', 'Expected duration (h)') as string}
|
<Input type="number" step="0.5" label={t('quotes.field.expectedDuration', 'Expected duration (h)') as string}
|
||||||
value={form.expectedDurationHours}
|
value={form.expectedDurationHours}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} />
|
onChange={(e) => setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} />
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ import {
|
|||||||
type BusinessHoursBlock,
|
type BusinessHoursBlock,
|
||||||
type QrFormat,
|
type QrFormat,
|
||||||
} from '../../../services/businessProfile.service';
|
} 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 { DecimalInput } from '../../../components/common/DecimalInput';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
export const SettingsBusinessProfilePage: React.FC = () => {
|
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];
|
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<{
|
const BusinessHoursEditor: React.FC<{
|
||||||
value: BusinessHours | null;
|
value: BusinessHours | null;
|
||||||
onChange: (next: BusinessHours) => void;
|
onChange: (next: BusinessHours) => void;
|
||||||
@@ -508,12 +429,14 @@ const BusinessHoursEditor: React.FC<{
|
|||||||
value={block.start}
|
value={block.start}
|
||||||
onChange={(v) => updateBlock(iso, idx, { start: v })}
|
onChange={(v) => updateBlock(iso, idx, { start: v })}
|
||||||
ariaLabel={t('businessProfile.businessHours.startTime', 'Opening time') as string}
|
ariaLabel={t('businessProfile.businessHours.startTime', 'Opening time') as string}
|
||||||
|
className="w-32 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="text-neutral-400">–</span>
|
<span className="text-neutral-400">–</span>
|
||||||
<TimeField
|
<TimeField
|
||||||
value={block.end}
|
value={block.end}
|
||||||
onChange={(v) => updateBlock(iso, idx, { end: v })}
|
onChange={(v) => updateBlock(iso, idx, { end: v })}
|
||||||
ariaLabel={t('businessProfile.businessHours.endTime', 'Closing time') as string}
|
ariaLabel={t('businessProfile.businessHours.endTime', 'Closing time') as string}
|
||||||
|
className="w-32 shrink-0"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user