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:
@@ -535,6 +535,18 @@ router.put('/:id/events', [
|
|||||||
// surface.
|
// 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', [
|
router.get('/:id/hour-entries', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
requirePermission('customers.view'),
|
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 = {
|
module.exports = {
|
||||||
listEntries,
|
listEntries,
|
||||||
|
getUnbilledSummaryByCustomer,
|
||||||
createEntry,
|
createEntry,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
|
|||||||
@@ -3268,7 +3268,16 @@
|
|||||||
"pickPlaceholder": "— Kunde wählen —",
|
"pickPlaceholder": "— Kunde wählen —",
|
||||||
"emptyList": "Noch keine Kunden mit aktivierter Stundenerfassung. Aktivieren Sie „Stundenerfassung“ zuerst auf der Kundendetailseite.",
|
"emptyList": "Noch keine Kunden mit aktivierter Stundenerfassung. Aktivieren Sie „Stundenerfassung“ zuerst auf der Kundendetailseite.",
|
||||||
"searchPlaceholder": "Nach E-Mail oder Firma suchen…",
|
"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": {
|
"crmDev": {
|
||||||
"title": "CRM-Entwicklung",
|
"title": "CRM-Entwicklung",
|
||||||
|
|||||||
@@ -3268,7 +3268,16 @@
|
|||||||
"pickPlaceholder": "— Select customer —",
|
"pickPlaceholder": "— Select customer —",
|
||||||
"emptyList": "No customers have hours logging enabled yet. Flip \"Hours logging\" on a customer's detail page first.",
|
"emptyList": "No customers have hours logging enabled yet. Flip \"Hours logging\" on a customer's detail page first.",
|
||||||
"searchPlaceholder": "Search by email or company…",
|
"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": {
|
"crmDev": {
|
||||||
"title": "CRM Development",
|
"title": "CRM Development",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Clock, ChevronRight, AlertTriangle } from 'lucide-react';
|
||||||
import { Card } from '../../../components/common';
|
import { Card } from '../../../components/common';
|
||||||
import { HoursSection } from '../../../components/admin/HoursSection';
|
import { HoursSection } from '../../../components/admin/HoursSection';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +23,21 @@ import {
|
|||||||
import {
|
import {
|
||||||
customerAdminService,
|
customerAdminService,
|
||||||
type CustomerAccountDetail,
|
type CustomerAccountDetail,
|
||||||
|
type UnbilledHoursSummaryRow,
|
||||||
} from '../../../services/customerAdmin.service';
|
} 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 = () => {
|
export const HoursLoggingPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -47,6 +62,30 @@ export const HoursLoggingPage: React.FC = () => {
|
|||||||
enabled: !!selectedId,
|
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 (
|
return (
|
||||||
<div className="container py-6 space-y-6">
|
<div className="container py-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -116,6 +155,76 @@ export const HoursLoggingPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{!selectedId && (
|
||||||
|
<Card padding="lg">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Clock className="w-4 h-4 text-muted-theme" />
|
||||||
|
<h2 className="text-base font-semibold text-theme">
|
||||||
|
{t('hoursLogging.openHours.title', 'Open hours across all customers')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-theme mb-4">
|
||||||
|
{t('hoursLogging.openHours.subtitle',
|
||||||
|
'Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{summaryLoading ? (
|
||||||
|
<p className="text-sm text-muted-theme py-6 text-center">
|
||||||
|
{t('common.loading', 'Loading…')}
|
||||||
|
</p>
|
||||||
|
) : summary.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-theme py-6 text-center">
|
||||||
|
{t('hoursLogging.openHours.empty',
|
||||||
|
'No unbilled hours right now — everything is billed or no time has been logged yet.')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
|
{summary.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.customerAccountId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectFromSummary(r)}
|
||||||
|
className="w-full flex items-center justify-between gap-4 py-3 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800/60 rounded-md px-2 -mx-2 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-theme truncate">{summaryLabel(r)}</span>
|
||||||
|
{r.isPassive && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300">
|
||||||
|
{t('hoursLogging.openHours.passive', 'Passive')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-theme mt-0.5">
|
||||||
|
{t('hoursLogging.openHours.entryLine', {
|
||||||
|
count: r.entryCount,
|
||||||
|
hours: (r.totalMinutes / 60).toFixed(2),
|
||||||
|
defaultValue: '{{count}} entries · {{hours}}h',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
{r.rateResolvable ? (
|
||||||
|
<div className="font-semibold text-theme tabular-nums">
|
||||||
|
{formatMoneyMinor(r.openAmountMinor, currency)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-300 text-xs">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
{t('hoursLogging.openHours.needsRate', 'Rate not set')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-theme" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedId && customerHoursAllowed && (
|
{selectedId && customerHoursAllowed && (
|
||||||
<HoursSection
|
<HoursSection
|
||||||
customerId={selectedId}
|
customerId={selectedId}
|
||||||
|
|||||||
@@ -340,6 +340,14 @@ export const customerAdminService = {
|
|||||||
return (response.data as any).data ?? response.data;
|
return (response.data as any).data ?? response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Landing aggregate for /admin/clients/hours — every customer that
|
||||||
|
* currently carries unbilled hour entries, with open hours + open
|
||||||
|
* amount (install default currency). Sorted by open amount desc. */
|
||||||
|
async getUnbilledHoursSummary(): Promise<UnbilledHoursSummaryRow[]> {
|
||||||
|
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,
|
/** Admin override — issue the customer's running monthly draft now,
|
||||||
* bypassing the cadence-day wait. 409 when no draft exists or the
|
* bypassing the cadence-day wait. 409 when no draft exists or the
|
||||||
* draft is empty. Returns the issued invoice id + number. */
|
* draft is empty. Returns the issued invoice id + number. */
|
||||||
@@ -419,6 +427,24 @@ export interface HourEntry {
|
|||||||
updatedAt: string;
|
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 {
|
export interface HourEntryCreatePayload {
|
||||||
entryDate: string; // YYYY-MM-DD
|
entryDate: string; // YYYY-MM-DD
|
||||||
startTime: string; // HH:MM
|
startTime: string; // HH:MM
|
||||||
|
|||||||
Reference in New Issue
Block a user