Fix: Improve the responsivity of the Reporting page (#6846)

This commit is contained in:
Nicolas Dorier
2025-07-17 13:26:27 +09:00
committed by GitHub
parent 9ef9ff7948
commit 96abeff86e
3 changed files with 207 additions and 119 deletions

View File

@@ -4,21 +4,34 @@
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model StoreReportsViewModel
@{
ViewData.SetActivePage(StoreNavPages.Reporting, StringLocalizer["Reporting"]);
Csp.UnsafeEval();
ViewData.SetActivePage(StoreNavPages.Reporting, StringLocalizer["Reporting"]);
Csp.UnsafeEval();
}
@section PageHeadContent
{
@* Set a height for the responsive table container to make it work with the fixed table headers.
Details described here: thttps://uxdesign.cc/position-stuck-96c9f55d9526 *@
<style>
#app .table-responsive { max-height: 80vh; }
#app #charts { gap: var(--btcpay-space-l) var(--btcpay-space-xxl); }
#app #charts article { flex: 1 1 450px; }
main .dropdown-menu.show { z-index: 99999; }
#app .table-responsive {
max-height: 80vh;
}
#app #charts {
gap: var(--btcpay-space-l) var(--btcpay-space-xxl);
}
#app #charts article {
flex: 1 1 450px;
}
main .dropdown-menu.show {
z-index: 99999;
}
</style>
}
<div class="sticky-header">
<h2>
@ViewData["Title"]
@@ -27,40 +40,50 @@
</a>
</h2>
<div>
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake data</a>
<button id="page-primary" class="btn btn-primary text-nowrap" type="button" data-action="exportCSV">Export</button>
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true"
asp-route-viewName="@Model.Request?.ViewName">Create fake data</a>
<button id="page-primary" class="btn btn-primary text-nowrap" type="button" data-action="exportCSV">Export</button>
</div>
</div>
<div class="d-flex flex-column flex-sm-row align-items-center gap-3 mb-l">
<div class="dropdown" v-pre>
<button id="ViewNameToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">@Model.Request.ViewName</button>
<div class="dropdown-menu" aria-labelledby="ViewNameToggle">
@foreach (var v in Model.AvailableViews)
{
<a href="#" data-view="@v" class="available-view dropdown-item @(Model.Request.ViewName == v ? "custom-active" : "")">@v</a>
}
</div>
</div>
<div class="input-group">
<input id="fromDate" class="form-control flatdtpicker" type="datetime-local"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
placeholder="Start Date" />
<button type="button" class="btn btn-primary input-group-clear" title="@StringLocalizer["Clear"]">
<vc:icon symbol="close" />
</button>
</div>
<div class="input-group">
<input id="toDate" class="form-control flatdtpicker" type="datetime-local"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
placeholder="End Date" />
<button type="button" class="btn btn-primary input-group-clear" title="@StringLocalizer["Clear"]">
<vc:icon symbol="close" />
</button>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<nav id="SectionNav">
<div class="nav">
@foreach (var v in Model.AvailableViews)
{
<a href="#" data-view="@v" class="available-view nav-link @(Model.Request.ViewName == v ? "active" : "")" role="tab">@v</a>
}
</div>
</nav>
<div class="d-flex gap-3">
<div class="form-group">
<label for="fromDate" class="form-label">@StringLocalizer["Start Date"]</label>
<input id="fromDate" name="fromDate"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
class="form-control flatdtpicker" placeholder="@StringLocalizer["Start Date"]" />
</div>
<div class="form-group">
<label for="toDate" class="form-label">@StringLocalizer["End Date"]</label>
<input id="toDate" name="toDate" class="form-control flatdtpicker"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
placeholder="@StringLocalizer["End Date"]" />
</div>
<div id="searchGroup" v-cloak class="form-group d-flex align-items-end">
<button id="searchBtn" class="btn btn-primary" :disabled="loading" type="button">
<span v-if="loading" class="spinner-border spinner-border-sm me-1" role="status" style="margin-left: -6px;"></span>
<span v-else class="me-1"><vc:icon symbol="actions-search" /></span>
<span text-translate="true">Search</span>
</button>
<span class="text-danger invalid-feedback field-validation-error" v-if="error">{{ error }}</span>
</div>
</div>
</div>
</div>
<div id="app" v-cloak class="w-100-fixed">
<div v-if="srv.charts && srv.charts.some(hasChartData)" id="charts" class="d-flex flex-wrap mb-5">
<div id="app" v-if="!loading" v-cloak class="w-100-fixed">
<div v-if="srv.charts && srv.charts.some(hasChartData)" id="charts" class="d-flex flex-wrap mb-3">
<article v-for="chart in srv.charts" v-if="hasChartData(chart)">
<h3>{{ chart.name }}</h3>
<div class="table-responsive">
@@ -74,8 +97,12 @@
<tbody>
<tr v-for="(row, rowIndex) in chart.rows">
<td v-for="(group, groupIndex) in row.groups" :rowspan="group.rowCount">
<template v-if="group.name === true"><vc:icon symbol="checkmark" css-class="text-success" /></template>
<template v-else-if="group.name === false"><vc:icon symbol="cross" css-class="text-danger" /></template>
<template v-if="group.name === true">
<vc:icon symbol="checkmark" css-class="text-success" />
</template>
<template v-else-if="group.name === false">
<vc:icon symbol="cross" css-class="text-danger" />
</template>
<template v-else-if="['Settled', 'Processing', 'Invalid', 'Expired', 'New', 'Pending'].includes(group.name)">
<span class="badge" :class="`badge-${group.name.toLowerCase()}`">{{ displayValue(group.name) }}</span>
</template>
@@ -83,8 +110,11 @@
</td>
<td v-if="row.isTotal" :colspan="row.rLevel">Total</td>
<td v-for="(value, columnIndex) in row.values" class="text-end">
<template v-if="chart.aggregates[columnIndex] === 'BalanceChange' && (value >= 0 || typeof value === 'object' && value.d >= 0)"><span class="text-success">{{ displayValue(value) }}</span></template>
<template v-else-if="chart.aggregates[columnIndex] === 'BalanceChange' && (value < 0 || typeof value === 'object' && value.d < 0)"><span class="text-danger">{{ displayValue(value) }}</span></template>
<template v-if="chart.aggregates[columnIndex] === 'BalanceChange' && (value >= 0 || typeof value === 'object' && value.d >= 0)">
<span class="text-success">{{ displayValue(value) }}</span></template>
<template
v-else-if="chart.aggregates[columnIndex] === 'BalanceChange' && (value < 0 || typeof value === 'object' && value.d < 0)">
<span class="text-danger">{{ displayValue(value) }}</span></template>
<template v-else>{{ displayValue(value) }}</template>
</td>
</tr>
@@ -127,7 +157,8 @@
target="_blank"
v-if="srv.result.fields[columnIndex].type === 'invoice_id'">{{ displayValue(value) }}</a>
<template v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" link="getExplorerUrl(value, row[columnIndex-1])" />
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id"
link="getExplorerUrl(value, row[columnIndex-1])" />
</template>
<template
v-else-if="value && srv.result.fields[columnIndex].name.toLowerCase().endsWith('url')">
@@ -136,15 +167,22 @@
</template>
<template v-else>{{ displayValue(value) }}</template>
</template>
<template v-else-if="value && ['Address'].includes(srv.result.fields[columnIndex].name)" >
<template v-else-if="value && ['Address'].includes(srv.result.fields[columnIndex].name)">
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" />
</template>
<template v-else-if="srv.result.fields[columnIndex].type === 'datetime'">{{ displayDate(value) }}</template>
<span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === true"><vc:icon symbol="checkmark" css-class="text-success" /></span>
<span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === false"><vc:icon symbol="cross" css-class="text-danger"/></span>
<span v-else-if="['BalanceChange'].includes(srv.result.fields[columnIndex].name) && (value >= 0 || typeof value === 'object' && value.d >= 0)" class="text-success">{{ displayValue(value) }}</span>
<span v-else-if="['BalanceChange'].includes(srv.result.fields[columnIndex].name) && (value < 0 || typeof value === 'object' && value.d < 0)" class="text-danger">{{ displayValue(value) }}</span>
<span v-else-if="['State'].includes(srv.result.fields[columnIndex].name)" class="badge" :class="`badge-${value.toLowerCase()}`">{{ displayValue(value) }}</span>
<span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === true"><vc:icon symbol="checkmark"
css-class="text-success" /></span>
<span v-else-if="srv.result.fields[columnIndex].type === 'boolean' && value === false"><vc:icon symbol="cross"
css-class="text-danger" /></span>
<span
v-else-if="['BalanceChange'].includes(srv.result.fields[columnIndex].name) && (value >= 0 || typeof value === 'object' && value.d >= 0)"
class="text-success">{{ displayValue(value) }}</span>
<span
v-else-if="['BalanceChange'].includes(srv.result.fields[columnIndex].name) && (value < 0 || typeof value === 'object' && value.d < 0)"
class="text-danger">{{ displayValue(value) }}</span>
<span v-else-if="['State'].includes(srv.result.fields[columnIndex].name)" class="badge"
:class="`badge-${value.toLowerCase()}`">{{ displayValue(value) }}</span>
<template v-else>{{ displayValue(value) }}</template>
</td>
</tr>
@@ -155,12 +193,13 @@
</article>
</div>
@section PageFootContent {
<script src="~/vendor/decimal.js/decimal.min.js" asp-append-version="true"></script>
<script src="~/vendor/FileSaver/FileSaver.min.js" asp-append-version="true"></script>
<script src="~/vendor/papaparse/papaparse.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script>const srv = @Safe.Json(Model);</script>
<script src="~/js/datatable.js" asp-append-version="true"></script>
<script src="~/js/store-reports.js" asp-append-version="true"></script>
<script src="~/vendor/decimal.js/decimal.min.js" asp-append-version="true"></script>
<script src="~/vendor/FileSaver/FileSaver.min.js" asp-append-version="true"></script>
<script src="~/vendor/papaparse/papaparse.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script>const srv = @Safe.Json(Model);</script>
<script src="~/js/datatable.js" asp-append-version="true"></script>
<script src="~/js/store-reports.js" asp-append-version="true"></script>
}

