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:
@@ -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).
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 customer’s invoices, regardless of template or global defaults.",
|
"skontoDisabledHint": "Disables the early-payment discount on all of this customer’s 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}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user