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:
parent
b638189cb0
commit
672f17b7f9
35 changed files with 2801 additions and 92 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
101
app/api/me/queue-preferences/route.ts
Normal file
101
app/api/me/queue-preferences/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.%'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
182
app/api/qbo/diagnose-ar/route.ts
Normal file
182
app/api/qbo/diagnose-ar/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue