From 93956db0caa4ad8a1ddb8d68c30f0912214715e8 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:43:42 +0200 Subject: [PATCH] feat(hours): aggregate open-hours landing view on /admin/clients/hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/src/routes/adminCustomers.js | 12 ++ backend/src/services/customerHoursService.js | 72 ++++++++++++ frontend/src/i18n/locales/de.json | 11 +- frontend/src/i18n/locales/en.json | 11 +- .../pages/admin/clients/HoursLoggingPage.tsx | 109 ++++++++++++++++++ .../src/services/customerAdmin.service.ts | 26 +++++ 6 files changed, 239 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js index 026af794..0d3c81e7 100644 --- a/backend/src/routes/adminCustomers.js +++ b/backend/src/routes/adminCustomers.js @@ -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'), diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index 6e17b864..2ce2e262 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -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, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index a9ee2fe2..0aa7ebab 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3268,7 +3268,16 @@ "pickPlaceholder": "— Kunde wählen —", "emptyList": "Noch keine Kunden mit aktivierter Stundenerfassung. Aktivieren Sie „Stundenerfassung“ zuerst auf der Kundendetailseite.", "searchPlaceholder": "Nach E-Mail oder Firma suchen…", - "customerLoggingDisabled": "Bei diesem Kunden ist die Stundenerfassung deaktiviert. Aktiviere sie auf der Kundendetailseite, um Stunden zu erfassen." + "customerLoggingDisabled": "Bei diesem Kunden ist die Stundenerfassung deaktiviert. Aktiviere sie auf der Kundendetailseite, um Stunden zu erfassen.", + "openHours": { + "title": "Offene Stunden über alle Kunden", + "subtitle": "Noch nicht abgerechnete Zeitblöcke. Oben einen Kunden wählen oder auf eine Zeile klicken, um Details zu öffnen.", + "empty": "Aktuell keine offenen Stunden – alles abgerechnet oder noch keine Zeit erfasst.", + "entryLine_one": "{{count}} Eintrag · {{hours}} Std.", + "entryLine_other": "{{count}} Einträge · {{hours}} Std.", + "passive": "Passiv", + "needsRate": "Kein Satz hinterlegt" + } }, "crmDev": { "title": "CRM-Entwicklung", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ceca3f51..618f5985 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3268,7 +3268,16 @@ "pickPlaceholder": "— Select customer —", "emptyList": "No customers have hours logging enabled yet. Flip \"Hours logging\" on a customer's detail page first.", "searchPlaceholder": "Search by email or company…", - "customerLoggingDisabled": "This customer has hour logging disabled. Enable it on the customer's detail page to log hours." + "customerLoggingDisabled": "This customer has hour logging disabled. Enable it on the customer's detail page to log hours.", + "openHours": { + "title": "Open hours across all customers", + "subtitle": "Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.", + "empty": "No unbilled hours right now — everything is billed or no time has been logged yet.", + "entryLine_one": "{{count}} entry · {{hours}}h", + "entryLine_other": "{{count}} entries · {{hours}}h", + "passive": "Passive", + "needsRate": "Rate not set" + } }, "crmDev": { "title": "CRM Development", diff --git a/frontend/src/pages/admin/clients/HoursLoggingPage.tsx b/frontend/src/pages/admin/clients/HoursLoggingPage.tsx index f1d0a529..02bc77cc 100644 --- a/frontend/src/pages/admin/clients/HoursLoggingPage.tsx +++ b/frontend/src/pages/admin/clients/HoursLoggingPage.tsx @@ -13,6 +13,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; +import { Clock, ChevronRight, AlertTriangle } from 'lucide-react'; import { Card } from '../../../components/common'; import { HoursSection } from '../../../components/admin/HoursSection'; import { @@ -22,7 +23,21 @@ import { import { customerAdminService, type CustomerAccountDetail, + type UnbilledHoursSummaryRow, } from '../../../services/customerAdmin.service'; +import { businessProfileService } from '../../../services/businessProfile.service'; +import { formatMoneyMinor } from '../../../utils/money'; + +/** Build a display label matching the CustomerPicker convention. */ +function summaryLabel(r: UnbilledHoursSummaryRow): string { + return ( + r.companyName + || [r.firstName, r.lastName].filter(Boolean).join(' ') + || r.displayName + || r.email + || `#${r.customerAccountId}` + ); +} export const HoursLoggingPage: React.FC = () => { const { t } = useTranslation(); @@ -47,6 +62,30 @@ export const HoursLoggingPage: React.FC = () => { enabled: !!selectedId, }); + // Landing aggregate — every customer with open (unbilled) hours. Only + // fetched while no customer is picked; once one is selected the page + // hands over to HoursSection. invalidated implicitly by remount on + // re-entry (HoursSection mutations bump per-customer keys). + const { data: summary = [], isLoading: summaryLoading } = useQuery({ + queryKey: ['admin-unbilled-hours-summary'], + queryFn: () => customerAdminService.getUnbilledHoursSummary(), + enabled: !selectedId, + }); + + const { data: profileSnapshot } = useQuery({ + queryKey: ['business-profile-snapshot'], + queryFn: () => businessProfileService.get(), + staleTime: 5 * 60 * 1000, + }); + const currency = profileSnapshot?.profile?.defaultCurrency || 'CHF'; + + const selectFromSummary = (r: UnbilledHoursSummaryRow) => { + setSelectedId(r.customerAccountId); + setCustomerLabel(summaryLabel(r)); + setCustomerIsPassive(r.isPassive); + setCustomerHoursAllowed(true); + }; + return (
@@ -116,6 +155,76 @@ export const HoursLoggingPage: React.FC = () => { )} + {!selectedId && ( + +
+ +

+ {t('hoursLogging.openHours.title', 'Open hours across all customers')} +

+
+

+ {t('hoursLogging.openHours.subtitle', + 'Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.')} +

+ + {summaryLoading ? ( +

+ {t('common.loading', 'Loading…')} +

+ ) : summary.length === 0 ? ( +

+ {t('hoursLogging.openHours.empty', + 'No unbilled hours right now — everything is billed or no time has been logged yet.')} +

+ ) : ( +
+ {summary.map((r) => ( + + ))} +
+ )} +
+ )} + {selectedId && customerHoursAllowed && ( { + const response = await api.get(`/admin/customers/hour-entries/unbilled-summary`); + return ((response.data as any).data?.summary ?? (response.data as any).summary) || []; + }, + /** Admin override — issue the customer's running monthly draft now, * bypassing the cadence-day wait. 409 when no draft exists or the * draft is empty. Returns the issued invoice id + number. */ @@ -419,6 +427,24 @@ export interface HourEntry { updatedAt: string; } +export interface UnbilledHoursSummaryRow { + customerAccountId: number; + companyName: string | null; + displayName: string | null; + firstName: string | null; + lastName: string | null; + email: string | null; + isPassive: boolean; + billingCadence: string | null; + entryCount: number; + totalMinutes: number; + openAmountMinor: number; + /** false when at least one entry has no resolvable rate (no override, + * no customer rate, no install default) — its amount is excluded from + * openAmountMinor and the UI prompts to set a rate. */ + rateResolvable: boolean; +} + export interface HourEntryCreatePayload { entryDate: string; // YYYY-MM-DD startTime: string; // HH:MM