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
+1 -1
View File
@@ -395,7 +395,7 @@ router.put('/:id', [
// generated invoice to billing_cycle_day of the next period. // generated invoice to billing_cycle_day of the next period.
// Cycle day spans -15..-1 (days before month end) and 1..28 // Cycle day spans -15..-1 (days before month end) and 1..28
// (day of month) per migration 128 + service-layer clamp. // (day of month) per migration 128 + service-layer clamp.
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']), body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly', 'manual']),
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 }) body('billing_cycle_day').optional().isInt({ min: -15, max: 28 })
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'), .withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
// Per-customer Skonto opt-out (migration 112). // Per-customer Skonto opt-out (migration 112).
+9 -6
View File
@@ -202,8 +202,10 @@ async function createEntry(customerId, payload, adminId) {
const inserted = await trx('customer_hour_entries').insert(row).returning('id'); const inserted = await trx('customer_hour_entries').insert(row).returning('id');
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Monthly-mode customers get the auto-append treatment. // Accumulator-mode customers (monthly + manual) get the auto-append
if (customer.billing_cadence === 'monthly') { // treatment — the entry lands on the running draft instead of staying
// unbilled. Manual differs only in that its draft never auto-flushes.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
const fullEntry = { ...row, id: entryId }; const fullEntry = { ...row, id: entryId };
const rate = resolveEffectiveRate(fullEntry, customer); const rate = resolveEffectiveRate(fullEntry, customer);
const lineItem = buildLineItemFromEntry(fullEntry, rate); const lineItem = buildLineItemFromEntry(fullEntry, rate);
@@ -387,15 +389,16 @@ async function deleteEntry(entryId, adminId) {
/** /**
* Per-event flow: mint a standalone invoice from all unbilled entries * Per-event flow: mint a standalone invoice from all unbilled entries
* for this customer, one line per entry. Refuses when the customer is * for this customer, one line per entry. Refuses when the customer is
* monthly-mode (those entries auto-billed on save, so there should be * in an accumulator mode (monthly / manual) — those entries auto-billed
* no unbilled rows). Returns the new invoice id. * onto the running draft on save, so there should be no unbilled rows.
* Returns the new invoice id.
*/ */
async function billUnbilledEntries(customerId, adminId) { async function billUnbilledEntries(customerId, adminId) {
const customer = await db('customer_accounts').where({ id: customerId }).first(); const customer = await db('customer_accounts').where({ id: customerId }).first();
if (!customer) throw new AppError('Customer not found', 404); if (!customer) throw new AppError('Customer not found', 404);
if (customer.billing_cadence === 'monthly') { if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
throw new AppError( throw new AppError(
'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.', 'Accumulator-mode customers (monthly / manual) auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
409, 409,
'CADENCE_MISMATCH', 'CADENCE_MISMATCH',
); );
+27 -13
View File
@@ -293,6 +293,13 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
// Manual cadence has no billing cycle: the draft accumulates
// indefinitely and ships ONLY via the admin "Trigger invoice now"
// gesture, so it carries NO period_end. The scheduler's auto-flush
// filter is `monthly_period_end <= today`, which a NULL period_end
// can never satisfy — keeping manual drafts out of the cron path.
const isManual = customer.billing_cadence === 'manual';
// Resolve period_end: prefer the cadence in the current month, but // Resolve period_end: prefer the cadence in the current month, but
// if it has already passed, roll to next month so the new draft // if it has already passed, roll to next month so the new draft
// gathers items toward the NEXT bill. // gathers items toward the NEXT bill.
@@ -302,8 +309,11 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
const nextMonth = today.getMonth() + 1; const nextMonth = today.getMonth() + 1;
target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay); target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay);
} }
const periodStart = new Date(target.getFullYear(), target.getMonth(), 1); const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1);
const periodEnd = target; const periodEnd = isManual ? null : target;
// Placeholder issue/due date for the empty draft row — recomputed at
// issuance time. Manual drafts have no period_end, so fall back to today.
const placeholderDate = (periodEnd || today).toISOString().slice(0, 10);
// Look up any existing open draft for this customer. We deliberately // Look up any existing open draft for this customer. We deliberately
// do NOT filter by monthly_period_end here — only one draft can be // do NOT filter by monthly_period_end here — only one draft can be
@@ -341,8 +351,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
event_id: null, event_id: null,
language, language,
currency, currency,
issue_date: periodEnd.toISOString().slice(0, 10), issue_date: placeholderDate,
due_date: periodEnd.toISOString().slice(0, 10), // recomputed at issuance time due_date: placeholderDate, // recomputed at issuance time
installment_index: 0, installment_index: 0,
installment_total: 1, installment_total: 1,
status: 'scheduled', status: 'scheduled',
@@ -355,8 +365,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
business_bank_account_id: bank?.id || null, business_bank_account_id: bank?.id || null,
qr_format: null, qr_format: null,
is_monthly_draft: true, is_monthly_draft: true,
monthly_period_start: periodStart.toISOString().slice(0, 10), monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null,
monthly_period_end: periodEnd.toISOString().slice(0, 10), monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null,
// Migration 140 — each monthly-draft cycle is its own deal (no // Migration 140 — each monthly-draft cycle is its own deal (no
// quote/contract chain). Fresh UUID at creation; subsequent line // quote/contract chain). Fresh UUID at creation; subsequent line
// appends just mutate this same row, so the uuid sticks. // appends just mutate this same row, so the uuid sticks.
@@ -677,15 +687,19 @@ async function createInvoice(payload, adminId, trx = db) {
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first(); const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
ensureCustomerCanBill(customer); ensureCustomerCanBill(customer);
// Monthly-billing intercept (migration 128). For customers in // Accumulator intercept (migration 128). For customers in
// billing_cadence='monthly' mode every createInvoice call APPENDS // billing_cadence='monthly' OR 'manual' mode every createInvoice call
// line items onto the running monthly-draft instead of minting a // APPENDS line items onto a single running draft instead of minting a
// fresh invoice. Admin sees the editor flow exactly as before; the // fresh invoice. Admin sees the editor flow exactly as before; the
// returned id is the draft's id so the UI can redirect to the // returned id is the draft's id so the UI can redirect to the
// accumulator. `_skipMonthlyRouting` is the escape hatch used by // accumulator. The two modes differ only in WHEN the draft ships:
// internal helpers that need to mint a non-draft row (e.g. the // 'monthly' auto-flushes on the cadence day (scheduler), 'manual'
// accumulator itself, or future test fixtures). // never auto-flushes (no period_end) and ships only via the admin
if (customer.billing_cadence === 'monthly' && !payload._skipMonthlyRouting) { // "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape
// hatch used by internal helpers that need to mint a non-draft row
// (e.g. the accumulator itself, or future test fixtures).
if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual')
&& !payload._skipMonthlyRouting) {
const draft = await appendToMonthlyDraft(payload, customer, adminId, trx); const draft = await appendToMonthlyDraft(payload, customer, adminId, trx);
return { invoiceIds: draft?.id ? [draft.id] : [] }; return { invoiceIds: draft?.id ? [draft.id] : [] };
} }
@@ -17,7 +17,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Clock } from 'lucide-react'; import { Clock } from 'lucide-react';
import { Button, Card } from '../common'; import { Button, Card, LocalizedDateInput } 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';
@@ -224,8 +224,7 @@ 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.date', 'Date')} {t('customers.hours.form.date', 'Date')}
</label> </label>
<input type="date" value={entryDate} <LocalizedDateInput value={entryDate} onChange={setEntryDate} />
onChange={(e) => setEntryDate(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">
+5 -1
View File
@@ -3127,22 +3127,26 @@
}, },
"billing": { "billing": {
"section": "Abrechnungsrhythmus", "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", "cadence": "Abrechnungsrhythmus",
"perEvent": "Per Event", "perEvent": "Per Event",
"monthly": "Monatlich", "monthly": "Monatlich",
"quarterly": "Quartalsweise", "quarterly": "Quartalsweise",
"manual": "Manuell (nur auf Auslösung)",
"cycleDay": "Stichtag", "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).", "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", "skontoDisabled": "Kein Skonto für diesen Kunden",
"skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden unabhängig von Vorlage oder globalen Standardwerten.", "skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden unabhängig von Vorlage oder globalen Standardwerten.",
"triggerNow": "Rechnung jetzt ausstellen", "triggerNow": "Rechnung jetzt ausstellen",
"triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.", "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.", "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}}", "triggered": "Monatsrechnung ausgestellt: {{number}}",
"triggerError": "Monatsrechnung konnte nicht ausgelöst werden.", "triggerError": "Monatsrechnung konnte nicht ausgelöst werden.",
"draftPreview": { "draftPreview": {
"title": "Offen für die Rechnung dieses Monats", "title": "Offen für die Rechnung dieses Monats",
"titleManual": "Offen wird auf manuelle Auslösung versendet",
"periodRange": "{{number}} · {{from}} {{to}}" "periodRange": "{{number}} · {{from}} {{to}}"
} }
}, },
+5 -1
View File
@@ -3127,22 +3127,26 @@
}, },
"billing": { "billing": {
"section": "Billing cadence", "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", "cadence": "Billing cadence",
"perEvent": "Per event", "perEvent": "Per event",
"monthly": "Monthly", "monthly": "Monthly",
"quarterly": "Quarterly", "quarterly": "Quarterly",
"manual": "Manual (trigger only)",
"cycleDay": "Cycle day", "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).", "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", "skontoDisabled": "No Skonto for this customer",
"skontoDisabledHint": "Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.", "skontoDisabledHint": "Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.",
"triggerNow": "Trigger invoice now", "triggerNow": "Trigger invoice now",
"triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.", "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.", "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}}", "triggered": "Monthly bill issued: {{number}}",
"triggerError": "Could not trigger the monthly bill.", "triggerError": "Could not trigger the monthly bill.",
"draftPreview": { "draftPreview": {
"title": "Pending in this month's bill", "title": "Pending in this month's bill",
"titleManual": "Pending — ships on manual trigger",
"periodRange": "{{number}} · {{from}} {{to}}" "periodRange": "{{number}} · {{from}} {{to}}"
} }
}, },
+36 -22
View File
@@ -77,7 +77,7 @@ export const CustomerDetailPage: React.FC = () => {
queryKey: ['admin-customer-monthly-draft', customerId], queryKey: ['admin-customer-monthly-draft', customerId],
queryFn: () => customerAdminService.getMonthlyDraft(customerId), queryFn: () => customerAdminService.getMonthlyDraft(customerId),
enabled: Number.isFinite(customerId) && customerId > 0 enabled: Number.isFinite(customerId) && customerId > 0
&& (customer?.billingCadence === 'monthly'), && (customer?.billingCadence === 'monthly' || customer?.billingCadence === 'manual'),
}); });
const monthlyDraft = monthlyDraftRes?.draft || null; 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="per_event">{t('customers.billing.perEvent', 'Per event')}</option>
<option value="monthly">{t('customers.billing.monthly', 'Monthly')}</option> <option value="monthly">{t('customers.billing.monthly', 'Monthly')}</option>
<option value="quarterly">{t('customers.billing.quarterly', 'Quarterly')}</option> <option value="quarterly">{t('customers.billing.quarterly', 'Quarterly')}</option>
<option value="manual">{t('customers.billing.manual', 'Manual (trigger only)')}</option>
</select> </select>
</div> </div>
{form.billingCadence && form.billingCadence !== 'per_event' && ( {(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
<div> <div>
<label className="block text-sm font-medium text-theme mb-1"> <label className="block text-sm font-medium text-theme mb-1">
{t('customers.billing.cycleDay', 'Cycle day')} {t('customers.billing.cycleDay', 'Cycle day')}
@@ -763,21 +764,26 @@ export const CustomerDetailPage: React.FC = () => {
period so admin sees exactly what "Trigger invoice now" period so admin sees exactly what "Trigger invoice now"
would ship. Hidden when no draft exists yet (admin hasn't would ship. Hidden when no draft exists yet (admin hasn't
saved anything onto the period). */} 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="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-theme"> <h3 className="text-sm font-semibold text-theme">
{t('customers.billing.draftPreview.title', {form.billingCadence === 'manual'
'Pending in this month\'s bill')} ? t('customers.billing.draftPreview.titleManual',
'Pending — ships on manual trigger')
: t('customers.billing.draftPreview.title',
'Pending in this month\'s bill')}
</h3> </h3>
<span className="text-xs text-muted-theme"> <span className="text-xs text-muted-theme">
{t('customers.billing.draftPreview.periodRange', {monthlyDraft.periodStart && monthlyDraft.periodEnd
'{{number}} · {{from}} {{to}}', ? t('customers.billing.draftPreview.periodRange',
{ '{{number}} · {{from}} {{to}}',
number: monthlyDraft.invoiceNumber, {
from: fmtDate(monthlyDraft.periodStart), number: monthlyDraft.invoiceNumber,
to: fmtDate(monthlyDraft.periodEnd), from: fmtDate(monthlyDraft.periodStart),
})} to: fmtDate(monthlyDraft.periodEnd),
})
: monthlyDraft.invoiceNumber}
</span> </span>
</div> </div>
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden"> <div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
@@ -834,20 +840,25 @@ export const CustomerDetailPage: React.FC = () => {
</div> </div>
)} )}
{/* Manual trigger — issue the running monthly draft NOW {/* Manual trigger — issue the running draft NOW. For monthly
instead of waiting for the cadence-day scheduler tick. customers this bypasses the cadence-day scheduler tick; for
Only shown for monthly-mode customers (per-event has no manual-cadence customers it's the ONLY way the draft ships
draft to arm; the equivalent action there is "Bill these (the scheduler never auto-flushes a manual draft). Per-event
hours" on the standalone Hours-logging page). */} has no draft to arm; the equivalent action there is "Bill
{form.billingCadence === 'monthly' && ( 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"> <div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button <Button
variant="outline" variant="outline"
disabled={triggerMonthlyBillMutation.isPending} disabled={triggerMonthlyBillMutation.isPending}
isLoading={triggerMonthlyBillMutation.isPending} isLoading={triggerMonthlyBillMutation.isPending}
onClick={() => { onClick={() => {
if (window.confirm(t('customers.billing.triggerConfirm', const confirmMsg = form.billingCadence === 'manual'
'Issue this customer\'s monthly bill now? The customer receives the email immediately.') as string)) { ? 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(); triggerMonthlyBillMutation.mutate();
} }
}} }}
@@ -855,8 +866,11 @@ export const CustomerDetailPage: React.FC = () => {
{t('customers.billing.triggerNow', 'Trigger invoice now')} {t('customers.billing.triggerNow', 'Trigger invoice now')}
</Button> </Button>
<p className="text-xs text-muted-theme mt-2"> <p className="text-xs text-muted-theme mt-2">
{t('customers.billing.triggerHint', {form.billingCadence === 'manual'
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')} ? 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> </p>
</div> </div>
)} )}
@@ -57,7 +57,7 @@ const safeParseDate = (dateValue: unknown): Date | null => {
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; 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 { 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 { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard'; 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"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.expirationDate')} {t('events.expirationDate')}
</label> </label>
<Input <LocalizedDateInput
type="date"
value={editForm.expires_at} 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')} min={format(new Date(), 'yyyy-MM-dd')}
/> />
</div> </div>
@@ -17,7 +17,7 @@ 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 } from '../../../components/common'; import { Button, Card, Input, Loading, LocalizedDateInput } from '../../../components/common';
import { import {
contractsService, contractsService,
type ContractBlockSection, type ContractBlockSection,
@@ -380,13 +380,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.issueDate', 'Issue date')} {t('contracts.editor.issueDate', 'Issue date')}
</label> </label>
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} /> <LocalizedDateInput value={issueDate} onChange={setIssueDate} />
</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.validUntil', 'Sign by (optional)')} {t('contracts.editor.validUntil', 'Sign by (optional)')}
</label> </label>
<Input type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} /> <LocalizedDateInput value={validUntil} onChange={setValidUntil} />
</div> </div>
</div> </div>
@@ -419,7 +419,7 @@ 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.eventDate', 'Event date')} {t('contracts.editor.eventDate', 'Event date')}
</label> </label>
<Input type="date" value={eventDate} onChange={(e) => setEventDate(e.target.value)} /> <LocalizedDateInput value={eventDate} onChange={setEventDate} />
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<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 } from '../../../components/common'; import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
import { import {
quotesService, quotesService,
type QuoteCreatePayload, type QuoteCreatePayload,
@@ -490,8 +490,8 @@ export const QuoteEditorPage: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <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} <Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} /> onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
<Input type="date" 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={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))} /> onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart} <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 }))} /> 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} <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} <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 }))} />
<Input type="date" label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil} <LocalizedDateInput label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} /> onChange={(iso) => setForm((f) => ({ ...f, validUntil: iso }))} />
</div> </div>
</Card> </Card>
@@ -58,7 +58,7 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
* - 'monthly' / 'quarterly': snap every scheduled invoice to * - 'monthly' / 'quarterly': snap every scheduled invoice to
* `billingCycleDay` of the next period. * `billingCycleDay` of the next period.
*/ */
billingCadence?: 'per_event' | 'monthly' | 'quarterly'; billingCadence?: 'per_event' | 'monthly' | 'quarterly' | 'manual';
billingCycleDay?: number; billingCycleDay?: number;
/** Per-customer Skonto opt-out (migration 112). When true, none of /** Per-customer Skonto opt-out (migration 112). When true, none of
* this customer's invoices qualify for an early-payment discount, * 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 * the invoices table with is_monthly_draft=true that gathers every
* invoice line created for this customer during the current period; * 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 { export interface MonthlyDraftPreview {
id: number; id: number;
invoiceNumber: string; invoiceNumber: string;
currency: string; currency: string;
periodStart: string; periodStart: string | null;
periodEnd: string; periodEnd: string | null;
netAmountMinor: number; netAmountMinor: number;
vatRate: number | null; vatRate: number | null;
vatAmountMinor: number; vatAmountMinor: number;