Add drill-down modals: View buttons open payment detail for each row

This commit is contained in:
Adam Moussa 2026-04-10 18:08:06 -04:00
parent 7f94eee308
commit 941cd094fc

View file

@ -19,14 +19,106 @@ async function getSlackToken() {
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) => {
const body = JSON.parse(event.body || "{}");
// Slack sends block_actions as form-encoded: payload=<JSON>
let body;
const rawBody = event.body || "";
if (rawBody.startsWith("payload=")) {
body = JSON.parse(decodeURIComponent(rawBody.replace("payload=", "")));
} else {
body = JSON.parse(rawBody || "{}");
}
// Handle Slack URL verification challenge
if (body.type === "url_verification") {
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
if (body.event?.type !== "app_home_opened") {
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
@ -34,32 +126,16 @@ export const handler = async (event) => {
const userId = body.event.user;
// Get metadata
const { Item: metadata } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
);
// Scan all payment items
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);
const payments = await scanPayments();
if (!payments.length) {
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
}
// Build and publish the home view
const view = buildHomeView(payments, metadata);
const slackToken = await getSlackToken();
@ -82,84 +158,136 @@ export const handler = async (event) => {
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};
function buildHomeView(payments, metadata) {
const formatCurrency = (value) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
Number(value || 0)
);
// --- Block action handler (button clicks) ---
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);
};
async function handleBlockAction(body) {
const action = body.actions?.[0];
if (!action) return { statusCode: 200, body: JSON.stringify({ ok: true }) };
const formatDisplayDate = (date) =>
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
const triggerId = body.trigger_id;
const actionId = action.action_id;
const today = new Date();
today.setHours(0, 0, 0, 0);
const payments = await scanPayments();
const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } = categorizePayments(payments);
const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24));
let modalTitle = "";
let items = [];
// Categorize payments
const scheduledChecks = [];
const scheduledACH = [];
const outstandingChecks = [];
if (actionId.startsWith("view_scheduled_")) {
const dateKey = actionId.replace("view_scheduled_checks_", "").replace("view_scheduled_ach_", "");
const method = actionId.includes("_checks_") ? "Check" : "ACH";
const source = method === "Check" ? scheduledChecks : scheduledACH;
const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"];
items = source.filter((p) => {
const dt = parseMDYLocal(p.send_payment_on);
return dt && dt.toISOString().split("T")[0] === dateKey;
});
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);
const dt = items[0] ? parseMDYLocal(items[0].send_payment_on) : null;
modalTitle = dt ? formatDisplayDate(dt) : dateKey;
} else if (actionId.startsWith("view_outstanding_")) {
const bucketIdx = parseInt(actionId.replace("view_outstanding_", ""), 10);
const bucket = bucketedOutstanding[bucketIdx];
if (bucket) {
items = bucket.items;
modalTitle = bucket.label;
}
}
// Sort by date
const byDate = (a, b) => {
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);
if (!items.length) {
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
}
// Age buckets for outstanding checks
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 modalBlocks = buildPaymentListBlocks(items);
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;
});
const total = items.reduce((sum, p) => sum + p.amount_usd, 0);
return { ...bucket, items, total };
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,
},
}),
});
// --- Block builders ---
const data = await res.json();
if (!data.ok) {
console.error("Slack views.open error:", JSON.stringify(data));
}
const buildScheduledBlocks = (title, emoji, items) => {
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}*\nCheck #${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 blocks = [
@ -205,13 +333,15 @@ function buildHomeView(payments, metadata) {
blocks.push({
type: "section",
fields: [
{ type: "mrkdwn", text: `*${dateLabel}* ${daysTag}` },
{
type: "mrkdwn",
text: `${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`,
},
],
text: {
type: "mrkdwn",
text: `*${dateLabel}* ${daysTag}\n${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`,
},
accessory: {
type: "button",
text: { type: "plain_text", text: "View" },
action_id: `view_scheduled_${methodKey}_${key}`,
},
});
}
@ -247,18 +377,21 @@ function buildHomeView(payments, metadata) {
return blocks;
}
for (const bucket of bucketedOutstanding) {
for (let i = 0; i < bucketedOutstanding.length; i++) {
const bucket = bucketedOutstanding[i];
if (!bucket.items.length) continue;
blocks.push({
type: "section",
fields: [
{ type: "mrkdwn", text: `*${bucket.label}*` },
{
type: "mrkdwn",
text: `${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`,
},
],
text: {
type: "mrkdwn",
text: `*${bucket.label}*\n${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`,
},
accessory: {
type: "button",
text: { type: "plain_text", text: "View" },
action_id: `view_outstanding_${i}`,
},
});
}
@ -266,7 +399,7 @@ function buildHomeView(payments, metadata) {
return blocks;
};
// --- Summary bar ---
// 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);
@ -300,8 +433,8 @@ function buildHomeView(payments, metadata) {
],
},
{ type: "divider" },
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks),
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH),
...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "checks"),
...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "ach"),
...buildOutstandingBlocks(),
],
};