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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user