mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-06 21:42:08 +00:00
Merge master into feature/boa-api-integration
Brings in dashboard UI improvements (drill-down modals, redesign, ACH reference labels) while preserving BoA OAuth integration code.
This commit is contained in:
commit
d065b320eb
2 changed files with 298 additions and 116 deletions
|
|
@ -81,6 +81,8 @@ export const handler = async (event) => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const row of normalizedRows) {
|
for (const row of normalizedRows) {
|
||||||
const checkNumber = (row["Check Number"] || "").trim();
|
const checkNumber = (row["Check Number"] || "").trim();
|
||||||
|
if (!checkNumber) continue;
|
||||||
|
|
||||||
const method = (row["Method"] || "").trim();
|
const method = (row["Method"] || "").trim();
|
||||||
let status = (row["Status"] || "").trim();
|
let status = (row["Status"] || "").trim();
|
||||||
const sendOn = (row["Send Payment On"] || "").trim();
|
const sendOn = (row["Send Payment On"] || "").trim();
|
||||||
|
|
@ -124,7 +126,7 @@ export const handler = async (event) => {
|
||||||
":payee": (row["Payee"] || "").trim(),
|
":payee": (row["Payee"] || "").trim(),
|
||||||
":check_number": checkNumber,
|
":check_number": checkNumber,
|
||||||
":invoice_numbers": (row["Invoice Numbers"] || "").trim(),
|
":invoice_numbers": (row["Invoice Numbers"] || "").trim(),
|
||||||
":send_payment_on": (row["Send Payment On"] || "").trim(),
|
":send_payment_on": sendOn,
|
||||||
":amount_usd": parseAmount(row["Amount in USD"]),
|
":amount_usd": parseAmount(row["Amount in USD"]),
|
||||||
":status": status,
|
":status": status,
|
||||||
":company_subsidiary": (row["Company/Subsidiary"] || "").trim(),
|
":company_subsidiary": (row["Company/Subsidiary"] || "").trim(),
|
||||||
|
|
@ -134,14 +136,14 @@ export const handler = async (event) => {
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
// Only process checks for CashPro
|
// Only process checks for CashPro
|
||||||
if (method !== "Check" || !checkNumber) continue;
|
if (method !== "Check") continue;
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
// New check → issue
|
// New check → issue
|
||||||
newChecks.push({
|
newChecks.push({
|
||||||
checkNumber,
|
checkNumber,
|
||||||
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
||||||
issueDate: toISODate(row["Send Payment On"] || ""),
|
issueDate: toISODate(sendOn),
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
cancelStatuses.includes(status.toLowerCase()) &&
|
cancelStatuses.includes(status.toLowerCase()) &&
|
||||||
|
|
@ -151,7 +153,7 @@ export const handler = async (event) => {
|
||||||
cancelChecks.push({
|
cancelChecks.push({
|
||||||
checkNumber,
|
checkNumber,
|
||||||
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
||||||
issueDate: toISODate(row["Send Payment On"] || ""),
|
issueDate: toISODate(sendOn),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,111 @@ async function getSlackToken() {
|
||||||
return cachedToken;
|
return cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Shared helpers used by both home view and modals ---
|
||||||
|
|
||||||
|
const formatCurrency = (value) =>
|
||||||
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
|
||||||
|
Number(value || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parseMDYLocal = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parts = String(value).split("/");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10));
|
||||||
|
if (!mm || !dd || !yyyy) return null;
|
||||||
|
return new Date(yyyy, mm - 1, dd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDisplayDate = (date) =>
|
||||||
|
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
|
const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"];
|
||||||
|
|
||||||
|
const ageBuckets = [
|
||||||
|
{ label: "0 – 15 days", min: 0, max: 15 },
|
||||||
|
{ label: "15 – 30 days", min: 15, max: 30 },
|
||||||
|
{ label: "30 – 45 days", min: 30, max: 45 },
|
||||||
|
{ label: "45 – 60 days", min: 45, max: 60 },
|
||||||
|
{ label: "60 – 90 days", min: 60, max: 90 },
|
||||||
|
{ label: "90+ days", min: 90, max: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
const byDate = (a, b) => {
|
||||||
|
const da = parseMDYLocal(a.send_payment_on);
|
||||||
|
const db = parseMDYLocal(b.send_payment_on);
|
||||||
|
return (da || 0) - (db || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Categorize payments into buckets ---
|
||||||
|
|
||||||
|
function categorizePayments(payments) {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
const scheduledChecks = [];
|
||||||
|
const scheduledACH = [];
|
||||||
|
const outstandingChecks = [];
|
||||||
|
|
||||||
|
for (const p of payments) {
|
||||||
|
if (p.method !== "ACH" && p.method !== "Check") continue;
|
||||||
|
const dt = parseMDYLocal(p.send_payment_on);
|
||||||
|
if (!dt) continue;
|
||||||
|
const status = (p.status || "").toLowerCase();
|
||||||
|
if (skipStatuses.includes(status)) continue;
|
||||||
|
|
||||||
|
if (dt >= today) {
|
||||||
|
if (p.method === "Check") scheduledChecks.push(p);
|
||||||
|
else scheduledACH.push(p);
|
||||||
|
} else if (p.method === "Check") {
|
||||||
|
outstandingChecks.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledChecks.sort(byDate);
|
||||||
|
scheduledACH.sort(byDate);
|
||||||
|
|
||||||
|
const bucketedOutstanding = ageBuckets.map((bucket) => {
|
||||||
|
const items = outstandingChecks.filter((p) => {
|
||||||
|
const age = daysSince(parseMDYLocal(p.send_payment_on));
|
||||||
|
return age >= bucket.min && age < bucket.max;
|
||||||
|
});
|
||||||
|
items.sort(byDate);
|
||||||
|
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
|
return { ...bucket, items, total };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main handler ---
|
||||||
|
|
||||||
export const handler = async (event) => {
|
export const handler = async (event) => {
|
||||||
const body = JSON.parse(event.body || "{}");
|
// Decode body — API Gateway may base64-encode it
|
||||||
|
let rawBody = event.body || "";
|
||||||
|
if (event.isBase64Encoded) {
|
||||||
|
rawBody = Buffer.from(rawBody, "base64").toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slack sends block_actions as form-encoded: payload=<JSON>
|
||||||
|
let body;
|
||||||
|
if (rawBody.startsWith("payload=")) {
|
||||||
|
body = JSON.parse(decodeURIComponent(rawBody.replace("payload=", "")));
|
||||||
|
} else {
|
||||||
|
body = JSON.parse(rawBody || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Slack URL verification challenge
|
// Handle Slack URL verification challenge
|
||||||
if (body.type === "url_verification") {
|
if (body.type === "url_verification") {
|
||||||
return { statusCode: 200, body: body.challenge };
|
return { statusCode: 200, body: body.challenge };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle button clicks → open modal
|
||||||
|
if (body.type === "block_actions") {
|
||||||
|
return handleBlockAction(body);
|
||||||
|
}
|
||||||
|
|
||||||
// Only process app_home_opened events
|
// Only process app_home_opened events
|
||||||
if (body.event?.type !== "app_home_opened") {
|
if (body.event?.type !== "app_home_opened") {
|
||||||
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
||||||
|
|
@ -34,32 +131,16 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
const userId = body.event.user;
|
const userId = body.event.user;
|
||||||
|
|
||||||
// Get metadata
|
|
||||||
const { Item: metadata } = await ddb.send(
|
const { Item: metadata } = await ddb.send(
|
||||||
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
|
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scan all payment items
|
const payments = await scanPayments();
|
||||||
const payments = [];
|
|
||||||
let lastKey;
|
|
||||||
do {
|
|
||||||
const result = await ddb.send(
|
|
||||||
new ScanCommand({
|
|
||||||
TableName: TABLE_NAME,
|
|
||||||
FilterExpression: "begins_with(pk, :prefix)",
|
|
||||||
ExpressionAttributeValues: { ":prefix": "payment#" },
|
|
||||||
ExclusiveStartKey: lastKey,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
payments.push(...result.Items);
|
|
||||||
lastKey = result.LastEvaluatedKey;
|
|
||||||
} while (lastKey);
|
|
||||||
|
|
||||||
if (!payments.length) {
|
if (!payments.length) {
|
||||||
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
|
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and publish the home view
|
|
||||||
const view = buildHomeView(payments, metadata);
|
const view = buildHomeView(payments, metadata);
|
||||||
const slackToken = await getSlackToken();
|
const slackToken = await getSlackToken();
|
||||||
|
|
||||||
|
|
@ -82,86 +163,158 @@ export const handler = async (event) => {
|
||||||
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildHomeView(payments, metadata) {
|
// --- Block action handler (button clicks) ---
|
||||||
const formatCurrency = (value) =>
|
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
|
|
||||||
Number(value || 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const parseMDYLocal = (value) => {
|
async function handleBlockAction(body) {
|
||||||
if (!value) return null;
|
const action = body.actions?.[0];
|
||||||
const parts = String(value).split("/");
|
if (!action) return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
||||||
if (parts.length !== 3) return null;
|
|
||||||
const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10));
|
|
||||||
if (!mm || !dd || !yyyy) return null;
|
|
||||||
return new Date(yyyy, mm - 1, dd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDisplayDate = (date) =>
|
const triggerId = body.trigger_id;
|
||||||
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
const actionId = action.action_id;
|
||||||
|
|
||||||
const today = new Date();
|
const payments = await scanPayments();
|
||||||
today.setHours(0, 0, 0, 0);
|
const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } = categorizePayments(payments);
|
||||||
|
|
||||||
// Categorize payments
|
let modalTitle = "";
|
||||||
const scheduledChecks = [];
|
let items = [];
|
||||||
const scheduledACH = [];
|
|
||||||
const outstandingChecks = [];
|
|
||||||
|
|
||||||
const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"];
|
if (actionId.startsWith("view_scheduled_")) {
|
||||||
const ninetyDaysAgo = new Date(today);
|
const dateKey = actionId.replace("view_scheduled_checks_", "").replace("view_scheduled_ach_", "");
|
||||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
const method = actionId.includes("_checks_") ? "Check" : "ACH";
|
||||||
|
const source = method === "Check" ? scheduledChecks : scheduledACH;
|
||||||
|
|
||||||
for (const p of payments) {
|
items = source.filter((p) => {
|
||||||
if (p.method !== "ACH" && p.method !== "Check") continue;
|
const dt = parseMDYLocal(p.send_payment_on);
|
||||||
|
return dt && dt.toISOString().split("T")[0] === dateKey;
|
||||||
|
});
|
||||||
|
|
||||||
const dt = parseMDYLocal(p.send_payment_on);
|
const dt = items[0] ? parseMDYLocal(items[0].send_payment_on) : null;
|
||||||
if (!dt) continue;
|
modalTitle = dt ? formatDisplayDate(dt) : dateKey;
|
||||||
|
} else if (actionId.startsWith("view_outstanding_")) {
|
||||||
const status = (p.status || "").toLowerCase();
|
const bucketIdx = parseInt(actionId.replace("view_outstanding_", ""), 10);
|
||||||
if (skipStatuses.includes(status)) continue;
|
const bucket = bucketedOutstanding[bucketIdx];
|
||||||
|
if (bucket) {
|
||||||
if (dt >= today) {
|
items = bucket.items;
|
||||||
// Future — scheduled
|
modalTitle = bucket.label;
|
||||||
if (p.method === "Check") scheduledChecks.push(p);
|
|
||||||
else scheduledACH.push(p);
|
|
||||||
} else if (p.method === "Check") {
|
|
||||||
// Past-due check not cleared — outstanding
|
|
||||||
outstandingChecks.push(p);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by date
|
if (!items.length) {
|
||||||
const byDate = (a, b) => {
|
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
||||||
const da = parseMDYLocal(a.send_payment_on);
|
}
|
||||||
const db = parseMDYLocal(b.send_payment_on);
|
|
||||||
return (da || 0) - (db || 0);
|
|
||||||
};
|
|
||||||
scheduledChecks.sort(byDate);
|
|
||||||
scheduledACH.sort(byDate);
|
|
||||||
|
|
||||||
// Split outstanding into <=90 days and >90 days
|
const modalBlocks = buildPaymentListBlocks(items);
|
||||||
const outstanding90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) >= ninetyDaysAgo);
|
|
||||||
const outstandingOver90 = outstandingChecks.filter((p) => parseMDYLocal(p.send_payment_on) < ninetyDaysAgo);
|
|
||||||
|
|
||||||
const buildScheduledBlocks = (title, emoji, items) => {
|
const slackToken = await getSlackToken();
|
||||||
|
const res = await fetch("https://slack.com/api/views.open", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${slackToken}`,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
trigger_id: triggerId,
|
||||||
|
view: {
|
||||||
|
type: "modal",
|
||||||
|
title: { type: "plain_text", text: modalTitle.slice(0, 24) },
|
||||||
|
close: { type: "plain_text", text: "Close" },
|
||||||
|
blocks: modalBlocks,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
console.error("Slack views.open error:", JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Build modal payment list ---
|
||||||
|
|
||||||
|
function buildPaymentListBlocks(items) {
|
||||||
|
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
|
|
||||||
|
const blocks = [
|
||||||
|
{
|
||||||
|
type: "context",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)} total`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "divider" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of items) {
|
||||||
|
const dt = parseMDYLocal(p.send_payment_on);
|
||||||
|
const dateStr = dt ? formatDisplayDate(dt) : "N/A";
|
||||||
|
const payee = p.payee || "—";
|
||||||
|
const checkNum = p.check_number || "—";
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: "section",
|
||||||
|
fields: [
|
||||||
|
{ type: "mrkdwn", text: `*${payee}*\n${p.method === "ACH" ? "Reference" : "Check"} #${checkNum}` },
|
||||||
|
{ type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scan all payments from DynamoDB ---
|
||||||
|
|
||||||
|
async function scanPayments() {
|
||||||
|
const payments = [];
|
||||||
|
let lastKey;
|
||||||
|
do {
|
||||||
|
const result = await ddb.send(
|
||||||
|
new ScanCommand({
|
||||||
|
TableName: TABLE_NAME,
|
||||||
|
FilterExpression: "begins_with(pk, :prefix)",
|
||||||
|
ExpressionAttributeValues: { ":prefix": "payment#" },
|
||||||
|
ExclusiveStartKey: lastKey,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
payments.push(...result.Items);
|
||||||
|
lastKey = result.LastEvaluatedKey;
|
||||||
|
} while (lastKey);
|
||||||
|
return payments;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Build the App Home view ---
|
||||||
|
|
||||||
|
function buildHomeView(payments, metadata) {
|
||||||
|
const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments);
|
||||||
|
|
||||||
|
const buildScheduledBlocks = (title, emoji, items, methodKey) => {
|
||||||
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
|
|
||||||
const blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
type: "section",
|
type: "header",
|
||||||
text: {
|
text: { type: "plain_text", text: `${emoji} ${title}` },
|
||||||
type: "mrkdwn",
|
},
|
||||||
text: `*${emoji} ${title}*\n${items.length} payments • ${formatCurrency(total)}`,
|
{
|
||||||
},
|
type: "context",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)} total`,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "section",
|
type: "section",
|
||||||
text: { type: "mrkdwn", text: "_None_" },
|
text: { type: "mrkdwn", text: "_No upcoming payments_" },
|
||||||
});
|
});
|
||||||
blocks.push({ type: "divider" });
|
blocks.push({ type: "divider" });
|
||||||
return blocks;
|
return blocks;
|
||||||
|
|
@ -180,16 +333,20 @@ function buildHomeView(payments, metadata) {
|
||||||
const group = grouped[key];
|
const group = grouped[key];
|
||||||
const dayTotal = group.payments.reduce((sum, p) => sum + p.amount_usd, 0);
|
const dayTotal = group.payments.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
const dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown";
|
const dateLabel = group.date ? formatDisplayDate(group.date) : "Unknown";
|
||||||
|
const daysUntil = group.date ? Math.ceil((group.date - today) / (1000 * 60 * 60 * 24)) : null;
|
||||||
|
const daysTag = daysUntil === 0 ? ":rotating_light: _Today_" : daysUntil === 1 ? "_Tomorrow_" : daysUntil != null ? `_in ${daysUntil} days_` : "";
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "section",
|
type: "section",
|
||||||
fields: [
|
text: {
|
||||||
{ type: "mrkdwn", text: `*${dateLabel}*` },
|
type: "mrkdwn",
|
||||||
{
|
text: `*${dateLabel}* ${daysTag}\n${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`,
|
||||||
type: "mrkdwn",
|
},
|
||||||
text: `${group.payments.length} payments • ${formatCurrency(dayTotal)}`,
|
accessory: {
|
||||||
},
|
type: "button",
|
||||||
],
|
text: { type: "plain_text", text: "View" },
|
||||||
|
action_id: `view_scheduled_${methodKey}_${key}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,64 +359,87 @@ function buildHomeView(payments, metadata) {
|
||||||
|
|
||||||
const blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
type: "section",
|
type: "header",
|
||||||
text: {
|
text: { type: "plain_text", text: ":warning: Outstanding Checks" },
|
||||||
type: "mrkdwn",
|
},
|
||||||
text: `*:warning: Outstanding Checks*\n${outstandingChecks.length} checks • ${formatCurrency(totalAll)}`,
|
{
|
||||||
},
|
type: "context",
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `${outstandingChecks.length} check${outstandingChecks.length !== 1 ? "s" : ""} · ${formatCurrency(totalAll)} total`,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!outstandingChecks.length) {
|
if (!outstandingChecks.length) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "section",
|
type: "section",
|
||||||
text: { type: "mrkdwn", text: "_None_" },
|
text: { type: "mrkdwn", text: ":white_check_mark: _All checks have cleared_" },
|
||||||
});
|
});
|
||||||
blocks.push({ type: "divider" });
|
blocks.push({ type: "divider" });
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total90 = outstanding90.reduce((sum, p) => sum + p.amount_usd, 0);
|
for (let i = 0; i < bucketedOutstanding.length; i++) {
|
||||||
const totalOver90 = outstandingOver90.reduce((sum, p) => sum + p.amount_usd, 0);
|
const bucket = bucketedOutstanding[i];
|
||||||
|
if (!bucket.items.length) continue;
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "section",
|
type: "section",
|
||||||
fields: [
|
text: {
|
||||||
{ type: "mrkdwn", text: `*90 days or less*` },
|
type: "mrkdwn",
|
||||||
{ type: "mrkdwn", text: `${outstanding90.length} checks • ${formatCurrency(total90)}` },
|
text: `*${bucket.label}*\n${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`,
|
||||||
],
|
},
|
||||||
});
|
accessory: {
|
||||||
|
type: "button",
|
||||||
blocks.push({
|
text: { type: "plain_text", text: "View" },
|
||||||
type: "section",
|
action_id: `view_outstanding_${i}`,
|
||||||
fields: [
|
},
|
||||||
{ type: "mrkdwn", text: `*Over 90 days*` },
|
});
|
||||||
{ type: "mrkdwn", text: `${outstandingOver90.length} checks • ${formatCurrency(totalOver90)}` },
|
}
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
blocks.push({ type: "divider" });
|
blocks.push({ type: "divider" });
|
||||||
return blocks;
|
return blocks;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Summary bar
|
||||||
|
const totalScheduled = scheduledChecks.length + scheduledACH.length;
|
||||||
|
const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
|
const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "home",
|
type: "home",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: "header",
|
type: "header",
|
||||||
text: { type: "plain_text", text: "Payments Dashboard" },
|
text: { type: "plain_text", text: ":bank: Payments Dashboard" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "context",
|
type: "context",
|
||||||
elements: [
|
elements: [
|
||||||
{ type: "mrkdwn", text: `*Last updated:* ${metadata?.last_updated || "N/A"}` },
|
{ type: "mrkdwn", text: `Last updated · ${metadata?.last_updated || "N/A"}` },
|
||||||
{ type: "mrkdwn", text: `*Source:* ${metadata?.file_name || "N/A"}` },
|
{ type: "mrkdwn", text: `Source · ${metadata?.file_name || "N/A"}` },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
{ type: "divider" },
|
||||||
...buildScheduledBlocks("Scheduled Checks", ":calendar:", scheduledChecks),
|
{
|
||||||
...buildScheduledBlocks("Scheduled ACH", ":calendar:", scheduledACH),
|
type: "section",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `:calendar: *Scheduled*\n${totalScheduled} payments · ${formatCurrency(totalScheduledAmt)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: `:warning: *Outstanding*\n${outstandingChecks.length} checks · ${formatCurrency(totalOutstandingAmt)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "divider" },
|
||||||
|
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "checks"),
|
||||||
|
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "ach"),
|
||||||
...buildOutstandingBlocks(),
|
...buildOutstandingBlocks(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue