fix(invoices): badge held (unsent, no send date) invoices as "Draft"
The earlier change only relabeled is_monthly_draft rows. But a per-event invoice created from hours is status 'scheduled' with scheduled_send_at = NULL and is_monthly_draft = false — it never auto-ships (the scheduler only picks rows with scheduled_send_at <= now), yet it still read "Scheduled" on the customer panel + lists. Add a shared isDraftInvoice() helper (scheduled && no send date, or a monthly/manual accumulator) and use it for the badge in the Bills list, the invoice detail header, and the customer profile's invoice panel. A scheduled invoice WITH a future send date keeps "Scheduled".
This commit is contained in:
@@ -19,7 +19,7 @@ import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
|
|||||||
import { Card, Button, Loading } from '../common';
|
import { Card, Button, Loading } from '../common';
|
||||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||||
import { quotesService } from '../../services/quotes.service';
|
import { quotesService } from '../../services/quotes.service';
|
||||||
import { billsService } from '../../services/bills.service';
|
import { billsService, isDraftInvoice } from '../../services/bills.service';
|
||||||
import { contractsService } from '../../services/contracts.service';
|
import { contractsService } from '../../services/contracts.service';
|
||||||
import { formatMoney } from './LineItemsTable';
|
import { formatMoney } from './LineItemsTable';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
@@ -204,14 +204,20 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm tabular-nums">{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}</span>
|
<span className="text-sm tabular-nums">{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}</span>
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
{isDraftInvoice(inv) ? (
|
||||||
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
|
||||||
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
|
{t('bills.status.draft', 'Draft')}
|
||||||
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
|
</span>
|
||||||
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
|
) : (
|
||||||
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
: 'bg-amber-100 text-amber-800'
|
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
||||||
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
|
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
|
||||||
|
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
|
||||||
|
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
|
||||||
|
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
|
||||||
|
: 'bg-amber-100 text-amber-800'
|
||||||
|
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react';
|
import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react';
|
||||||
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
|
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
|
||||||
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
||||||
import { billsService } from '../../../services/bills.service';
|
import { billsService, isDraftInvoice } from '../../../services/bills.service';
|
||||||
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -264,10 +264,10 @@ export const BillDetailPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded bg-neutral-100 text-neutral-700">
|
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded bg-neutral-100 text-neutral-700">
|
||||||
{/* A running monthly/manual accumulator carries status
|
{/* Held invoice ('scheduled' with no send date, incl. the
|
||||||
'scheduled' but never auto-sends on manual cadence —
|
monthly/manual accumulator) never auto-ships — read it as
|
||||||
read it as "Draft", matching the Bills list. */}
|
"Draft", matching the Bills list. */}
|
||||||
{inv.isMonthlyDraft ? t('bills.status.draft', 'Draft') : t(`bills.status.${inv.status}`, inv.status)}
|
{isDraftInvoice(inv) ? t('bills.status.draft', 'Draft') : t(`bills.status.${inv.status}`, inv.status)}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Search, Upload, X } from 'lucide-react';
|
import { Plus, Search, Upload, X } from 'lucide-react';
|
||||||
import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service';
|
import { billsService, isDraftInvoice, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service';
|
||||||
import { Button, Card, Input, Loading, LocalizedDateInput, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
|
import { Button, Card, Input, Loading, LocalizedDateInput, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
|
||||||
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||||
@@ -189,10 +189,10 @@ export const BillsListPage: React.FC = () => {
|
|||||||
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
|
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{inv.isMonthlyDraft ? (
|
{isDraftInvoice(inv) ? (
|
||||||
// Running accumulator draft (manual/monthly). It carries
|
// Held invoice: 'scheduled' with no send date (incl. the
|
||||||
// status 'scheduled' but never auto-sends on manual cadence,
|
// monthly/manual accumulator) never auto-ships, so badge it
|
||||||
// so badge it honestly as "Draft" rather than "Scheduled".
|
// honestly as "Draft" rather than "Scheduled".
|
||||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
|
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
|
||||||
{t('bills.status.draft', 'Draft')}
|
{t('bills.status.draft', 'Draft')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ export interface InvoiceSummary {
|
|||||||
isMonthlyDraft?: boolean;
|
isMonthlyDraft?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "scheduled" invoice with no send date is HELD — the scheduler only
|
||||||
|
* picks up rows whose `scheduled_send_at <= now`, so a null send date
|
||||||
|
* means it never auto-ships and is waiting on the admin (Send now /
|
||||||
|
* Trigger invoice now). Those, plus monthly/manual accumulators, read as
|
||||||
|
* "Draft" everywhere instead of the misleading "Scheduled". A scheduled
|
||||||
|
* invoice WITH a future send date is genuinely scheduled and keeps that
|
||||||
|
* label.
|
||||||
|
*/
|
||||||
|
export function isDraftInvoice(inv: Pick<InvoiceSummary, 'status' | 'scheduledSendAt' | 'isMonthlyDraft'>): boolean {
|
||||||
|
if (inv.isMonthlyDraft) return true;
|
||||||
|
return inv.status === 'scheduled' && !inv.scheduledSendAt;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InvoiceDetail extends InvoiceSummary {
|
export interface InvoiceDetail extends InvoiceSummary {
|
||||||
netAmountMinor: number;
|
netAmountMinor: number;
|
||||||
vatRate: number | null;
|
vatRate: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user