feat(invoices): surface monthly/manual accumulator drafts in the Bills list
Manual/monthly-cadence customers accumulate logged hours into one running
draft invoice (is_monthly_draft, migration 128). That draft gets a real
invoice number and stamps the hours ("Billed: R-2026-0026"), but listInvoices
hid is_monthly_draft rows from the main list — so the invoice looked lost even
though it existed on the customer's monthly-queue card. It also carried status
'scheduled' despite never auto-sending on manual cadence, reading misleadingly
as "Scheduled".
- Bills list now opts into drafts via a new `includeDrafts` query param
(GET /admin/invoices → listInvoices includeMonthlyDrafts). Pickers/sub-lists
that reuse billsService.list leave it off, so they're unaffected.
- Draft rows render a distinct "Draft" badge instead of "Scheduled"
(transformInvoice already exposes isMonthlyDraft).
- The hours "Billed: R-…" chip now links straight to its invoice.
- i18n: bills.status.draft (de "Entwurf", en "Draft").
This commit is contained in:
@@ -342,6 +342,7 @@ router.get(
|
|||||||
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
|
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
|
||||||
|
query('includeDrafts').optional({ values: 'falsy' }).isBoolean(),
|
||||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']),
|
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']),
|
||||||
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||||
@@ -358,6 +359,9 @@ router.get(
|
|||||||
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
|
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
|
||||||
sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null,
|
sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null,
|
||||||
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
|
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
|
||||||
|
// Surface running monthly/manual accumulator drafts (hidden by
|
||||||
|
// default per migration 128) when the Bills list explicitly asks.
|
||||||
|
includeMonthlyDrafts: req.query.includeDrafts === 'true' || req.query.includeDrafts === true,
|
||||||
q: req.query.q,
|
q: req.query.q,
|
||||||
},
|
},
|
||||||
sort: req.query.sort || 'issue_desc',
|
sort: req.query.sort || 'issue_desc',
|
||||||
|
|||||||
@@ -448,12 +448,24 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pr-3">
|
<td className="py-1.5 pr-3">
|
||||||
{e.status === 'billed' ? (
|
{e.status === 'billed' ? (
|
||||||
<span className="text-xs text-green-700 dark:text-green-300">
|
e.invoiceId ? (
|
||||||
{e.invoiceNumber
|
// Link straight to the invoice so a "Billed: R-…" entry
|
||||||
? t('customers.hours.status.billedOn',
|
// is one click from its (possibly draft) invoice.
|
||||||
'Billed: {{number}}', { number: e.invoiceNumber })
|
<Link
|
||||||
: t('customers.hours.status.billed', 'Billed')}
|
to={`/admin/clients/bills/${e.invoiceId}`}
|
||||||
</span>
|
className="text-xs text-green-700 dark:text-green-300 underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{e.invoiceNumber
|
||||||
|
? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber })
|
||||||
|
: t('customers.hours.status.billed', 'Billed')}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-green-700 dark:text-green-300">
|
||||||
|
{e.invoiceNumber
|
||||||
|
? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber })
|
||||||
|
: t('customers.hours.status.billed', 'Billed')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
{t('customers.hours.status.unbilled', 'Unbilled')}
|
{t('customers.hours.status.unbilled', 'Unbilled')}
|
||||||
|
|||||||
@@ -4666,6 +4666,7 @@
|
|||||||
"bills": {
|
"bills": {
|
||||||
"status": {
|
"status": {
|
||||||
"scheduled": "Geplant",
|
"scheduled": "Geplant",
|
||||||
|
"draft": "Entwurf",
|
||||||
"pending_delivery": "Wartet auf Lieferung",
|
"pending_delivery": "Wartet auf Lieferung",
|
||||||
"sent": "Gesendet",
|
"sent": "Gesendet",
|
||||||
"paid": "Bezahlt",
|
"paid": "Bezahlt",
|
||||||
|
|||||||
@@ -4810,6 +4810,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"scheduled": "Scheduled",
|
"scheduled": "Scheduled",
|
||||||
|
"draft": "Draft",
|
||||||
"pending_delivery": "Awaiting delivery",
|
"pending_delivery": "Awaiting delivery",
|
||||||
"sent": "Sent",
|
"sent": "Sent",
|
||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export const BillsListPage: React.FC = () => {
|
|||||||
q: search || undefined,
|
q: search || undefined,
|
||||||
status: statusFilter.length ? statusFilter : undefined,
|
status: statusFilter.length ? statusFilter : undefined,
|
||||||
unpaidOnly,
|
unpaidOnly,
|
||||||
|
// Surface the running monthly/manual accumulator drafts here (they're
|
||||||
|
// badged "Draft"); they're hidden from pickers/sub-lists by default.
|
||||||
|
includeDrafts: true,
|
||||||
sort, page, pageSize: 25,
|
sort, page, pageSize: 25,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -186,14 +189,23 @@ export const BillsListPage: React.FC = () => {
|
|||||||
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
|
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
{inv.isMonthlyDraft ? (
|
||||||
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
// Running accumulator draft (manual/monthly). It carries
|
||||||
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
|
// status 'scheduled' but never auto-sends on manual cadence,
|
||||||
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
|
// so badge it honestly as "Draft" rather than "Scheduled".
|
||||||
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
|
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
|
||||||
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
|
{t('bills.status.draft', 'Draft')}
|
||||||
: 'bg-amber-100 text-amber-800'
|
</span>
|
||||||
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
|
) : (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
||||||
|
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
|
||||||
|
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
|
||||||
|
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
|
||||||
|
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
|
||||||
|
: 'bg-amber-100 text-amber-800'
|
||||||
|
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ export interface InvoiceSummary {
|
|||||||
* (migration 111). Hide line-item editing on these rows; the
|
* (migration 111). Hide line-item editing on these rows; the
|
||||||
* uploaded PDF is the source of truth. */
|
* uploaded PDF is the source of truth. */
|
||||||
isImported?: boolean;
|
isImported?: boolean;
|
||||||
|
/** True for the running monthly/manual accumulator draft
|
||||||
|
* (migration 128). Carries status 'scheduled' but never auto-sends
|
||||||
|
* (manual) — shown with a "Draft" badge in the list. */
|
||||||
|
isMonthlyDraft?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceDetail extends InvoiceSummary {
|
export interface InvoiceDetail extends InvoiceSummary {
|
||||||
@@ -225,6 +229,10 @@ export const billsService = {
|
|||||||
sort?: InvoiceSort;
|
sort?: InvoiceSort;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
/** Include the running monthly/manual accumulator drafts that the
|
||||||
|
* main list hides by default (migration 128). Only the Bills list
|
||||||
|
* opts in; pickers/sub-lists leave it off. */
|
||||||
|
includeDrafts?: boolean;
|
||||||
} = {}): Promise<InvoiceListResponse> {
|
} = {}): Promise<InvoiceListResponse> {
|
||||||
const { data } = await api.get('/admin/invoices', {
|
const { data } = await api.get('/admin/invoices', {
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
Reference in New Issue
Block a user