feat(billing): manual cadence + fix admin date inputs ignoring date-format setting

Manual billing cadence
  Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
  the monthly draft accumulator — invoices and billed hours pile onto one running
  draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
  never matches. The draft ships only when an admin clicks "Trigger invoice now".
  No migration: billing_cadence is a free-form string column gated by validators.
  - adminCustomers.js: allow 'manual' in billing_cadence validator
  - invoiceService.js: route manual through accumulator; NULL periods + placeholder
    issue/due date in getOrCreateMonthlyDraft
  - customerHoursService.js: manual auto-appends hours to running draft;
    billUnbilledEntries refuses manual (CADENCE_MISMATCH)
  - CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
    NULL-period-safe draft preview + trigger button, manual-specific copy
  - customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
  - en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
    draftPreview.titleManual
Date-format fixes
  Replace raw <input type="date"> (browser-locale) with LocalizedDateInput so
  these admin surfaces honor the general_date_format setting:
  - ContractEditorPage.tsx (issue / valid-until / event dates)
  - QuoteEditorPage.tsx (event / valid-until dates)
  - EventDetailsPage.tsx (expiry date)
  - HoursSection.tsx (entry date)
This commit is contained in:
Luca
2026-06-02 09:27:10 +02:00
parent d788a6cde5
commit 06b4522dec
11 changed files with 104 additions and 65 deletions
@@ -17,7 +17,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Clock } from 'lucide-react';
import { Button, Card } from '../common';
import { Button, Card, LocalizedDateInput } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
@@ -224,8 +224,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.date', 'Date')}
</label>
<input type="date" value={entryDate}
onChange={(e) => setEntryDate(e.target.value)} className="input w-full" />
<LocalizedDateInput value={entryDate} onChange={setEntryDate} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
+5 -1
View File
@@ -3127,22 +3127,26 @@
},
"billing": {
"section": "Abrechnungsrhythmus",
"hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird.",
"hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird. Manuell: Positionen sammeln sich genauso, aber die Rechnung wird erst versendet, wenn Sie sie auslösen.",
"cadence": "Abrechnungsrhythmus",
"perEvent": "Per Event",
"monthly": "Monatlich",
"quarterly": "Quartalsweise",
"manual": "Manuell (nur auf Auslösung)",
"cycleDay": "Stichtag",
"cycleDayHint": "1..28 = Tag im Monat. Negativ -1..-15 für „N Tage vor Monatsende“ (so löst -3 in einem 31-Tage-Monat am 28. aus).",
"skontoDisabled": "Kein Skonto für diesen Kunden",
"skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden unabhängig von Vorlage oder globalen Standardwerten.",
"triggerNow": "Rechnung jetzt ausstellen",
"triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
"triggerConfirmManual": "Gesammelte Rechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
"triggerHint": "Überspringt den Stichtag und stellt den aktuellen Entwurf sofort aus. Wird abgelehnt, wenn für die aktuelle Periode nichts erfasst wurde.",
"triggerHintManual": "Stellt den aktuellen Entwurf sofort aus. Entwürfe mit manuellem Rhythmus werden nie automatisch versendet dies ist der einzige Weg, sie zu versenden. Wird abgelehnt, wenn nichts erfasst wurde.",
"triggered": "Monatsrechnung ausgestellt: {{number}}",
"triggerError": "Monatsrechnung konnte nicht ausgelöst werden.",
"draftPreview": {
"title": "Offen für die Rechnung dieses Monats",
"titleManual": "Offen wird auf manuelle Auslösung versendet",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
+5 -1
View File
@@ -3127,22 +3127,26 @@
},
"billing": {
"section": "Billing cadence",
"hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day.",
"hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day. Manual: items accumulate the same way, but the bill ships only when you trigger it.",
"cadence": "Billing cadence",
"perEvent": "Per event",
"monthly": "Monthly",
"quarterly": "Quarterly",
"manual": "Manual (trigger only)",
"cycleDay": "Cycle day",
"cycleDayHint": "1..28 = day of month. Use negative -1..-15 for \"N days before month end\" (so -3 fires on the 28th of a 31-day month).",
"skontoDisabled": "No Skonto for this customer",
"skontoDisabledHint": "Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.",
"triggerNow": "Trigger invoice now",
"triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.",
"triggerConfirmManual": "Issue this customer's accumulated bill now? The customer receives the email immediately.",
"triggerHint": "Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.",
"triggerHintManual": "Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.",
"triggered": "Monthly bill issued: {{number}}",
"triggerError": "Could not trigger the monthly bill.",
"draftPreview": {
"title": "Pending in this month's bill",
"titleManual": "Pending — ships on manual trigger",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
+36 -22
View File
@@ -77,7 +77,7 @@ export const CustomerDetailPage: React.FC = () => {
queryKey: ['admin-customer-monthly-draft', customerId],
queryFn: () => customerAdminService.getMonthlyDraft(customerId),
enabled: Number.isFinite(customerId) && customerId > 0
&& (customer?.billingCadence === 'monthly'),
&& (customer?.billingCadence === 'monthly' || customer?.billingCadence === 'manual'),
});
const monthlyDraft = monthlyDraftRes?.draft || null;
@@ -716,9 +716,10 @@ export const CustomerDetailPage: React.FC = () => {
<option value="per_event">{t('customers.billing.perEvent', 'Per event')}</option>
<option value="monthly">{t('customers.billing.monthly', 'Monthly')}</option>
<option value="quarterly">{t('customers.billing.quarterly', 'Quarterly')}</option>
<option value="manual">{t('customers.billing.manual', 'Manual (trigger only)')}</option>
</select>
</div>
{form.billingCadence && form.billingCadence !== 'per_event' && (
{(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.billing.cycleDay', 'Cycle day')}
@@ -763,21 +764,26 @@ export const CustomerDetailPage: React.FC = () => {
period so admin sees exactly what "Trigger invoice now"
would ship. Hidden when no draft exists yet (admin hasn't
saved anything onto the period). */}
{form.billingCadence === 'monthly' && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-theme">
{t('customers.billing.draftPreview.title',
'Pending in this month\'s bill')}
{form.billingCadence === 'manual'
? t('customers.billing.draftPreview.titleManual',
'Pending — ships on manual trigger')
: t('customers.billing.draftPreview.title',
'Pending in this month\'s bill')}
</h3>
<span className="text-xs text-muted-theme">
{t('customers.billing.draftPreview.periodRange',
'{{number}} · {{from}} {{to}}',
{
number: monthlyDraft.invoiceNumber,
from: fmtDate(monthlyDraft.periodStart),
to: fmtDate(monthlyDraft.periodEnd),
})}
{monthlyDraft.periodStart && monthlyDraft.periodEnd
? t('customers.billing.draftPreview.periodRange',
'{{number}} · {{from}} {{to}}',
{
number: monthlyDraft.invoiceNumber,
from: fmtDate(monthlyDraft.periodStart),
to: fmtDate(monthlyDraft.periodEnd),
})
: monthlyDraft.invoiceNumber}
</span>
</div>
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
@@ -834,20 +840,25 @@ export const CustomerDetailPage: React.FC = () => {
</div>
)}
{/* Manual trigger — issue the running monthly draft NOW
instead of waiting for the cadence-day scheduler tick.
Only shown for monthly-mode customers (per-event has no
draft to arm; the equivalent action there is "Bill these
hours" on the standalone Hours-logging page). */}
{form.billingCadence === 'monthly' && (
{/* Manual trigger — issue the running draft NOW. For monthly
customers this bypasses the cadence-day scheduler tick; for
manual-cadence customers it's the ONLY way the draft ships
(the scheduler never auto-flushes a manual draft). Per-event
has no draft to arm; the equivalent action there is "Bill
these hours" on the standalone Hours-logging page. */}
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
variant="outline"
disabled={triggerMonthlyBillMutation.isPending}
isLoading={triggerMonthlyBillMutation.isPending}
onClick={() => {
if (window.confirm(t('customers.billing.triggerConfirm',
'Issue this customer\'s monthly bill now? The customer receives the email immediately.') as string)) {
const confirmMsg = form.billingCadence === 'manual'
? t('customers.billing.triggerConfirmManual',
'Issue this customer\'s accumulated bill now? The customer receives the email immediately.')
: t('customers.billing.triggerConfirm',
'Issue this customer\'s monthly bill now? The customer receives the email immediately.');
if (window.confirm(confirmMsg as string)) {
triggerMonthlyBillMutation.mutate();
}
}}
@@ -855,8 +866,11 @@ export const CustomerDetailPage: React.FC = () => {
{t('customers.billing.triggerNow', 'Trigger invoice now')}
</Button>
<p className="text-xs text-muted-theme mt-2">
{t('customers.billing.triggerHint',
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
{form.billingCadence === 'manual'
? t('customers.billing.triggerHintManual',
'Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.')
: t('customers.billing.triggerHint',
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
</p>
</div>
)}
@@ -57,7 +57,7 @@ const safeParseDate = (dateValue: unknown): Date | null => {
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
@@ -1194,10 +1194,9 @@ export const EventDetailsPage: React.FC = () => {
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.expirationDate')}
</label>
<Input
type="date"
<LocalizedDateInput
value={editForm.expires_at}
onChange={(e) => setEditForm(prev => ({ ...prev, expires_at: e.target.value }))}
onChange={(iso) => setEditForm(prev => ({ ...prev, expires_at: iso }))}
min={format(new Date(), 'yyyy-MM-dd')}
/>
</div>
@@ -17,7 +17,7 @@ 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 } from '../../../components/common';
import { Button, Card, Input, Loading, LocalizedDateInput } from '../../../components/common';
import {
contractsService,
type ContractBlockSection,
@@ -380,13 +380,13 @@ export const ContractEditorPage: React.FC = () => {
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.issueDate', 'Issue date')}
</label>
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} />
<LocalizedDateInput value={issueDate} onChange={setIssueDate} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.validUntil', 'Sign by (optional)')}
</label>
<Input type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
<LocalizedDateInput value={validUntil} onChange={setValidUntil} />
</div>
</div>
@@ -419,7 +419,7 @@ export const ContractEditorPage: React.FC = () => {
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventDate', 'Event date')}
</label>
<Input type="date" value={eventDate} onChange={(e) => setEventDate(e.target.value)} />
<LocalizedDateInput value={eventDate} onChange={setEventDate} />
</div>
<div className="grid grid-cols-2 gap-2">
<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 } from '../../../components/common';
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
import {
quotesService,
type QuoteCreatePayload,
@@ -490,8 +490,8 @@ export const QuoteEditorPage: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
<Input type="date" label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
onChange={(e) => setForm((f) => ({ ...f, eventDate: 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}
@@ -499,8 +499,8 @@ export const QuoteEditorPage: React.FC = () => {
<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 }))} />
<Input type="date" label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
<LocalizedDateInput label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
onChange={(iso) => setForm((f) => ({ ...f, validUntil: iso }))} />
</div>
</Card>
@@ -58,7 +58,7 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
* - 'monthly' / 'quarterly': snap every scheduled invoice to
* `billingCycleDay` of the next period.
*/
billingCadence?: 'per_event' | 'monthly' | 'quarterly';
billingCadence?: 'per_event' | 'monthly' | 'quarterly' | 'manual';
billingCycleDay?: number;
/** Per-customer Skonto opt-out (migration 112). When true, none of
* this customer's invoices qualify for an early-payment discount,
@@ -360,16 +360,18 @@ export const customerAdminService = {
},
};
/** Open monthly bill accumulator preview (migration 128). One row in
/** Open bill accumulator preview (migration 128). One row in
* the invoices table with is_monthly_draft=true that gathers every
* invoice line created for this customer during the current period;
* ships on the cadence day or via triggerMonthlyBill. */
* ships on the cadence day or via triggerMonthlyBill. Manual-cadence
* drafts carry no period (periodStart/End null) and ship only on the
* admin trigger. */
export interface MonthlyDraftPreview {
id: number;
invoiceNumber: string;
currency: string;
periodStart: string;
periodEnd: string;
periodStart: string | null;
periodEnd: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;