feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)

Closes #866.

Three features, all behind the `incomingInvoices` feature flag:

1. Attach the stored supplier proof PDF to the client-invoice email when a
   captured invoice is re-billed/passed through, as a SEPARATE attachment so
   invoice immutability holds. Global default (off), per-customer tri-state
   override, and per-file selection in a new Send dialog. A missing proof at
   issue time stamps inbound_documents.proof_attach_error rather than silently
   dropping, and never blocks the send. Proof filename is a configurable
   template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens.

2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid
   with status derived from the linked invoice lifecycle rather than a
   duplicated column.

3. Cross-add dialog rolling open hours and open re-bills into one invoice,
   symmetric from both entry points. The two stay distinct, contiguous line
   groups — never merged into shared line items.

Migration 169 is additive, hasColumn-guarded and idempotent.

Review (two rounds) closed two concerns:

- Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a
  covering invoice was cancelled, so a Storno'd re-bill showed as Open in the
  new panel while every billing path filters on that column being NULL — the
  supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now
  detaches the linkage on both invoice-cancel paths, with a regression test on
  the issued-cancel path.

- Permission gating: the new controls rendered on data presence alone while
  their endpoints require accounting.view / accounting.manage / customers.edit.
  Now gated at both the query and render layers.

Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
This commit is contained in:
Luca
2026-08-03 22:03:31 +02:00
committed by GitHub
parent d66425c8ee
commit 165cebdb5c
25 changed files with 1645 additions and 99 deletions
@@ -0,0 +1,65 @@
/**
* Cross-add dialog (issue #866, Feature 3).
*
* Shown when the admin bills ONE category (hours or re-bills) for a customer
* who also has open items in the OTHER category. Offers to roll both into the
* same invoice. Hours and re-bills are never merged into shared line items —
* they stay as distinct, contiguous groups on the invoice.
*/
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../common';
interface Props {
open: boolean;
/** The category the admin clicked "create invoice" on. */
primary: 'hours' | 'rebills';
/** How many OPEN items exist in the OTHER category. */
otherCount: number;
busy?: boolean;
/** includeOther = true → combine both; false → bill only the primary. */
onConfirm: (includeOther: boolean) => void;
onClose: () => void;
}
export const CrossAddInvoiceDialog: React.FC<Props> = ({ open, primary, otherCount, busy, onConfirm, onClose }) => {
const { t } = useTranslation();
// Escape closes without billing (mirrors the explicit Cancel below).
useEffect(() => {
if (!open) return undefined;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, busy, onClose]);
if (!open) return null;
const other = primary === 'hours' ? 'rebills' : 'hours';
const otherLabel = other === 'hours'
? t('crossAdd.hours', 'open hours')
: t('crossAdd.rebills', 'open re-bills');
const primaryOnlyLabel = primary === 'hours'
? t('crossAdd.hoursOnly', 'Just the hours')
: t('crossAdd.rebillsOnly', 'Just the re-bills');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => !busy && onClose()}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-md mx-4 p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold mb-2 text-lg text-neutral-900 dark:text-neutral-100">
{t('crossAdd.title', 'Add other open items?')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('crossAdd.body',
'This customer also has {{count}} {{label}}. Add them to the same invoice? They stay as a separate group — hours and re-bills are never mixed into one line.',
{ count: otherCount, label: otherLabel })}
</p>
<div className="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-between gap-2">
<Button variant="ghost" disabled={busy} onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<div className="flex flex-col-reverse sm:flex-row gap-2">
<Button variant="outline" disabled={busy} onClick={() => onConfirm(false)}>{primaryOnlyLabel}</Button>
<Button disabled={busy} onClick={() => onConfirm(true)}>{t('crossAdd.addBoth', 'Add both')}</Button>
</div>
</div>
</div>
</div>
);
};
@@ -11,16 +11,21 @@
* Lives as a separate component so CustomerDetailPage doesn't need to
* know about CRM types; the panels handle their own data fetching.
*/
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { FileText, Plus, Receipt, ScrollText, Repeat2, AlertTriangle } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePermission } from '../../hooks/usePermission';
import { quotesService } from '../../services/quotes.service';
import { billsService, isDraftInvoice } from '../../services/bills.service';
import { contractsService } from '../../services/contracts.service';
import { accountingService, type CustomerRebillItem } from '../../services/accounting.service';
import { customerAdminService } from '../../services/customerAdmin.service';
import { CrossAddInvoiceDialog } from './CrossAddInvoiceDialog';
import { formatMoney } from './LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -36,6 +41,7 @@ export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
{flags.incomingInvoices && <RebillsPanel customerAccountId={customerAccountId} />}
</>
);
};
@@ -225,3 +231,167 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
</Card>
);
};
// ── Re-bills / passthrough panel (issue #866, Feature 2) ─────────────────────
// History-only, mirrors the Hours section pattern: grouped by derived status
// (Open / Sent / Paid) with a "Create invoice from re-bills" button up top for
// the open pool. When the customer also has open hours, the button opens the
// cross-add dialog first (Feature 3).
const REBILL_STATUS_ORDER: Array<CustomerRebillItem['status']> = ['open', 'sent', 'paid'];
const RebillsPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { format: fmtDate } = useLocalizedDate();
const navigate = useNavigate();
const qc = useQueryClient();
// Permission gating (#866 review). The endpoints require, respectively:
// view the panel → accounting.view
// "Create invoice from re-bills" (billPendingRebills) → accounting.manage
// cross-add "Add both" (billCombined) → customers.edit
const canView = usePermission('accounting.view');
const canManage = usePermission('accounting.manage');
const canCombine = usePermission('customers.edit');
const [crossAddOpen, setCrossAddOpen] = useState(false);
const [busy, setBusy] = useState(false);
const { data: items = [], isLoading } = useQuery({
queryKey: ['customer-rebills', customerAccountId],
queryFn: () => accountingService.listCustomerRebills(customerAccountId),
enabled: canView,
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.
const { data: openHours = 0 } = useQuery({
queryKey: ['customer-open-hours-count', customerAccountId],
queryFn: async () => (await customerAdminService.listHourEntries(customerAccountId, 'unbilled')).length,
enabled: !!flags.hoursLogging && canCombine,
staleTime: 30_000,
});
const openItems = items.filter((r) => r.status === 'open');
const onSuccess = (invoiceId: number, msg: string) => {
toast.success(msg);
qc.invalidateQueries({ queryKey: ['customer-rebills', customerAccountId] });
qc.invalidateQueries({ queryKey: ['customer-invoices', customerAccountId] });
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerAccountId] });
qc.invalidateQueries({ queryKey: ['customer-open-hours-count', customerAccountId] });
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
};
const runBill = async (includeHours: boolean) => {
setBusy(true);
try {
if (includeHours) {
const { invoiceId } = await customerAdminService.billCombined(customerAccountId, { includeHours: true, includeRebills: true });
onSuccess(invoiceId, t('rebills.toast.billedCombined', 'Invoice created from re-bills and hours.'));
} else {
const { invoiceId } = await accountingService.billPendingRebills(customerAccountId);
onSuccess(invoiceId, t('rebills.toast.billed', 'Invoice created from re-bills.'));
}
setCrossAddOpen(false);
} catch (e: any) {
toast.error(e?.response?.data?.error || t('rebills.toast.billFailed', 'Failed to create invoice'));
} finally {
setBusy(false);
}
};
const handleCreateInvoice = () => {
// Offer to fold in open hours only when the customer has both AND the admin
// can create the combined invoice (customers.edit); otherwise bill the
// re-bills directly.
if (openHours > 0 && canCombine) setCrossAddOpen(true);
else runBill(false);
};
// No accounting.view → don't render an empty card (query is disabled too).
if (!canView) return null;
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Repeat2 className="w-5 h-5" /> {t('customers.detail.rebillsSection', 'Re-bills & passthrough')}
</h2>
{openItems.length > 0 && canManage && (
<Button size="sm" disabled={busy} onClick={handleCreateInvoice}>
<Plus className="w-4 h-4 mr-1" />{t('rebills.createInvoice', 'Create invoice from re-bills')}
</Button>
)}
</div>
{isLoading ? <Loading /> : items.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noRebills', 'No re-billed or passed-through supplier invoices for this customer yet.')}
</p>
) : (
<div className="space-y-4">
{REBILL_STATUS_ORDER.map((status) => {
const group = items.filter((r) => r.status === status);
if (group.length === 0) return null;
return (
<div key={status}>
<h3 className="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-1">
{t(`rebills.status.${status}`, status)} · {group.length}
</h3>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{group.map((r) => (
<li key={r.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm text-neutral-900 dark:text-neutral-100 truncate">
{r.supplierName || t('rebills.unknownSupplier', 'Supplier')}
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{r.mode === 'passthrough' ? t('rebills.mode.passthrough', 'Passthrough') : t('rebills.mode.rebill', 'Re-bill')}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate">
{r.date ? fmtDate(r.date) : ''}
{r.eventName ? ` · ${r.eventName}` : ''}
{r.invoiceNumber ? (
<>
{' · '}
<Link to={`/admin/clients/bills/${r.invoiceId}`} className="hover:underline font-mono">{r.invoiceNumber}</Link>
</>
) : ''}
</div>
{r.proofAttachError && (
<div className="mt-0.5 flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3 shrink-0" />
{t('rebills.proofError', 'Proof not attached: {{err}}', { err: r.proofAttachError })}
</div>
)}
</div>
<div className="text-right shrink-0">
<div className="text-sm tabular-nums text-neutral-900 dark:text-neutral-100">
{formatMoney(r.rebilledMinor / 100, r.currency)}
</div>
{r.rebilledMinor !== r.costMinor && (
<div className="text-xs text-neutral-400 dark:text-neutral-500 tabular-nums">
{t('rebills.costLabel', 'cost {{amount}}', { amount: formatMoney(r.costMinor / 100, r.currency) })}
</div>
)}
</div>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
<CrossAddInvoiceDialog
open={crossAddOpen}
primary="rebills"
otherCount={openHours}
busy={busy}
onConfirm={runBill}
onClose={() => setCrossAddOpen(false)}
/>
</Card>
);
};
+64 -17
View File
@@ -22,10 +22,14 @@ import { Button, Card, LocalizedDateInput, TimeField } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { accountingService } from '../../services/accounting.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePermission } from '../../hooks/usePermission';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { ProjectSelect } from './ProjectSelect';
import { CrossAddInvoiceDialog } from './CrossAddInvoiceDialog';
export interface HoursSectionProps {
customerId: number;
@@ -49,6 +53,9 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
const { t } = useTranslation();
const qc = useQueryClient();
const navigate = useNavigate();
const { flags } = useFeatureFlags();
// Billing hours (and the combined path) go through customers.edit (#866 review).
const canBill = usePermission('customers.edit');
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00');
@@ -159,20 +166,51 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
errorMessage: 'Failed to delete entry',
});
const billMutation = useMutation({
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
onSuccess: ({ invoiceId }) => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
// Open the new scheduled invoice so the admin can add other line
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
},
// 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.
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,
staleTime: 30_000,
});
const [crossAddOpen, setCrossAddOpen] = useState(false);
const [billBusy, setBillBusy] = useState(false);
const onBilled = (invoiceId: number, msg: string) => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
qc.invalidateQueries({ queryKey: ['customer-rebills', customerId] });
qc.invalidateQueries({ queryKey: ['customer-open-rebills-count', customerId] });
toast.success(msg);
// Open the new scheduled invoice so the admin can add other line
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
};
const runBill = async (includeRebills: boolean) => {
setBillBusy(true);
try {
if (includeRebills) {
const { invoiceId } = await customerAdminService.billCombined(customerId, { includeHours: true, includeRebills: true });
onBilled(invoiceId, t('customers.hours.toast.billedCombined', 'Invoice created from hours and re-bills.'));
} else {
const { invoiceId } = await customerAdminService.billUnbilledHourEntries(customerId);
onBilled(invoiceId, t('customers.hours.toast.billed', 'Hours billed'));
}
setCrossAddOpen(false);
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
} finally {
setBillBusy(false);
}
};
const handleBillHours = () => {
// Offer to fold in open re-bills when the customer has both (Feature 3).
if (openRebills > 0) setCrossAddOpen(true);
else runBill(false);
};
// Single pass — both the count and the money total live behind the
// same filter. Memoised so a parent re-render (e.g. the
@@ -383,7 +421,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
{/* Bill-these-hours button for per-event customers only. Stays
visible in compact mode so the customer-detail page can
still trigger the on-demand billing action. */}
{!isMonthly && unbilledCount > 0 && (
{!isMonthly && unbilledCount > 0 && canBill && (
<div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3">
<span className="text-sm">
{t('customers.hours.unbilledCount',
@@ -395,15 +433,24 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
</span>
<Button
variant="primary"
disabled={billMutation.isPending}
isLoading={billMutation.isPending}
onClick={() => billMutation.mutate()}
disabled={billBusy}
isLoading={billBusy}
onClick={handleBillHours}
>
{t('customers.hours.billButton', 'Create draft invoice')}
</Button>
</div>
)}
<CrossAddInvoiceDialog
open={crossAddOpen}
primary="hours"
otherCount={openRebills}
busy={billBusy}
onConfirm={runBill}
onClose={() => setCrossAddOpen(false)}
/>
{/* Entry list table. */}
{isLoading ? (
<p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p>