Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3c9880b
ui: compact data tables with one header style
IAmJSD Oct 29, 2026
7657dfd
ui: keep table selection and action columns in view
IAmJSD Oct 29, 2026
eaabe91
ui: sticky column shadows use colour tokens
IAmJSD Oct 29, 2026
6b9e3e7
ui: one spelling for payment, term, provider and money in tables
IAmJSD Oct 29, 2026
db9c57d
ui: RI exchange notices as callouts, compact Opportunities plan button
IAmJSD Oct 29, 2026
eb8ef40
ui: fit Opportunities at 1920px with compact headers
IAmJSD Oct 29, 2026
0768223
ui: keep Opportunities header funnels inside their cell
IAmJSD Oct 29, 2026
d1c471a
ui: narrower planned purchase and history columns
IAmJSD Oct 29, 2026
d8b2972
ui: fit the RI reshape table at 1440px
IAmJSD Oct 29, 2026
857ac90
ui: don't pin wide approval queue and history actions on phones
IAmJSD Oct 29, 2026
863063f
ui: keep main's right-edge fade for the unpinned phone tables
IAmJSD Oct 29, 2026
58ea23b
ui: purchases tables as cards on phones
IAmJSD Oct 29, 2026
b0f7a91
ui: keep a header filter popover open while its button stays in view
IAmJSD Oct 30, 2026
2726300
Merge branch 'ui/sweep-tables-core' into ui/sweep-tables-mobile-cards
IAmJSD Oct 30, 2026
f7bc076
ui: keep a history filter button expanded across its table's re-render
IAmJSD Oct 30, 2026
3f66a30
test: anchorInView honours a scroller's vertical clip
IAmJSD Oct 30, 2026
1013a3b
Merge branch 'ui/sweep-tables-core' into ui/sweep-tables-mobile-cards
IAmJSD Oct 30, 2026
89b4b60
Merge origin/main into ui/sweep-tables-mobile-cards (after #1612 squash)
cristim Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 28 additions & 28 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,8 +683,8 @@ function providerCell(p: HistoryPurchase): string {
function upfrontCell(p: HistoryPurchase, amortize: boolean): string {
const amount = formatCurrency(p.upfront_cost);
const folded = amortize && p.monthly_cost != null && p.upfront_cost != null && p.upfront_cost > 0 && p.term > 0;
if (!folded) return `<td class="num">${amount}</td>`;
return `<td class="num upfront-in-monthly" title="Included in the amortized monthly cost">${amount}<div class="upfront-in-monthly-note">(in monthly)</div></td>`;
if (!folded) return `<td class="num" data-label="Upfront Cost">${amount}</td>`;
return `<td class="num upfront-in-monthly" data-label="Upfront Cost" title="Included in the amortized monthly cost">${amount}<div class="upfront-in-monthly-note">(in monthly)</div></td>`;
}

// canCancelPendingRow returns true when the current session is permitted
Expand Down Expand Up @@ -1408,18 +1408,18 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {
const dashOr = (html: string): string => (empty ? MUTED_DASH : html);
return `
<tr${execIdAttr}>
<td>${statusCell}</td>
<td>${formatDate(p.timestamp)}</td>
<td>${providerCell(p)}</td>
<td>${dashOr(escapeHtml(p.service))}</td>
<td>${empty ? NO_RECS_HTML : escapeHtml(p.resource_type)}</td>
<td>${dashOr(escapeHtml(p.region))}</td>
<td class="num">${dashOr(escapeHtml(String(p.count ?? '')))}</td>
<td>${termCell(p)}</td>
${empty ? `<td class="num">${MUTED_DASH}</td>` : upfrontCell(p, amortize)}
<td class="num">${dashOr(monthlyCostCell)}</td>
<td class="num savings">${dashOr(formatCurrency(p.estimated_savings))}</td>
<td>${planCellContent}</td>
<td data-label="Status">${statusCell}</td>
<td data-label="Date">${formatDate(p.timestamp)}</td>
<td data-label="Provider">${providerCell(p)}</td>
<td data-label="Service">${dashOr(escapeHtml(p.service))}</td>
<td data-label="Type">${empty ? NO_RECS_HTML : escapeHtml(p.resource_type)}</td>
<td data-label="Region">${dashOr(escapeHtml(p.region))}</td>
<td class="num" data-label="Count">${dashOr(escapeHtml(String(p.count ?? '')))}</td>
<td data-label="Term">${termCell(p)}</td>
${empty ? `<td class="num" data-label="Upfront Cost">${MUTED_DASH}</td>` : upfrontCell(p, amortize)}
<td class="num" data-label="${amortize ? 'Monthly Cost (amortized)' : 'Monthly Cost'}">${dashOr(monthlyCostCell)}</td>
<td class="num savings" data-label="Monthly Savings">${dashOr(formatCurrency(p.estimated_savings))}</td>
<td data-label="Plan">${planCellContent}</td>
</tr>
`;
}).join('');
Expand All @@ -1430,7 +1430,7 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {
);
const markup = `
${buildStatusChipRowHTML(purchases, activeStatusFilter)}
<table class="table-compact table-sticky-end">
<table class="table-compact table-sticky-end table-cards-mobile">
<thead>
<tr>
<th>Status</th>
Expand Down Expand Up @@ -2046,18 +2046,18 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtmlAttr(p.purchase_id)}"` : '';
return `
<tr${execIdAttr}>
<td>${formatDate(p.timestamp)}</td>
<td>${accountCell}</td>
<td>${providerCell(p)}</td>
<td>${empty ? NO_RECS_HTML : escapeHtml(p.service)}</td>
<td class="num">${dashOr(escapeHtml(String(p.count ?? '')))}</td>
<td>${termCell(p)}</td>
<td>${dashOr(paymentCell)}</td>
<td class="num">${dashOr(monthlyCostCell)}</td>
${empty ? `<td class="num">${MUTED_DASH}</td>` : upfrontCell(p, amortize)}
<td class="num savings">${dashOr(formatCurrency(p.estimated_savings))}</td>
<td>${createdBy}</td>
<td>${actionsCell}</td>
<td data-label="Date">${formatDate(p.timestamp)}</td>
<td data-label="Account">${accountCell}</td>
<td data-label="Provider">${providerCell(p)}</td>
<td data-label="Service">${empty ? NO_RECS_HTML : escapeHtml(p.service)}</td>
<td class="num" data-label="Count">${dashOr(escapeHtml(String(p.count ?? '')))}</td>
<td data-label="Term">${termCell(p)}</td>
<td data-label="Payment">${dashOr(paymentCell)}</td>
<td class="num" data-label="${amortize ? 'Monthly Cost (amortized)' : 'Monthly Cost'}">${dashOr(monthlyCostCell)}</td>
${empty ? `<td class="num" data-label="Upfront Cost">${MUTED_DASH}</td>` : upfrontCell(p, amortize)}
<td class="num savings" data-label="Monthly Savings">${dashOr(formatCurrency(p.estimated_savings))}</td>
<td data-label="Created By">${createdBy}</td>
<td data-label="Actions">${actionsCell}</td>
</tr>
`;
}).join('');
Expand All @@ -2069,7 +2069,7 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
col, APPROVAL_QUEUE_LABELS[col], colFilters[col] != null, APPROVAL_QUEUE_FILTER_SCOPE,
);
container.innerHTML = `
<table class="approval-queue-table table-compact table-sticky-end">
<table class="approval-queue-table table-compact table-sticky-end table-cards-mobile">
<thead>
<tr>
<th>Date</th>
Expand Down
118 changes: 118 additions & 0 deletions frontend/src/styles/tables.css
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,121 @@ th .history-column-filter-btn:focus-visible {
.savings-stripe-swatch--medium {
background: var(--cudly-warn);
}

/* Phones: the approval queue and purchase history (.table-cards-mobile)
* turn each row into a card of label / value lines (labels from the
* cells' data-label), so nothing hides behind a sideways scroll. The
* header row stays as a wrap of chips for the columns that have a filter
* funnel, so filtering still works; the other header cells are hidden. */
@media (max-width: 640px) {
.table-cards-mobile,
.table-cards-mobile thead,
.table-cards-mobile tbody,
.table-cards-mobile tr,
.table-cards-mobile td {
display: block;
}

.table-cards-mobile {
margin-top: var(--cudly-sp-2);
background: transparent;
box-shadow: none;
border-radius: 0;
overflow: visible;
}

.table-cards-mobile thead tr {
display: flex;
flex-wrap: wrap;
gap: var(--cudly-sp-1);
margin-bottom: var(--cudly-sp-2);
}

.table-cards-mobile thead tr:hover {
background: transparent;
}

.table-cards-mobile thead th {
display: inline-flex;
align-items: center;
position: static;
padding: 0 0 0 var(--cudly-sp-2);
border: 1px solid var(--cudly-border);
border-radius: var(--cudly-r-full);
background: var(--cudly-surface);
}

.table-cards-mobile thead th:not(:has(button)) {
display: none;
}

/* Each card is a three-column grid of label-over-value fields; long
* values (account, creator, type) take two columns. */
.table-cards-mobile.table-compact tbody tr {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-flow: row dense;
gap: var(--cudly-sp-2) var(--cudly-sp-3);
margin-bottom: var(--cudly-sp-2);
padding: var(--cudly-sp-3);
border: 1px solid var(--cudly-border);
border-radius: var(--cudly-r-md);
background: var(--cudly-surface);
}

.table-cards-mobile.table-compact tbody td {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
position: static;
min-width: 0;
overflow-wrap: anywhere;
padding: 0;
border: 0;
background: transparent;
text-align: left;
white-space: normal;
}

.table-cards-mobile.table-compact tbody td::before {
content: attr(data-label);
position: static;
width: auto;
opacity: 1;
background: none;
max-width: 100%;
overflow: hidden;
color: var(--cudly-text-subtle);
font-size: var(--cudly-fs-xs);
text-overflow: ellipsis;
white-space: nowrap;
}

/* Status (history) leads the card and the actions / plan cell closes
* it, each across the full width. */
.table-cards-mobile.table-compact tbody td:is([data-label="Status"], :last-child) {
grid-column: 1 / -1;
}

.table-cards-mobile.table-compact tbody td:is([data-label="Account"], [data-label="Created By"], [data-label="Type"]) {
grid-column: span 2;
}

.table-cards-mobile.table-compact tbody td[data-label="Status"]::before {
content: none;
}

.table-cards-mobile.table-compact tbody td:last-child {
padding-top: var(--cudly-sp-2);
border-top: 1px solid var(--cudly-border);
}

.table-cards-mobile.table-compact tbody td:last-child::before {
content: none;
}

.table-cards-mobile .cell-truncate {
max-width: 100%;
}
}
Loading