View File

@@ -1,4 +1,4 @@
let app, origData;
let app, searchBtnApp, origData;
srv.sortBy = function (field, event) {
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
@@ -8,12 +8,10 @@ srv.sortBy = function (field, event) {
if (sortedField && (fieldView.sortBy === "" || fieldView.sortBy === "desc")) {
fieldView.sortByTitle = "asc";
fieldView.sortBy = "asc";
}
else if (sortedField && (fieldView.sortByTitle === "asc")) {
} else if (sortedField && (fieldView.sortByTitle === "asc")) {
fieldView.sortByTitle = "desc";
fieldView.sortBy = "desc";
}
else {
} else {
fieldView.sortByTitle = "";
fieldView.sortBy = "";
}
@@ -23,9 +21,11 @@ srv.sortBy = function (field, event) {
document.querySelectorAll('.sort-column').forEach($a => {
$a.innerHTML = $a.innerHTML.replace(/#actions-sort-(asc|desc)"/, '#actions-sort"')
})
const { sort } = event.currentTarget.dataset;
const {sort} = event.currentTarget.dataset;
const next = sort === '' || sort === 'desc' ? 'asc' : 'desc';
event.currentTarget.innerHTML = event.currentTarget.innerHTML.replace(`#actions-sort"`, `#actions-sort-${next}"`)
const icon = event.currentTarget.querySelector('svg');
if (icon)
icon.setAttribute('href', `#actions-sort-${next}`);
}
srv.applySort = function () {
@@ -74,15 +74,17 @@ srv.updateFieldViews = function () {
const field = this.result.fields[i];
if (!this.fieldViews.hasOwnProperty(field.name)) {
this.fieldViews[field.name] =
{
sortBy: "",
sortByTitle: ""
};
{
sortBy: "",
sortByTitle: ""
};
}
}
};
document.addEventListener("DOMContentLoaded", () => {
delegate("click", "#searchBtn", function () {
fetchStoreReports();
})
delegate("input", ".flatdtpicker", function () {
// We don't use vue to bind dates, because VueJS break the flatpickr as soon as binding occurs.
let to = document.getElementById("toDate").value
@@ -96,20 +98,17 @@ document.addEventListener("DOMContentLoaded", () => {
srv.request.timePeriod.from = from;
srv.request.timePeriod.to = to;
fetchStoreReports();
});
delegate("click", "[data-action='exportCSV']", downloadCSV);
const $viewNameToggle = document.getElementById("ViewNameToggle")
delegate("click", ".available-view", function (e) {
e.preventDefault();
const { view } = e.target.dataset;
$viewNameToggle.innerText = view;
document.querySelectorAll(".available-view").forEach($el => $el.classList.remove("custom-active"));
e.target.classList.add("custom-active");
const {view} = e.target.dataset;
document.querySelectorAll(".available-view").forEach($el => $el.classList.remove("active"));
e.target.classList.add("active");
srv.request.viewName = view;
fetchStoreReports();
fetchStoreReports(true)
});
let to = new Date();
@@ -124,17 +123,24 @@ document.addEventListener("DOMContentLoaded", () => {
srv.request = srv.request || {};
srv.request.timePeriod = srv.request.timePeriod || {};
srv.request.timePeriod.to = moment(to).unix();
srv.request.viewName = srv.request.viewName || "Payments";
srv.request.viewName = srv.request.viewName || "Invoices";
srv.request.timePeriod.from = moment(from).unix();
srv.request.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
srv.result = { fields: [], values: [] };
updateUIDateRange();
srv.result = {fields: [], values: []};
searchBtnApp = new Vue({
el: '#searchGroup',
data() {
return { loading: false, error: "" };
},
});
app = new Vue({
el: '#app',
data() { return { srv } },
data() {
return {srv, loading: false};
},
methods: {
hasChartData(chart) {
return chart.rows.length || chart.hasGrandTotal;
return chart && (chart.rows.length || chart.hasGrandTotal);
},
titleCase(str, shorten) {
const result = str.replace(/([A-Z])/g, " $1");
@@ -145,13 +151,14 @@ document.addEventListener("DOMContentLoaded", () => {
displayDate
}
});
updateUIDateRange();
fetchStoreReports();
});
const dtFormatter = new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' });
const dtFormatter = new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'});
function displayDate(val) {
if(!val){
if (!val) {
return val;
}
const date = new Date(val);
@@ -170,7 +177,7 @@ function updateUIDateRange() {
// This function modify all the fields of a given type
function modifyFields(fields, data, type, action) {
const fieldIndices = fields
.map((f, i) => ({ i: i, type: f.type }))
.map((f, i) => ({i: i, type: f.type}))
.filter(f => f.type === type)
.map(f => f.i);
if (fieldIndices.length === 0)
@@ -181,6 +188,7 @@ function modifyFields(fields, data, type, action) {
}
}
}
function downloadCSV() {
if (!origData) return;
const data = clone(origData);
@@ -188,42 +196,72 @@ function downloadCSV() {
// Convert ISO8601 dates to YYYY-MM-DD HH:mm:ss so the CSV easily integrate with Excel
modifyFields(srv.result.fields, data, 'amount', displayValue)
modifyFields(srv.result.fields, data, 'datetime', v => v ? moment(v).format('YYYY-MM-DD HH:mm:ss') : v);
const csv = Papa.unparse({ fields: srv.result.fields.map(f => f.name), data });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const csv = Papa.unparse({fields: srv.result.fields.map(f => f.name), data});
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8;'});
saveAs(blob, "export.csv");
}
async function fetchStoreReports() {
const result = await fetch(window.location, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(srv.request)
});
let fetchPromise = null;
var abortFetching = new AbortController()
srv.result = await result.json();
srv.dataUpdated();
// Dates from API are UTC, convert them to local time
modifyFields(srv.result.fields, srv.result.data, 'datetime', a => a? moment(a).format(): a);
var urlParams = new URLSearchParams(new URL(window.location).search);
urlParams.set("viewName", srv.request.viewName);
urlParams.set("from", srv.request.timePeriod.from);
urlParams.set("to", srv.request.timePeriod.to);
history.replaceState(null, null, "?" + urlParams.toString());
updateUIDateRange();
srv.charts = [];
for (let i = 0; i < srv.result.charts.length; i++) {
const chart = srv.result.charts[i];
const table = createTable(chart, srv.result.fields.map(f => f.name), srv.result.data);
table.name = chart.name;
srv.charts.push(table);
function setLoading(val)
{
searchBtnApp.loading = val;
app.loading = val;
}
async function fetchStoreReports(abort) {
if (abort)
{
abortFetching.abort();
}
if (fetchPromise) {
await fetchPromise;
}
abortFetching = new AbortController();
fetchPromise = (async () => {
setLoading(true);
searchBtnApp.error = "";
try {
const result = await fetch(window.location, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(srv.request),
signal: abortFetching.signal
});
app.srv = srv;
srv.result = await result.json();
srv.dataUpdated();
setLoading(false);
// Dates from API are UTC, convert them to local time
modifyFields(srv.result.fields, srv.result.data, 'datetime', a => a ? moment(a).format() : a);
var urlParams = new URLSearchParams(new URL(window.location).search);
urlParams.set("viewName", srv.request.viewName);
urlParams.set("from", srv.request.timePeriod.from);
urlParams.set("to", srv.request.timePeriod.to);
history.replaceState(null, null, "?" + urlParams.toString());
updateUIDateRange();
srv.charts = [];
for (let i = 0; i < srv.result.charts.length; i++) {
const chart = srv.result.charts[i];
const table = createTable(chart, srv.result.fields.map(f => f.name), srv.result.data);
table.name = chart.name;
srv.charts.push(table);
}
app.srv = srv;
} catch (e) {
setLoading(false);
if (e.name !== 'AbortError') {
searchBtnApp.error = e.message;
}
}
})();
await fetchPromise;
}
function getInvoiceUrl(value) {
@@ -231,6 +269,7 @@ function getInvoiceUrl(value) {
return;
return srv.invoiceTemplateUrl.replace("INVOICE_ID", value);
}
window.getInvoiceUrl = getInvoiceUrl;
function getExplorerUrl(tx_id, cryptoCode) {
@@ -241,4 +280,5 @@ function getExplorerUrl(tx_id, cryptoCode) {
return null;
return explorer.replace("TX_ID", tx_id);
}
window.getExplorerUrl = getExplorerUrl;

View File

@@ -378,7 +378,7 @@ h2 .icon.icon-info {
.widget {
--widget-padding: var(--btcpay-space-m);
--widget-chart-width: 100vw;
border: 1px solid var(--btcpay-body-border-light);
border-radius: var(--btcpay-border-radius-l);
padding: var(--widget-padding);
@@ -546,7 +546,7 @@ h2 .icon.icon-info {
.widget.store-lightning-services .services-list .service {
--service-width: 3rem;
}
.widget .store-number {
flex: 0 1 100%;
}
@@ -598,7 +598,7 @@ h2 .icon.icon-info {
grid-column-start: 9;
grid-column-end: 13;
}
.widget.store-numbers {
flex-direction: column;
justify-content: start;
@@ -650,7 +650,7 @@ h2 .icon.icon-info {
.btcpay-list-select-item {
display: flex;
flex-wrap: wrap;
flex: 1 1 45%;
flex: 1 1 45%;
align-items: center;
padding: .75rem var(--btcpay-space-s);
cursor: pointer;
@@ -697,7 +697,7 @@ input:checked + label.btcpay-list-select-item {
--wrap-max-width: none;
--wrap-padding-vertical: var(--btcpay-space-l);
--wrap-padding-horizontal: var(--btcpay-space-m);
display: flex;
flex-direction: column;
gap: 1.5rem;
@@ -705,7 +705,7 @@ input:checked + label.btcpay-list-select-item {
margin: 0 auto;
padding: var(--wrap-padding-vertical) var(--wrap-padding-horizontal);
}
/* gradually try to set better but less supported values and units */
.min-vh-100,
.public-page-wrap {
@@ -808,7 +808,7 @@ a.store-powered-by:hover .logo-brand-dark {
--icon-size: 64px;
--icon-border-size: var(--btcpay-space-xs);
--icon-border-color: var(--btcpay-white);
max-width: 320px;
min-width: var(--qr-size);
margin: 0 auto;
@@ -1090,7 +1090,7 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
border-radius: var(--btcpay-border-radius-l);
padding: var(--btcpay-space-xs) var(--btcpay-space-s);
}
.truncate-center-text {
color: transparent;
position: absolute;
@@ -1161,6 +1161,15 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
.blazor-status .btn-close .icon {
--icon-size: .75rem;
}
.btn .icon {
--icon-size: 1.25rem;
vertical-align: text-bottom;
/*Without this the icon + text are in the middle,*/
/*but the visual balance is off, and it doesn't feel center*/
margin-left: calc(var(--icon-size) / -2);
}
.btn.btn-lg .icon {
--icon-size: 1.75rem;
}