chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts

Bundles several in-progress efforts that were sitting uncommitted:
- User queue-preferences (migration 087, API route, popover component)
- QBO invoice soft-delete (migration 088) and AR diagnostics route
- Dashboard/mobile engagement route and page adjustments
- Docker Compose log-rotation config
- One-off ticket/RMM investigation scripts (scripts/)
- Planning docs: phase verification/pattern notes, mobile shell design spec
- .gitignore: exclude local scratch financial/inventory data and Claude Code
  worktree/local-settings runtime state (never meant for version control)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
lorentz 2026-07-18 06:34:57 -04:00
parent b638189cb0
commit 672f17b7f9
35 changed files with 2801 additions and 92 deletions

View file

@ -5,7 +5,8 @@
* volumeByDay last 30 days, ticket creation count per day
* resolutionByDay last 30 days, mean resolution hours per day completed
* queueHeatmap open tickets grouped by (queue, priority)
* activeEngineers top engineers today by hours logged
* activeEngineers working engineers today (top N by hours), each with their per-ticket time entries
* ptoEngineers engineers whose only time today is on PTO/Vacation allocation codes
*
* All queries run in parallel. ~50 ms total against a warm DB.
*/
@ -18,6 +19,10 @@ import { getUserTimezone } from '@/lib/services/user-timezone';
const TREND_DAYS = 30;
const TOP_QUEUES = 10;
const TOP_ENGINEERS = 8;
// Autotask allocation codes treated as PTO/Vacation/time-off.
// These are the internal codes used at Wulf for non-billable time-off entries
// (vacation, personal day, etc.) — surfaced separately from working hours.
const PTO_ALLOCATION_CODE_IDS = [91206, 91207, 91209];
export async function GET() {
const { session, error } = await requireAuth();
@ -63,6 +68,7 @@ export async function GET() {
[tz],
),
// queueHeatmap: open-only counts (no day-boundary math) — tz does not apply.
// Filters out queues the calling user has hidden via /api/me/queue-preferences.
postgresClient.query<{
queue_id: number | null;
queue_label: string | null;
@ -77,29 +83,81 @@ export async function GET() {
LEFT JOIN queues q ON q.value = t.queue_id
WHERE t.completed_date IS NULL
AND (t.is_deleted = false OR t.is_deleted IS NULL)
AND (t.queue_id IS NULL OR t.queue_id NOT IN (
SELECT queue_id FROM user_queue_preferences WHERE user_id = $1
))
GROUP BY t.queue_id, q.label, t.priority
ORDER BY COUNT(*) DESC`,
[session!.user.id],
),
postgresClient.query<{
resource_id: string;
resource_name: string;
hours: string;
tickets_touched: string;
ticket_id: string | null;
ticket_number: string | null;
ticket_title: string | null;
ticket_description: string | null;
ticket_status_label: string | null;
ticket_hours: string | null;
is_pto: boolean;
pto_note: string | null;
}>(
`SELECT te.resource_id::text,
`WITH today_entries AS (
SELECT te.resource_id,
te.ticket_id,
te.hours_worked,
te.allocation_code_id,
te.notes,
te.title
FROM time_entries te
WHERE te.entry_date::date = (NOW() AT TIME ZONE $1)::date
AND te.hours_worked > 0
),
engineer_totals AS (
SELECT resource_id,
SUM(hours_worked) AS hours,
COUNT(DISTINCT ticket_id) FILTER (WHERE ticket_id IS NOT NULL) AS tickets_touched,
BOOL_OR(allocation_code_id = ANY($2::int[])) AS has_pto,
BOOL_OR(allocation_code_id IS NULL OR NOT (allocation_code_id = ANY($2::int[]))) AS has_work,
MAX(CASE WHEN allocation_code_id = ANY($2::int[])
THEN NULLIF(COALESCE(notes, title), '') END) AS pto_note
FROM today_entries
GROUP BY resource_id
),
ticket_totals AS (
SELECT te.resource_id,
te.ticket_id,
SUM(te.hours_worked) AS ticket_hours
FROM today_entries te
WHERE te.ticket_id IS NOT NULL
GROUP BY te.resource_id, te.ticket_id
)
SELECT et.resource_id::text,
COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''),
r.email,
'Resource ' || te.resource_id) AS resource_name,
SUM(te.hours_worked)::text AS hours,
COUNT(DISTINCT te.ticket_id)::text AS tickets_touched
FROM time_entries te
LEFT JOIN resources r ON r.id = te.resource_id
WHERE ((te.entry_date AT TIME ZONE 'UTC') AT TIME ZONE $1)::date = (NOW() AT TIME ZONE $1)::date
AND te.hours_worked > 0
GROUP BY te.resource_id, r.first_name, r.last_name, r.email
ORDER BY SUM(te.hours_worked) DESC
LIMIT ${TOP_ENGINEERS}`,
[tz],
'Resource ' || et.resource_id) AS resource_name,
et.hours::text AS hours,
et.tickets_touched::text AS tickets_touched,
tt.ticket_id::text AS ticket_id,
t.ticket_number,
t.title AS ticket_title,
t.description AS ticket_description,
s.label AS ticket_status_label,
tt.ticket_hours::text AS ticket_hours,
(et.has_pto AND NOT et.has_work) AS is_pto,
et.pto_note
FROM engineer_totals et
LEFT JOIN resources r ON r.id = et.resource_id
LEFT JOIN ticket_totals tt ON tt.resource_id = et.resource_id
LEFT JOIN tickets t ON t.id = tt.ticket_id
LEFT JOIN statuses s ON s.value = t.status
ORDER BY (et.has_pto AND NOT et.has_work) ASC,
et.hours DESC,
et.resource_id,
tt.ticket_hours DESC NULLS LAST`,
[tz, PTO_ALLOCATION_CODE_IDS],
),
]);
@ -129,6 +187,61 @@ export async function GET() {
return { queueId: q.id, queueLabel: q.label, total: q.total, byPriority: cells };
});
// Fold the joined engineer/ticket rows into per-engineer records.
type Ticket = {
id: string;
ticketNumber: string | null;
title: string | null;
description: string | null;
statusLabel: string | null;
hours: number;
};
type Engineer = {
resourceId: string;
name: string;
hours: number;
ticketsTouched: number;
tickets: Ticket[];
isPto: boolean;
ptoNote: string | null;
};
const engineerById = new Map<string, Engineer>();
for (const row of engineersRes.rows) {
let eng = engineerById.get(row.resource_id);
if (!eng) {
eng = {
resourceId: row.resource_id,
name: row.resource_name,
hours: Math.round(parseFloat(row.hours) * 10) / 10,
ticketsTouched: parseInt(row.tickets_touched, 10),
tickets: [],
isPto: row.is_pto,
ptoNote: row.pto_note,
};
engineerById.set(row.resource_id, eng);
}
if (row.ticket_id) {
eng.tickets.push({
id: row.ticket_id,
ticketNumber: row.ticket_number,
title: row.ticket_title,
description: row.ticket_description,
statusLabel: row.ticket_status_label,
hours: row.ticket_hours == null
? 0
: Math.round(parseFloat(row.ticket_hours) * 10) / 10,
});
}
}
const allEngineers = [...engineerById.values()];
const activeEngineers = allEngineers
.filter((e) => !e.isPto)
.sort((a, b) => b.hours - a.hours)
.slice(0, TOP_ENGINEERS);
const ptoEngineers = allEngineers
.filter((e) => e.isPto)
.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({
volumeByDay: volumeRes.rows.map((r) => ({
date: r.d,
@ -139,11 +252,7 @@ export async function GET() {
avgHours: r.avg_hours == null ? null : Math.round(parseFloat(r.avg_hours) * 10) / 10,
})),
queueHeatmap: heatmap,
activeEngineers: engineersRes.rows.map((r) => ({
resourceId: r.resource_id,
name: r.resource_name,
hours: Math.round(parseFloat(r.hours) * 10) / 10,
ticketsTouched: parseInt(r.tickets_touched, 10),
})),
activeEngineers,
ptoEngineers,
});
}

View file

@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import { postgresClient } from '@/lib/services/postgres-client';
// GET /api/me/queue-preferences
// -> { queues: [{ id, label, hidden }], hiddenIds: number[] }
// Returns every active queue with a `hidden` flag for the calling user,
// plus the bare list of hidden queue IDs (for clients that only need that).
//
// PUT /api/me/queue-preferences
// body { hiddenIds: number[] } -> { hiddenIds: number[] }
// Replaces the user's hidden-queue set atomically (full set semantics).
//
// Per-user only — writes target session.user.id, never an id from the body.
export async function GET(): Promise<NextResponse> {
const { session, error } = await requireAuth();
if (error) return error;
try {
const result = await postgresClient.query<{
value: number;
label: string;
hidden: boolean;
}>(
`SELECT q.value, q.label,
(p.queue_id IS NOT NULL) AS hidden
FROM queues q
LEFT JOIN user_queue_preferences p
ON p.queue_id = q.value AND p.user_id = $1
WHERE q.is_active = true
AND (q.is_deleted = false OR q.is_deleted IS NULL)
ORDER BY q.label`,
[session!.user.id],
);
const queues = result.rows.map((r) => ({
id: r.value,
label: r.label,
hidden: r.hidden,
}));
const hiddenIds = queues.filter((q) => q.hidden).map((q) => q.id);
return NextResponse.json({ queues, hiddenIds });
} catch (e) {
console.error('GET /api/me/queue-preferences failed:', e);
return NextResponse.json(
{ error: 'Failed to read queue preferences', message: e instanceof Error ? e.message : 'unknown' },
{ status: 500 },
);
}
}
export async function PUT(request: NextRequest): Promise<NextResponse> {
const { session, error } = await requireAuth();
if (error) return error;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: 'Invalid JSON', message: 'Request body must be JSON' },
{ status: 400 },
);
}
const raw =
body && typeof body === 'object' && 'hiddenIds' in body
? (body as { hiddenIds: unknown }).hiddenIds
: undefined;
if (!Array.isArray(raw) || !raw.every((v) => Number.isInteger(v))) {
return NextResponse.json(
{ error: 'Invalid input', message: 'hiddenIds must be an array of integers' },
{ status: 400 },
);
}
const hiddenIds = [...new Set(raw as number[])];
const userId = session!.user.id;
try {
await postgresClient.transaction(async (client) => {
await client.query('DELETE FROM user_queue_preferences WHERE user_id = $1', [userId]);
if (hiddenIds.length > 0) {
const placeholders = hiddenIds.map((_, i) => `($1, $${i + 2})`).join(', ');
await client.query(
`INSERT INTO user_queue_preferences (user_id, queue_id) VALUES ${placeholders}
ON CONFLICT (user_id, queue_id) DO NOTHING`,
[userId, ...hiddenIds],
);
}
});
return NextResponse.json({ hiddenIds });
} catch (e) {
console.error('PUT /api/me/queue-preferences failed:', e);
return NextResponse.json(
{ error: 'Failed to update queue preferences', message: e instanceof Error ? e.message : 'unknown' },
{ status: 500 },
);
}
}

View file

@ -131,7 +131,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
FROM time_entries te
JOIN resources r ON r.id = te.resource_id AND (r.is_deleted = false OR r.is_deleted IS NULL)
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= (NOW() AT TIME ZONE $1) - INTERVAL '${interval}'
WHERE te.entry_date >= (NOW() AT TIME ZONE $1)::date - INTERVAL '${interval}'
AND (te.is_deleted = false OR te.is_deleted IS NULL)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'

View file

@ -56,18 +56,18 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
)::date AS day
),
daily_hours AS (
SELECT (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours
SELECT te.entry_date::date AS day, COALESCE(SUM(te.hours_worked), 0) AS hours
FROM time_entries te
JOIN resources r ON r.id = te.resource_id
AND (r.is_deleted = false OR r.is_deleted IS NULL)
JOIN graph_users gu ON LOWER(gu.email) = LOWER(r.email)
WHERE (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
AND (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date <= (NOW() AT TIME ZONE $1)::date
WHERE te.entry_date::date >= ((NOW() AT TIME ZONE $1) - INTERVAL '${days - 1} days')::date
AND te.entry_date::date <= (NOW() AT TIME ZONE $1)::date
AND (te.is_deleted = false OR te.is_deleted IS NULL)
AND gu.account_enabled = true
AND LOWER(gu.email) LIKE '%@wulfconsulting.%'
AND LOWER(gu.email) NOT LIKE '%#ext#%'
GROUP BY (te.entry_date AT TIME ZONE 'UTC' AT TIME ZONE $1)::date
GROUP BY te.entry_date::date
)
SELECT to_char(ds.day, 'YYYY-MM-DD') AS date,
COALESCE(dh.hours, 0)::numeric AS hours

View file

@ -13,11 +13,11 @@ export async function GET() {
if (error) return error;
const tz = getUserTimezone(session);
const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue] = await Promise.all([
const [summary, aging, topCustomers, overdueInvoices, recentPayments, monthlyRevenue, credits] = await Promise.all([
postgresClient.query(
`
SELECT
SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar,
SUM(balance) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_gross,
COUNT(*) FILTER (WHERE status IN ('Open','Overdue')) as total_ar_count,
SUM(balance) FILTER (WHERE status = 'Open') as current_balance,
COUNT(*) FILTER (WHERE status = 'Open') as current_count,
@ -26,6 +26,7 @@ export async function GET() {
SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('month', NOW() AT TIME ZONE $1)) as paid_mtd,
SUM(total_amt) FILTER (WHERE status = 'Paid' AND (txn_date AT TIME ZONE 'UTC' AT TIME ZONE $1) >= DATE_TRUNC('year', NOW() AT TIME ZONE $1)) as paid_ytd
FROM qbo_invoices
WHERE is_deleted = false
`,
[tz],
),
@ -41,22 +42,43 @@ export async function GET() {
SUM(balance) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as days_60_plus,
COUNT(*) FILTER (WHERE status = 'Overdue' AND due_date < (NOW() AT TIME ZONE $1)::date - 60) as cnt_60_plus
FROM qbo_invoices
WHERE is_deleted = false
`,
[tz],
),
// Top customers — net out any unapplied payment credits the customer is
// sitting on, so the number matches QBO's customer A/R view.
postgresClient.query(`
SELECT customer_ref_name, SUM(balance) as balance, COUNT(*) as invoice_count
FROM qbo_invoices
WHERE status IN ('Open','Overdue')
GROUP BY customer_ref_name
ORDER BY balance DESC LIMIT 8
WITH inv AS (
SELECT customer_ref_id, customer_ref_name,
SUM(balance) AS gross_balance,
COUNT(*) AS invoice_count
FROM qbo_invoices
WHERE is_deleted = false
AND status IN ('Open','Overdue')
GROUP BY customer_ref_id, customer_ref_name
),
cred AS (
SELECT customer_ref_id, SUM(unapplied_amt) AS unapplied
FROM qbo_payments
WHERE unapplied_amt > 0
GROUP BY customer_ref_id
)
SELECT inv.customer_ref_name,
GREATEST(inv.gross_balance - COALESCE(cred.unapplied, 0), 0) AS balance,
inv.invoice_count
FROM inv
LEFT JOIN cred ON cred.customer_ref_id = inv.customer_ref_id
ORDER BY balance DESC
LIMIT 8
`),
postgresClient.query(
`
SELECT id, doc_number, txn_date, due_date, customer_ref_name, total_amt, balance, status,
(NOW() AT TIME ZONE $1)::date - due_date::date as days_overdue
FROM qbo_invoices
WHERE status IN ('Open','Overdue')
WHERE is_deleted = false
AND status IN ('Open','Overdue')
ORDER BY status DESC, balance DESC LIMIT 30
`,
[tz],
@ -72,23 +94,39 @@ export async function GET() {
SELECT DATE_TRUNC('month', txn_date) as month,
SUM(total_amt) as revenue, COUNT(*) as invoice_count
FROM qbo_invoices
WHERE status = 'Paid' AND txn_date >= NOW() - INTERVAL '12 months'
WHERE is_deleted = false
AND status = 'Paid'
AND txn_date >= NOW() - INTERVAL '12 months'
GROUP BY 1 ORDER BY 1 ASC
`),
// Unapplied customer credits — payments not yet linked to an invoice.
// QBO nets these against A/R in its customer balance reports.
postgresClient.query(`
SELECT COALESCE(SUM(unapplied_amt), 0) AS total_unapplied
FROM qbo_payments
WHERE unapplied_amt > 0
`),
]);
const s = summary.rows[0];
const a = aging.rows[0];
const unappliedCredits = parseFloat(credits.rows[0]?.total_unapplied ?? 0);
const grossAr = parseFloat(s.total_ar_gross ?? 0);
// QBO's A/R reports net unapplied customer credits against the gross
// invoice balance. Mirror that so the dashboard headline matches QBO.
const netAr = Math.max(grossAr - unappliedCredits, 0);
return NextResponse.json({
summary: {
total_ar: parseFloat(s.total_ar ?? 0),
total_ar_count: parseInt(s.total_ar_count ?? 0),
current_balance: parseFloat(s.current_balance ?? 0),
current_count: parseInt(s.current_count ?? 0),
overdue_balance: parseFloat(s.overdue_balance ?? 0),
overdue_count: parseInt(s.overdue_count ?? 0),
paid_mtd: parseFloat(s.paid_mtd ?? 0),
paid_ytd: parseFloat(s.paid_ytd ?? 0),
total_ar: netAr,
total_ar_gross: grossAr,
unapplied_credits: unappliedCredits,
total_ar_count: parseInt(s.total_ar_count ?? 0),
current_balance: parseFloat(s.current_balance ?? 0),
current_count: parseInt(s.current_count ?? 0),
overdue_balance: parseFloat(s.overdue_balance ?? 0),
overdue_count: parseInt(s.overdue_count ?? 0),
paid_mtd: parseFloat(s.paid_mtd ?? 0),
paid_ytd: parseFloat(s.paid_ytd ?? 0),
},
aging: {
days_1_30: { balance: parseFloat(a.days_1_30 ?? 0), count: parseInt(a.cnt_1_30 ?? 0) },

View file

@ -0,0 +1,182 @@
/**
* GET /api/qbo/diagnose-ar
* One-off reconciliation diagnostic pulls QBO's Aged Receivable Detail
* report live and diffs it against Pulse's qbo_invoices to identify the
* gap between Pulse's headline A/R and what QBO reports.
*
* Returns per-invoice mismatches in three buckets:
* - in_qbo_not_in_pulse QBO knows about it, Pulse doesn't (sync miss)
* - in_pulse_not_in_qbo Pulse has live A/R for it, QBO doesn't
* (likely a tombstone candidate)
* - balance_diff both have it but the balance differs
*/
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
import { QboClient } from '@/lib/services/qbo-client';
// QBO report rows are deeply nested — walk them and yield every leaf "Data"
// row. Each leaf carries an array of column values matching the report's
// column header (Customer | Date | Transaction Type | Num | Due Date | Aging |
// Open Balance, etc.).
function* walkRows(node: unknown): Generator<{ values: string[]; group?: string }> {
if (!node || typeof node !== 'object') return;
const n = node as Record<string, unknown>;
if (Array.isArray(n.Row)) {
for (const child of n.Row as unknown[]) yield* walkRows(child);
return;
}
if (n.Rows) {
yield* walkRows(n.Rows);
return;
}
if (n.type === 'Data' && n.ColData && Array.isArray(n.ColData)) {
const values = (n.ColData as Array<{ value?: string }>).map((c) => c?.value ?? '');
yield { values, group: typeof n.group === 'string' ? n.group : undefined };
}
if (n.Header || n.Summary) {
// Section node — recurse into its rows
if (n.Rows) yield* walkRows(n.Rows);
}
}
export async function GET() {
const { error } = await requireAdmin();
if (error) return error;
const client = new QboClient();
const report = await client.getAgedReceivableDetail();
// Map columns by ColTitle so we don't rely on positional order
const cols: Array<{ ColTitle?: string; ColType?: string }> =
(report.Columns?.Column ?? []) as Array<{ ColTitle?: string; ColType?: string }>;
const idx = (title: string) => cols.findIndex((c) => (c.ColTitle ?? '').toLowerCase() === title.toLowerCase());
const iNum = idx('Num');
const iCust = idx('Customer');
const iBalance = idx('Open Balance');
const iType = idx('Transaction Type');
const iAging = idx('Aging');
type QboLine = {
docNumber: string;
customer: string;
type: string;
aging: string;
balance: number;
};
const qboInvoices: QboLine[] = [];
let qboTotal = 0;
for (const row of walkRows(report.Rows)) {
const v = row.values;
if (!v.length) continue;
const balanceStr = iBalance >= 0 ? v[iBalance] : '';
const balance = balanceStr ? parseFloat(balanceStr) : 0;
const docNumber = iNum >= 0 ? v[iNum] : '';
const customer = iCust >= 0 ? v[iCust] : '';
const type = iType >= 0 ? v[iType] : '';
const aging = iAging >= 0 ? v[iAging] : '';
// Skip total/summary rows (no doc number, no customer)
if (!docNumber && !customer) continue;
qboInvoices.push({ docNumber, customer, type, aging, balance });
qboTotal += balance;
}
const pulseRes = await postgresClient.query<{
id: string;
doc_number: string | null;
customer_ref_name: string | null;
balance: string;
status: string;
}>(
`SELECT id, doc_number, customer_ref_name, balance::text, status
FROM qbo_invoices
WHERE is_deleted = false
AND status IN ('Open','Overdue')
AND balance <> 0`,
);
const pulseByDoc = new Map<string, { id: string; customer: string; balance: number; status: string }>();
let pulseTotal = 0;
for (const r of pulseRes.rows) {
const balance = parseFloat(r.balance);
pulseTotal += balance;
if (r.doc_number) {
pulseByDoc.set(r.doc_number, {
id: r.id,
customer: r.customer_ref_name ?? '',
balance,
status: r.status,
});
}
}
// Diff
const inQboNotInPulse: QboLine[] = [];
const balanceDiff: Array<{
docNumber: string;
customer: string;
qboBalance: number;
pulseBalance: number;
delta: number;
}> = [];
const seenDocs = new Set<string>();
for (const inv of qboInvoices) {
if (!inv.docNumber) continue;
seenDocs.add(inv.docNumber);
const pulse = pulseByDoc.get(inv.docNumber);
if (!pulse) {
inQboNotInPulse.push(inv);
continue;
}
const delta = Math.round((inv.balance - pulse.balance) * 100) / 100;
if (Math.abs(delta) > 0.005) {
balanceDiff.push({
docNumber: inv.docNumber,
customer: inv.customer || pulse.customer,
qboBalance: inv.balance,
pulseBalance: pulse.balance,
delta,
});
}
}
const inPulseNotInQbo: Array<{
docNumber: string;
customer: string;
balance: number;
status: string;
}> = [];
for (const [docNumber, p] of pulseByDoc) {
if (!seenDocs.has(docNumber)) {
inPulseNotInQbo.push({
docNumber,
customer: p.customer,
balance: p.balance,
status: p.status,
});
}
}
const sumOf = <T extends { balance?: number; delta?: number }>(arr: T[], key: 'balance' | 'delta') =>
Math.round(arr.reduce((s, x) => s + (x[key] ?? 0), 0) * 100) / 100;
return NextResponse.json({
summary: {
qbo_aging_total: Math.round(qboTotal * 100) / 100,
pulse_gross_ar: Math.round(pulseTotal * 100) / 100,
qbo_invoice_count: qboInvoices.length,
pulse_invoice_count: pulseRes.rows.length,
gap: Math.round((pulseTotal - qboTotal) * 100) / 100,
},
differences: {
in_qbo_not_in_pulse: { count: inQboNotInPulse.length, sum: sumOf(inQboNotInPulse, 'balance'), rows: inQboNotInPulse },
in_pulse_not_in_qbo: { count: inPulseNotInQbo.length, sum: sumOf(inPulseNotInQbo, 'balance'), rows: inPulseNotInQbo },
balance_diff: { count: balanceDiff.length, sum: sumOf(balanceDiff, 'delta'), rows: balanceDiff },
},
report_meta: {
report_name: report.Header?.ReportName,
end_period: report.Header?.EndPeriod,
time: report.Header?.Time,
report_basis: report.Header?.ReportBasis,
},
});
}

View file

@ -40,7 +40,7 @@ export async function POST(request: NextRequest) {
export async function GET() {
try {
const [invoices, payments, deposits, transactions, reports] = await Promise.all([
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_invoices`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_invoices WHERE is_deleted = false`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_payments`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_deposits`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_transactions`),

View file

@ -23,6 +23,7 @@ import { KpiCard } from '@/components/dashboard/kpi-card';
import { VolumeTrend } from '@/components/dashboard/volume-trend';
import { ResolutionTrend } from '@/components/dashboard/resolution-trend';
import { QueueHeatmap } from '@/components/dashboard/queue-heatmap';
import { QueuePreferencesPopover } from '@/components/dashboard/queue-preferences-popover';
import { ActiveEngineers } from '@/components/dashboard/active-engineers';
import {
RefreshCw,
@ -89,6 +90,32 @@ interface Trends {
name: string;
hours: number;
ticketsTouched: number;
tickets: Array<{
id: string;
ticketNumber: string | null;
title: string | null;
description: string | null;
statusLabel: string | null;
hours: number;
}>;
isPto: boolean;
ptoNote: string | null;
}>;
ptoEngineers: Array<{
resourceId: string;
name: string;
hours: number;
ticketsTouched: number;
tickets: Array<{
id: string;
ticketNumber: string | null;
title: string | null;
description: string | null;
statusLabel: string | null;
hours: number;
}>;
isPto: boolean;
ptoNote: string | null;
}>;
}
@ -270,11 +297,12 @@ export default function DashboardPage() {
{/* QUEUE POSTURE ------------------------------------------------ */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<Card className="lg:col-span-8">
<CardHeader className="pb-3">
<CardHeader className="pb-3 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base flex items-center gap-2">
<Layers className="h-4 w-4" />
Queue posture
</CardTitle>
<QueuePreferencesPopover onSaved={load} />
</CardHeader>
<CardContent>
{!trends ? (
@ -295,7 +323,10 @@ export default function DashboardPage() {
{!trends ? (
<SkeletonChart height={196} />
) : (
<ActiveEngineers data={trends.activeEngineers} />
<ActiveEngineers
working={trends.activeEngineers}
pto={trends.ptoEngineers}
/>
)}
</CardContent>
</Card>

View file

@ -26,6 +26,7 @@ interface AgingBucket { balance: number; count: number; }
interface FinanceData {
summary: {
total_ar: number; total_ar_count: number;
total_ar_gross?: number; unapplied_credits?: number;
current_balance: number; current_count: number;
overdue_balance: number; overdue_count: number;
paid_mtd: number; paid_ytd: number;
@ -178,7 +179,11 @@ export default function MobileFinance() {
<KpiCardMobile
label="TOTAL AR"
value={fmt$(summary.total_ar)}
caption={`${summary.total_ar_count} open invoices`}
caption={
summary.unapplied_credits && summary.unapplied_credits > 0
? `${summary.total_ar_count} open · ${fmt$(summary.unapplied_credits)} credits`
: `${summary.total_ar_count} open invoices`
}
/>
<KpiCardMobile
label="CURRENT"