diff --git a/frontend/src/components/admin/CustomerCrmPanels.tsx b/frontend/src/components/admin/CustomerCrmPanels.tsx index b59b57a8..8c102ae5 100644 --- a/frontend/src/components/admin/CustomerCrmPanels.tsx +++ b/frontend/src/components/admin/CustomerCrmPanels.tsx @@ -252,6 +252,10 @@ const RebillsPanel: React.FC = ({ customerAccountId }) => { const canView = usePermission('accounting.view'); const canManage = usePermission('accounting.manage'); const canCombine = usePermission('customers.edit'); + // The open-hours counter below reads GET /customers/:id/hour-entries, which + // requires customers.view — a different permission from the customers.edit + // that authorises the combined billing itself (#983). + const canViewCustomers = usePermission('customers.view'); const [crossAddOpen, setCrossAddOpen] = useState(false); const [busy, setBusy] = useState(false); @@ -262,12 +266,15 @@ const RebillsPanel: React.FC = ({ customerAccountId }) => { staleTime: 30_000, }); - // Open hours count for the cross-add offer — only when hours logging is on - // AND the admin can actually create the combined invoice. + // Open hours count for the cross-add offer — only when hours logging is on, + // the admin can actually create the combined invoice (customers.edit) AND can + // read the hour entries the count comes from (customers.view). Both are + // required: without the read permission the request just 403s on every + // render (#983). const { data: openHours = 0 } = useQuery({ queryKey: ['customer-open-hours-count', customerAccountId], queryFn: async () => (await customerAdminService.listHourEntries(customerAccountId, 'unbilled')).length, - enabled: !!flags.hoursLogging && canCombine, + enabled: !!flags.hoursLogging && canCombine && canViewCustomers, staleTime: 30_000, }); diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index dc5217b5..585f3a47 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -56,6 +56,10 @@ export const HoursSection: React.FC = ({ const { flags } = useFeatureFlags(); // Billing hours (and the combined path) go through customers.edit (#866 review). const canBill = usePermission('customers.edit'); + // The open-re-bills counter below reads GET /expenses/inbound/by-customer/:id, + // which requires accounting.view — a different permission from the one that + // authorises the billing itself (#983). + const canViewAccounting = usePermission('accounting.view'); const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate(); const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10)); const [startTime, setStartTime] = useState('09:00'); @@ -167,11 +171,14 @@ export const HoursSection: React.FC = ({ }); // Open re-bills count for the cross-add offer (#866) — only when the - // incoming-invoices feature is on and the admin can create the invoice. + // incoming-invoices feature is on, the admin can create the invoice + // (customers.edit) AND can read the re-bills the count comes from + // (accounting.view). Both are required: without the read permission the + // request just 403s on every render (#983). const { data: openRebills = 0 } = useQuery({ queryKey: ['customer-open-rebills-count', customerId], queryFn: async () => (await accountingService.listCustomerRebills(customerId)).filter((r) => r.status === 'open').length, - enabled: !!flags.incomingInvoices && canBill, + enabled: !!flags.incomingInvoices && canBill && canViewAccounting, staleTime: 30_000, }); const [crossAddOpen, setCrossAddOpen] = useState(false);