From 626ab45e0bfe09d1296bf0a0a4e6889ea30acfae Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:24:27 +0200 Subject: [PATCH] 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 to it: business hours, HoursSection, HourEntryInlinePopover, CreateEventPage, Quote/Bill/Contract editors. Removes the unreliable lang-hint plumbing. --- .../core/116_backfill_imported_paid_amount.js | 37 ++++++ backend/src/routes/adminInvoices.js | 9 +- .../src/components/admin/HoursSection.tsx | 16 +-- frontend/src/components/common/TimeField.tsx | 106 ++++++++++++++++++ frontend/src/components/common/index.ts | 1 + frontend/src/pages/admin/CreateEventPage.tsx | 14 +-- .../src/pages/admin/bills/BillEditorPage.tsx | 13 +-- .../admin/clients/HourEntryInlinePopover.tsx | 16 +-- .../admin/contracts/ContractEditorPage.tsx | 9 +- .../pages/admin/quotes/QuoteEditorPage.tsx | 13 +-- .../settings/SettingsBusinessProfilePage.tsx | 83 +------------- 11 files changed, 180 insertions(+), 137 deletions(-) create mode 100644 backend/migrations/core/116_backfill_imported_paid_amount.js create mode 100644 frontend/src/components/common/TimeField.tsx diff --git a/backend/migrations/core/116_backfill_imported_paid_amount.js b/backend/migrations/core/116_backfill_imported_paid_amount.js new file mode 100644 index 00000000..07677019 --- /dev/null +++ b/backend/migrations/core/116_backfill_imported_paid_amount.js @@ -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. +}; diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index 1a656da9..326501bf 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -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 diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index f9b86673..da02c43f 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -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 = ({ }) => { const { t } = useTranslation(); const qc = useQueryClient(); - const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate(); - // `lang` hint on 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 = ({ - setStartTime(e.target.value)} className="input w-full" /> +
- setEndTime(e.target.value)} className="input w-full" /> +
{!formData.is_full_day && (
- setFormData(prev => ({ ...prev, event_time_start: v }))} /> - setFormData(prev => ({ ...prev, event_time_end: v }))} />
)} diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx index 8426117d..3f2d3c1e 100644 --- a/frontend/src/pages/admin/bills/BillEditorPage.tsx +++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx @@ -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)} /> - setEventTimeStart(e.target.value)} /> - setEventTimeEnd(e.target.value)} /> + +
diff --git a/frontend/src/pages/admin/clients/HourEntryInlinePopover.tsx b/frontend/src/pages/admin/clients/HourEntryInlinePopover.tsx index 539dd974..f4290919 100644 --- a/frontend/src/pages/admin/clients/HourEntryInlinePopover.tsx +++ b/frontend/src/pages/admin/clients/HourEntryInlinePopover.tsx @@ -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 = ({ - setStartTime(e.target.value)} - /> +
- setEndTime(e.target.value)} - /> +
diff --git a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx index 05aa8114..c06ed00a 100644 --- a/frontend/src/pages/admin/contracts/ContractEditorPage.tsx +++ b/frontend/src/pages/admin/contracts/ContractEditorPage.tsx @@ -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 = () => { - setEventTimeStart(e.target.value)} /> +
- setEventTimeEnd(e.target.value)} /> +
diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx index 22a68839..9f6c380c 100644 --- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx @@ -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 }))} /> setForm((f) => ({ ...f, eventDate: iso }))} /> - setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} /> - setForm((f) => ({ ...f, eventTimeEnd: e.target.value }))} /> + setForm((f) => ({ ...f, eventTimeStart: v }))} /> + setForm((f) => ({ ...f, eventTimeEnd: v }))} /> setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} /> diff --git a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx index 2e38aef3..cff6045f 100644 --- a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx +++ b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx @@ -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 = ({ 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 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 ( - 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" /> updateBlock(iso, idx, { end: v })} ariaLabel={t('businessProfile.businessHours.endTime', 'Closing time') as string} + className="w-32 shrink-0" />