Merge pull request #682 from Luca-Timo/fix/invoice-draft-and-nits
CRM: held invoices read as "Draft", + mark-paid / dashboard / reminder fixes
This commit is contained in:
@@ -439,6 +439,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||
const monthCutoff = new Date(now - 30 * DAY);
|
||||
const quarterCutoff = new Date(now - 90 * DAY);
|
||||
const yearCutoff = new Date(now - 365 * DAY);
|
||||
// Calendar year-to-date (Jan 1 of the current year, local time) —
|
||||
// the dashboard's revenue "year" tile can toggle between this and
|
||||
// the trailing-365-day window.
|
||||
const calendarYearCutoff = new Date(new Date(now).getFullYear(), 0, 1);
|
||||
|
||||
// ---- quotes: counts by status ---------------------------------
|
||||
let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 };
|
||||
@@ -459,6 +463,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||
let revenueMonthMinor = 0;
|
||||
let revenueQuarterMinor = 0;
|
||||
let revenueYearMinor = 0;
|
||||
let revenueCalendarYearMinor = 0;
|
||||
let outstandingTotalMinor = 0;
|
||||
let outstandingCount = 0;
|
||||
|
||||
@@ -504,9 +509,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||
.first();
|
||||
return Number(row?.total || 0);
|
||||
};
|
||||
revenueMonthMinor = await winSum(monthCutoff);
|
||||
revenueQuarterMinor = await winSum(quarterCutoff);
|
||||
revenueYearMinor = await winSum(yearCutoff);
|
||||
revenueMonthMinor = await winSum(monthCutoff);
|
||||
revenueQuarterMinor = await winSum(quarterCutoff);
|
||||
revenueYearMinor = await winSum(yearCutoff);
|
||||
revenueCalendarYearMinor = await winSum(calendarYearCutoff);
|
||||
|
||||
// Outstanding: every invoice that's been sent but not fully
|
||||
// paid (sent + overdue). Outstanding = total - paid. We sum
|
||||
@@ -568,9 +574,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||
quotes: quoteCounts,
|
||||
invoices: invoiceCounts,
|
||||
revenue: {
|
||||
monthMinor: revenueMonthMinor,
|
||||
quarterMinor: revenueQuarterMinor,
|
||||
yearMinor: revenueYearMinor,
|
||||
monthMinor: revenueMonthMinor,
|
||||
quarterMinor: revenueQuarterMinor,
|
||||
yearMinor: revenueYearMinor,
|
||||
calendarYearMinor: revenueCalendarYearMinor,
|
||||
},
|
||||
outstanding: {
|
||||
totalMinor: outstandingTotalMinor,
|
||||
|
||||
@@ -342,6 +342,7 @@ router.get(
|
||||
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
|
||||
query('includeDrafts').optional({ values: 'falsy' }).isBoolean(),
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']),
|
||||
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
|
||||
@@ -358,6 +359,9 @@ router.get(
|
||||
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
|
||||
sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null,
|
||||
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
|
||||
// Surface running monthly/manual accumulator drafts (hidden by
|
||||
// default per migration 128) when the Bills list explicitly asks.
|
||||
includeMonthlyDrafts: req.query.includeDrafts === 'true' || req.query.includeDrafts === true,
|
||||
q: req.query.q,
|
||||
},
|
||||
sort: req.query.sort || 'issue_desc',
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const emailProcessor = require('./emailProcessor');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -176,9 +177,9 @@ async function runEventReminderPass() {
|
||||
const now = new Date();
|
||||
const rows = await db('events')
|
||||
.whereNotNull('events.event_date')
|
||||
.where('events.is_active', true)
|
||||
.where('events.is_archived', false)
|
||||
.where('events.event_reminder_disabled', false)
|
||||
.where('events.is_active', formatBoolean(true))
|
||||
.where('events.is_archived', formatBoolean(false))
|
||||
.where('events.event_reminder_disabled', formatBoolean(false))
|
||||
.whereNull('events.event_reminder_sent_at')
|
||||
.where('events.event_date', '>=', now.toISOString().slice(0, 10))
|
||||
.select('events.*');
|
||||
@@ -328,7 +329,7 @@ async function resolveReminderRecipients(eventRow) {
|
||||
const assigned = await db('event_customer_assignments as a')
|
||||
.join('customer_accounts as c', 'c.id', 'a.customer_account_id')
|
||||
.where('a.event_id', eventRow.id)
|
||||
.where('c.is_active', true)
|
||||
.where('c.is_active', formatBoolean(true))
|
||||
.whereNotNull('c.email')
|
||||
.select('c.email');
|
||||
// De-dup emails defensively (a customer assigned twice, etc.).
|
||||
|
||||
@@ -50,6 +50,10 @@ export const CrmOverviewSection: React.FC = () => {
|
||||
// publicSettings finishes loading — shows everything, then settles.
|
||||
const showRevenue = publicSettings?.crm_overview_show_revenue !== false;
|
||||
const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false;
|
||||
// Revenue "year" tile toggles in place between the trailing-365-day
|
||||
// window and calendar year-to-date — keeps the dashboard to four
|
||||
// tiles instead of adding a fifth.
|
||||
const [revYearMode, setRevYearMode] = React.useState<'rolling' | 'calendar'>('rolling');
|
||||
const showQuotes = publicSettings?.crm_overview_show_quotes !== false;
|
||||
const showInvoices = publicSettings?.crm_overview_show_invoices !== false;
|
||||
// Compute which sub-sections actually render so we can skip the
|
||||
@@ -121,8 +125,15 @@ export const CrmOverviewSection: React.FC = () => {
|
||||
/>
|
||||
<StatCard
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
label={t('crmOverview.revenue.year', 'Revenue · last 365 days')}
|
||||
value={formatMoney(d.revenue.yearMinor, cur)}
|
||||
label={revYearMode === 'calendar'
|
||||
? t('crmOverview.revenue.yearCalendar', 'Revenue · this year')
|
||||
: t('crmOverview.revenue.year', 'Revenue · last 365 days')}
|
||||
value={formatMoney(
|
||||
revYearMode === 'calendar' ? d.revenue.calendarYearMinor : d.revenue.yearMinor,
|
||||
cur,
|
||||
)}
|
||||
sub={t('crmOverview.revenue.toggleHint', 'Tap to switch window')}
|
||||
onClick={() => setRevYearMode((m) => (m === 'rolling' ? 'calendar' : 'rolling'))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -249,8 +260,11 @@ interface StatCardProps {
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
to?: string;
|
||||
/** Makes the whole tile a button (mutually exclusive with `to`).
|
||||
* Used by the revenue tile to toggle its window in place. */
|
||||
onClick?: () => void;
|
||||
}
|
||||
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to }) => {
|
||||
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to, onClick }) => {
|
||||
const inner = (
|
||||
<Card padding="md" className="h-full">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -266,6 +280,13 @@ const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to }) => {
|
||||
if (to) {
|
||||
return <Link to={to} className="block hover:opacity-90 transition-opacity">{inner}</Link>;
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="block w-full text-left hover:opacity-90 transition-opacity">
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return inner;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
|
||||
import { Card, Button, Loading } from '../common';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
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 { formatMoney } from './LineItemsTable';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
@@ -204,14 +204,20 @@ const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
|
||||
</span>
|
||||
</div>
|
||||
<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 ${
|
||||
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
||||
: 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>
|
||||
{isDraftInvoice(inv) ? (
|
||||
<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')}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
||||
: 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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -448,12 +448,24 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{e.status === 'billed' ? (
|
||||
<span className="text-xs text-green-700 dark:text-green-300">
|
||||
{e.invoiceNumber
|
||||
? t('customers.hours.status.billedOn',
|
||||
'Billed: {{number}}', { number: e.invoiceNumber })
|
||||
: t('customers.hours.status.billed', 'Billed')}
|
||||
</span>
|
||||
e.invoiceId ? (
|
||||
// Link straight to the invoice so a "Billed: R-…" entry
|
||||
// is one click from its (possibly draft) invoice.
|
||||
<Link
|
||||
to={`/admin/clients/bills/${e.invoiceId}`}
|
||||
className="text-xs text-green-700 dark:text-green-300 underline hover:no-underline"
|
||||
>
|
||||
{e.invoiceNumber
|
||||
? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber })
|
||||
: t('customers.hours.status.billed', 'Billed')}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-green-700 dark:text-green-300">
|
||||
{e.invoiceNumber
|
||||
? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber })
|
||||
: t('customers.hours.status.billed', 'Billed')}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{t('customers.hours.status.unbilled', 'Unbilled')}
|
||||
|
||||
@@ -4666,6 +4666,7 @@
|
||||
"bills": {
|
||||
"status": {
|
||||
"scheduled": "Geplant",
|
||||
"draft": "Entwurf",
|
||||
"pending_delivery": "Wartet auf Lieferung",
|
||||
"sent": "Gesendet",
|
||||
"paid": "Bezahlt",
|
||||
@@ -4800,6 +4801,7 @@
|
||||
"notes": "Notizen",
|
||||
"methodPlaceholder": "Methode wählen…",
|
||||
"methods": {
|
||||
"bankTransfer": "Überweisung",
|
||||
"card": "Karte",
|
||||
"cash": "Bar",
|
||||
"paypal": "PayPal",
|
||||
|
||||
@@ -4789,6 +4789,7 @@
|
||||
"notes": "Notes",
|
||||
"methodPlaceholder": "Select method…",
|
||||
"methods": {
|
||||
"bankTransfer": "Bank transfer",
|
||||
"card": "Card",
|
||||
"cash": "Cash",
|
||||
"paypal": "PayPal",
|
||||
@@ -4810,6 +4811,7 @@
|
||||
},
|
||||
"status": {
|
||||
"scheduled": "Scheduled",
|
||||
"draft": "Draft",
|
||||
"pending_delivery": "Awaiting delivery",
|
||||
"sent": "Sent",
|
||||
"paid": "Paid",
|
||||
|
||||
@@ -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 { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
|
||||
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 { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -264,7 +264,10 @@ export const BillDetailPage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded bg-neutral-100 text-neutral-700">
|
||||
{t(`bills.status.${inv.status}`, inv.status)}
|
||||
{/* Held invoice ('scheduled' with no send date, incl. the
|
||||
monthly/manual accumulator) never auto-ships — read it as
|
||||
"Draft", matching the Bills list. */}
|
||||
{isDraftInvoice(inv) ? t('bills.status.draft', 'Draft') : t(`bills.status.${inv.status}`, inv.status)}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
@@ -523,6 +526,7 @@ export const BillDetailPage: React.FC = () => {
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
|
||||
>
|
||||
<option value="">{t('bills.payment.methodPlaceholder', 'Select method…')}</option>
|
||||
<option value="bank_transfer">{t('bills.payment.methods.bankTransfer', 'Bank transfer')}</option>
|
||||
<option value="cash">{t('bills.payment.methods.cash', 'Cash')}</option>
|
||||
<option value="card">{t('bills.payment.methods.card', 'Card')}</option>
|
||||
<option value="paypal">{t('bills.payment.methods.paypal', 'PayPal')}</option>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
@@ -47,6 +47,9 @@ export const BillsListPage: React.FC = () => {
|
||||
q: search || undefined,
|
||||
status: statusFilter.length ? statusFilter : undefined,
|
||||
unpaidOnly,
|
||||
// Surface the running monthly/manual accumulator drafts here (they're
|
||||
// badged "Draft"); they're hidden from pickers/sub-lists by default.
|
||||
includeDrafts: true,
|
||||
sort, page, pageSize: 25,
|
||||
}),
|
||||
});
|
||||
@@ -186,14 +189,23 @@ export const BillsListPage: React.FC = () => {
|
||||
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
||||
: 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>
|
||||
{isDraftInvoice(inv) ? (
|
||||
// Held invoice: 'scheduled' with no send date (incl. the
|
||||
// monthly/manual accumulator) never auto-ships, so badge it
|
||||
// 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">
|
||||
{t('bills.status.draft', 'Draft')}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
inv.status === 'paid' ? 'bg-green-100 text-green-800'
|
||||
: 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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -92,6 +92,24 @@ export interface InvoiceSummary {
|
||||
* (migration 111). Hide line-item editing on these rows; the
|
||||
* uploaded PDF is the source of truth. */
|
||||
isImported?: boolean;
|
||||
/** True for the running monthly/manual accumulator draft
|
||||
* (migration 128). Carries status 'scheduled' but never auto-sends
|
||||
* (manual) — shown with a "Draft" badge in the list. */
|
||||
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 {
|
||||
@@ -225,6 +243,10 @@ export const billsService = {
|
||||
sort?: InvoiceSort;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Include the running monthly/manual accumulator drafts that the
|
||||
* main list hides by default (migration 128). Only the Bills list
|
||||
* opts in; pickers/sub-lists leave it off. */
|
||||
includeDrafts?: boolean;
|
||||
} = {}): Promise<InvoiceListResponse> {
|
||||
const { data } = await api.get('/admin/invoices', {
|
||||
params: {
|
||||
@@ -378,6 +400,9 @@ export interface CrmOverviewStats {
|
||||
monthMinor: number;
|
||||
quarterMinor: number;
|
||||
yearMinor: number;
|
||||
/** Revenue since Jan 1 of the current year (calendar YTD). The
|
||||
* dashboard's "year" tile toggles between this and yearMinor. */
|
||||
calendarYearMinor: number;
|
||||
};
|
||||
outstanding: {
|
||||
totalMinor: number;
|
||||
|
||||
Reference in New Issue
Block a user