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
@@ -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 (
<div className="container py-6 space-y-6">
<div className="flex items-center justify-between">
@@ -116,6 +155,76 @@ export const HoursLoggingPage: React.FC = () => {
)}
</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 && (
<HoursSection
customerId={selectedId}