feat(hours): aggregate open-hours landing view on /admin/clients/hours

When no customer is selected, list every customer with unbilled hour
entries — entry count, total hours, and open amount (resolved via the
override → customer-rate → install-default chain). Rows with no
resolvable rate are flagged "Rate not set" rather than undercounted.
Click a row to drill into the per-customer logging section.

Backend: getUnbilledSummaryByCustomer() + GET
/api/admin/customers/hour-entries/unbilled-summary (customers.view).
This commit is contained in:
Luca
2026-06-02 11:43:42 +02:00
parent ab6bad17c9
commit 93956db0ca
6 changed files with 239 additions and 2 deletions
+12
View File
@@ -535,6 +535,18 @@ router.put('/:id/events', [
// surface.
// ---------------------------------------------------------------------
// Aggregate landing view for /admin/clients/hours — every customer with
// open (unbilled) hours + the open monetary amount. Registered before
// the /:id/hour-entries routes; the literal first segment ("hour-entries")
// can't collide with the int-validated :id pattern.
router.get('/hour-entries/unbilled-summary', [
adminAuth,
requirePermission('customers.view'),
], handleAsync(async (req, res) => {
const summary = await customerHoursService.getUnbilledSummaryByCustomer();
successResponse(res, { summary });
}));
router.get('/:id/hour-entries', [
adminAuth,
requirePermission('customers.view'),
@@ -495,8 +495,80 @@ async function billUnbilledEntries(customerId, adminId) {
});
}
/**
* Landing aggregate for /admin/clients/hours: one row per customer that
* currently carries unbilled hour entries, with the open hours + open
* monetary amount. In practice only per-event customers surface here —
* monthly/manual cadences auto-append each entry onto the running draft
* at save time (status flips straight to 'billed'), so they never leave
* unbilled rows behind. Each entry's amount resolves through the usual
* override → customer-rate → install-default chain; if an entry has no
* resolvable rate it still counts toward hours/entries but the row is
* flagged rateResolvable=false so the UI can prompt for a rate rather
* than silently undercounting. Sorted by open amount desc.
*/
async function getUnbilledSummaryByCustomer() {
const installDefaultMinor = await getInstallDefaultRateMinor();
const rows = await db('customer_hour_entries as h')
.join('customer_accounts as c', 'h.customer_account_id', 'c.id')
.where('h.status', 'unbilled')
.select(
'h.customer_account_id',
'h.duration_minutes',
'h.hourly_rate_minor_override',
'c.hourly_rate_minor as customer_hourly_rate_minor',
'c.company_name',
'c.display_name',
'c.first_name',
'c.last_name',
'c.email',
'c.password_hash',
'c.billing_cadence',
);
const byCustomer = new Map();
for (const r of rows) {
let agg = byCustomer.get(r.customer_account_id);
if (!agg) {
agg = {
customerAccountId: r.customer_account_id,
companyName: r.company_name || null,
displayName: r.display_name || null,
firstName: r.first_name || null,
lastName: r.last_name || null,
email: r.email || null,
// passive = no portal password set, same rule as the customer
// list / picker (adminCustomers transform).
isPassive: r.password_hash == null,
billingCadence: r.billing_cadence || null,
entryCount: 0,
totalMinutes: 0,
openAmountMinor: 0,
rateResolvable: true,
};
byCustomer.set(r.customer_account_id, agg);
}
agg.entryCount += 1;
const minutes = Number(r.duration_minutes || 0);
agg.totalMinutes += minutes;
let rateMinor = null;
if (r.hourly_rate_minor_override != null) rateMinor = Number(r.hourly_rate_minor_override);
else if (r.customer_hourly_rate_minor != null) rateMinor = Number(r.customer_hourly_rate_minor);
else if (installDefaultMinor != null) rateMinor = installDefaultMinor;
if (rateMinor == null) {
agg.rateResolvable = false;
} else {
agg.openAmountMinor += Math.round((minutes / 60) * rateMinor);
}
}
return Array.from(byCustomer.values())
.sort((a, b) => b.openAmountMinor - a.openAmountMinor);
}
module.exports = {
listEntries,
getUnbilledSummaryByCustomer,
createEntry,
updateEntry,
deleteEntry,