added filter to analytics desktop

This commit is contained in:
Alex
2026-04-14 04:39:38 -06:00
parent 62434020e2
commit 582918e9e1
2 changed files with 301 additions and 84 deletions

View File

@@ -1,19 +1,22 @@
package org.example.petshopdesktop.controllers;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.chart.*;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.layout.VBox;
import org.example.petshopdesktop.api.dto.analytics.DailySales;
import org.example.petshopdesktop.api.dto.analytics.DashboardResponse;
import org.example.petshopdesktop.api.dto.analytics.TopProduct;
import org.example.petshopdesktop.api.dto.sale.SaleItemResponse;
import org.example.petshopdesktop.api.dto.sale.SaleResponse;
import org.example.petshopdesktop.api.endpoints.AnalyticsApi;
import org.example.petshopdesktop.api.endpoints.SaleApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.ActivityLogger;
@@ -74,6 +77,32 @@ public class AnalyticsController {
@FXML
private TabPane tabPane;
@FXML
private Button btnToggleFilters;
@FXML
private Label lblFilterSummary;
@FXML
private VBox vbFilterContent;
@FXML
private DatePicker dpStartDate;
@FXML
private DatePicker dpEndDate;
@FXML
private ComboBox<String> cbPaymentFilter;
@FXML
private ComboBox<String> cbTopN;
private List<SaleResponse> cachedSales = new ArrayList<>();
private FilterState currentFilter = new FilterState();
private static final int[] TOP_N_VALUES = {5, 10, 15, 20};
private static final String SALES_COLOR = "#ff6b35";
private static final String REVENUE_COLOR = "#4ecdc4";
private static final String QUANTITY_COLOR = "#ff9f1c";
@@ -88,13 +117,15 @@ public class AnalyticsController {
@FXML
public void initialize() {
configureCharts();
if (tabPane != null) {
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
if (oldTab != newTab && newTab != null) {
loadAnalyticsData();
}
});
}
cbTopN.setItems(FXCollections.observableArrayList("Top 5", "Top 10", "Top 15", "Top 20"));
cbTopN.getSelectionModel().selectFirst();
cbPaymentFilter.setItems(FXCollections.observableArrayList("All"));
cbPaymentFilter.getSelectionModel().selectFirst();
lblFilterSummary.setText("All time");
loadAnalyticsData();
}
@@ -142,15 +173,42 @@ public class AnalyticsController {
private void loadAnalyticsData() {
lblError.setVisible(false);
if (lblStatus != null) {
lblStatus.setVisible(false);
}
btnRefresh.setDisable(true);
new Thread(() -> {
try {
DashboardResponse dashboard = AnalyticsApi.getInstance().getDashboard(30, 10);
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
List<SaleResponse> sales = SaleApi.getInstance().listAllSales(null, storeId);
Platform.runLater(() -> {
cachedSales = sales;
derivePaymentMethods();
applyCurrentFilter();
btnRefresh.setDisable(false);
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", e, "Loading sales data for analytics");
lblError.setText("Error loading analytics data. Please try again.");
lblError.setVisible(true);
btnRefresh.setDisable(false);
});
}
}).start();
}
private void applyCurrentFilter() {
try {
List<SaleResponse> filtered = filterSales(cachedSales, currentFilter);
String start = currentFilter.startDate.isEmpty() ? LocalDate.now().minusDays(6).toString() : currentFilter.startDate;
String end = currentFilter.endDate.isEmpty() ? LocalDate.now().toString() : currentFilter.endDate;
DashboardResponse dashboard = new DashboardResponse();
dashboard.setSalesSummary(buildSalesSummary(filtered));
dashboard.setDailySales(buildDailySalesBetween(filtered, start, end));
dashboard.setTopProducts(buildTopProducts(filtered, currentFilter.topN > 0 ? currentFilter.topN : 5));
dashboard.setPaymentMethods(buildPaymentMethods(filtered));
dashboard.setEmployeePerformance(buildEmployeePerformance(filtered));
boolean isAdmin = UserSession.getInstance().isAdmin();
loadSummaryData(dashboard);
loadSalesOverTime(dashboard);
@@ -159,45 +217,61 @@ public class AnalyticsController {
loadPaymentMethodDistribution(dashboard);
loadEmployeePerformance(dashboard, isAdmin);
applyRoleVisibility(isAdmin);
updateFilterSummary();
} catch (Exception e) {
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", e, "Loading analytics data");
lblError.setText("Error loading analytics data. Please try again.");
ActivityLogger.getInstance().logException("AnalyticsController.applyCurrentFilter", e, "Computing analytics from filtered sales");
lblError.setText("Error computing analytics. Please try again.");
lblError.setVisible(true);
}
});
} catch (Exception e) {
if (UserSession.getInstance().isStaff()) {
try {
DashboardResponse fallback = buildStaffFallbackDashboard();
Platform.runLater(() -> {
try {
loadSummaryData(fallback);
loadSalesOverTime(fallback);
loadTopProductsByRevenue(fallback);
loadTopProductsByQuantity(fallback);
loadPaymentMethodDistribution(fallback);
loadEmployeePerformance(fallback, false);
applyRoleVisibility(false);
lblError.setVisible(false);
} catch (Exception inner) {
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", inner, "Rendering fallback analytics data");
lblError.setText("Error loading analytics data. Please try again.");
lblError.setVisible(true);
}
});
return;
} catch (Exception fallbackError) {
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", fallbackError, "Loading fallback analytics data");
}
}
Platform.runLater(() -> {
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", e, "Loading analytics data");
lblError.setText("Error loading analytics data. Please try again.");
lblError.setVisible(true);
});
private List<SaleResponse> filterSales(List<SaleResponse> sales, FilterState filter) {
return sales.stream().filter(sale -> {
String date = sale.getSaleDate() != null ? sale.getSaleDate().toLocalDate().toString() : "";
if (!filter.startDate.isEmpty() && !date.isEmpty() && date.compareTo(filter.startDate) < 0) return false;
if (!filter.endDate.isEmpty() && !date.isEmpty() && date.compareTo(filter.endDate) > 0) return false;
if (!filter.paymentMethod.equals("All") && !filter.paymentMethod.isEmpty()) {
if (!filter.paymentMethod.equalsIgnoreCase(sale.getPaymentMethod())) return false;
}
}).start();
return true;
}).collect(Collectors.toList());
}
private void derivePaymentMethods() {
Set<String> methods = new TreeSet<>();
for (SaleResponse s : cachedSales) {
if (s.getPaymentMethod() != null && !s.getPaymentMethod().isEmpty()) {
methods.add(s.getPaymentMethod());
}
}
List<String> items = new ArrayList<>();
items.add("All");
items.addAll(methods);
String current = cbPaymentFilter.getValue();
cbPaymentFilter.setItems(FXCollections.observableArrayList(items));
if (current != null && items.contains(current)) {
cbPaymentFilter.setValue(current);
} else {
cbPaymentFilter.getSelectionModel().selectFirst();
}
}
private void updateFilterSummary() {
String start = currentFilter.startDate;
String end = currentFilter.endDate;
if (start.isEmpty() && end.isEmpty()) {
lblFilterSummary.setText("All time");
} else if (start.isEmpty()) {
lblFilterSummary.setText("Up to " + shortDate(end));
} else if (end.isEmpty()) {
lblFilterSummary.setText("From " + shortDate(start));
} else {
lblFilterSummary.setText(shortDate(start) + " " + shortDate(end));
}
}
private String shortDate(String date) {
return (date != null && date.length() >= 10) ? date.substring(5) : date;
}
private void loadSummaryData(DashboardResponse dashboard) throws Exception {
@@ -326,26 +400,23 @@ public class AnalyticsController {
}
}
private DashboardResponse buildStaffFallbackDashboard() throws Exception {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
List<SaleResponse> sales = SaleApi.getInstance().listSales(0, Integer.MAX_VALUE, null, storeId);
String employeeName = UserSession.getInstance().getEmployeeName();
if (employeeName == null || employeeName.isBlank()) {
employeeName = UserSession.getInstance().getUsername();
private List<DashboardResponse.EmployeePerformanceData> buildEmployeePerformance(List<SaleResponse> sales) {
Map<String, BigDecimal> empRevenue = new LinkedHashMap<>();
for (SaleResponse sale : sales) {
if (Boolean.TRUE.equals(sale.getIsRefund())) continue;
String emp = sale.getEmployeeName() != null ? sale.getEmployeeName() : "Unknown";
BigDecimal amount = sale.getTotalAmount() != null ? sale.getTotalAmount() : BigDecimal.ZERO;
empRevenue.merge(emp, amount, BigDecimal::add);
}
final String employeeNameFilter = employeeName;
List<SaleResponse> personalSales = sales.stream()
.filter(sale -> sale.getEmployeeName() != null && sale.getEmployeeName().equalsIgnoreCase(employeeNameFilter))
.toList();
DashboardResponse dashboard = new DashboardResponse();
dashboard.setSalesSummary(buildSalesSummary(personalSales));
dashboard.setDailySales(buildDailySales(personalSales, 30));
dashboard.setTopProducts(buildTopProducts(personalSales, 10));
dashboard.setPaymentMethods(buildPaymentMethods(personalSales));
dashboard.setEmployeePerformance(List.of(new DashboardResponse.EmployeePerformanceData()));
return dashboard;
return empRevenue.entrySet().stream()
.sorted((a, b) -> b.getValue().compareTo(a.getValue()))
.map(e -> {
DashboardResponse.EmployeePerformanceData d = new DashboardResponse.EmployeePerformanceData();
d.setEmployeeName(e.getKey());
d.setRevenue(e.getValue());
return d;
})
.collect(Collectors.toList());
}
private DashboardResponse.SalesSummary buildSalesSummary(List<SaleResponse> sales) {
@@ -379,21 +450,26 @@ public class AnalyticsController {
return summary;
}
private List<DailySales> buildDailySales(List<SaleResponse> sales, int days) {
private List<DailySales> buildDailySalesBetween(List<SaleResponse> sales, String startStr, String endStr) {
Map<LocalDate, DailySales> daily = new LinkedHashMap<>();
LocalDate start = LocalDate.now().minusDays(days - 1L);
for (int i = 0; i < days; i++) {
LocalDate date = start.plusDays(i);
try {
LocalDate start = LocalDate.parse(startStr);
LocalDate end = LocalDate.parse(endStr);
if (start.plusDays(60).isBefore(end)) {
start = end.minusDays(59);
}
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
DailySales row = new DailySales();
row.setDate(date.toString());
row.setRevenue(BigDecimal.ZERO);
row.setSalesCount(0L);
daily.put(date, row);
}
for (SaleResponse sale : sales) {
if (Boolean.TRUE.equals(sale.getIsRefund()) || sale.getSaleDate() == null) {
continue;
} catch (Exception e) {
return new ArrayList<>();
}
for (SaleResponse sale : sales) {
if (Boolean.TRUE.equals(sale.getIsRefund()) || sale.getSaleDate() == null) continue;
LocalDate date = sale.getSaleDate().toLocalDate();
DailySales row = daily.get(date);
if (row != null) {
@@ -494,4 +570,73 @@ public class AnalyticsController {
loadAnalyticsData();
TableViewSupport.flashStatus(lblStatus, "Refreshed");
}
@FXML
void btnToggleFilters(ActionEvent event) {
boolean expanded = vbFilterContent.isVisible();
vbFilterContent.setVisible(!expanded);
vbFilterContent.setManaged(!expanded);
btnToggleFilters.setText(expanded ? "▼ Filters" : "▲ Filters");
}
@FXML
void btnPresetToday(ActionEvent event) { applyPreset(0, 0); }
@FXML
void btnPreset7D(ActionEvent event) { applyPreset(-6, 0); }
@FXML
void btnPreset30D(ActionEvent event) { applyPreset(-29, 0); }
@FXML
void btnPreset3M(ActionEvent event) { applyPreset(-89, 0); }
@FXML
void btnPreset1Y(ActionEvent event) { applyPreset(-364, 0); }
@FXML
void btnPresetAll(ActionEvent event) {
dpStartDate.setValue(null);
dpEndDate.setValue(null);
applyFiltersFromUI();
}
@FXML
void btnApplyFilter(ActionEvent event) {
applyFiltersFromUI();
}
@FXML
void btnResetFilter(ActionEvent event) {
dpStartDate.setValue(null);
dpEndDate.setValue(null);
cbPaymentFilter.getSelectionModel().selectFirst();
cbTopN.getSelectionModel().selectFirst();
currentFilter = new FilterState();
applyCurrentFilter();
}
private void applyPreset(int startOffset, int endOffset) {
dpStartDate.setValue(LocalDate.now().plusDays(startOffset));
dpEndDate.setValue(LocalDate.now().plusDays(endOffset));
applyFiltersFromUI();
}
private void applyFiltersFromUI() {
currentFilter = new FilterState();
currentFilter.startDate = dpStartDate.getValue() != null ? dpStartDate.getValue().toString() : "";
currentFilter.endDate = dpEndDate.getValue() != null ? dpEndDate.getValue().toString() : "";
String pm = cbPaymentFilter.getValue();
currentFilter.paymentMethod = pm != null ? pm : "All";
int topNPos = cbTopN.getSelectionModel().getSelectedIndex();
currentFilter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
applyCurrentFilter();
}
private static class FilterState {
String startDate = "";
String endDate = "";
String paymentMethod = "All";
int topN = 5;
}
}

View File

@@ -7,9 +7,12 @@
<?import javafx.scene.chart.NumberAxis?>
<?import javafx.scene.chart.PieChart?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.DatePicker?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Tab?>
<?import javafx.scene.control.TabPane?>
<?import javafx.scene.layout.FlowPane?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
@@ -37,6 +40,75 @@
</Button>
</HBox>
<VBox style="-fx-background-color: white; -fx-background-radius: 8; -fx-border-color: #e2e8f0; -fx-border-radius: 8; -fx-border-width: 1;">
<padding>
<Insets bottom="10.0" left="12.0" right="12.0" top="10.0" />
</padding>
<children>
<HBox alignment="CENTER_LEFT" spacing="10.0">
<children>
<Button fx:id="btnToggleFilters" mnemonicParsing="false" onAction="#btnToggleFilters" style="-fx-background-color: transparent; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="▼ Filters" textFill="#2c3e50">
<font><Font name="System Bold" size="12.0" /></font>
</Button>
<Label fx:id="lblFilterSummary" text="All time" textFill="#64748b">
<font><Font size="12.0" /></font>
</Label>
</children>
</HBox>
<VBox fx:id="vbFilterContent" spacing="8.0" visible="false" managed="false">
<VBox.margin><Insets top="8.0" /></VBox.margin>
<children>
<FlowPane alignment="CENTER_LEFT" hgap="8.0" vgap="8.0">
<children>
<DatePicker fx:id="dpStartDate" prefWidth="145.0" promptText="Start date" />
<Label text="to" textFill="#64748b" />
<DatePicker fx:id="dpEndDate" prefWidth="145.0" promptText="End date" />
<Button fx:id="btnPresetToday" mnemonicParsing="false" onAction="#btnPresetToday" style="-fx-background-color: #f1f5f9; -fx-background-radius: 6; -fx-border-color: #e2e8f0; -fx-border-radius: 6; -fx-border-width: 1; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Today" textFill="#475569">
<font><Font size="11.0" /></font>
<padding><Insets bottom="5.0" left="10.0" right="10.0" top="5.0" /></padding>
</Button>
<Button fx:id="btnPreset7D" mnemonicParsing="false" onAction="#btnPreset7D" style="-fx-background-color: #f1f5f9; -fx-background-radius: 6; -fx-border-color: #e2e8f0; -fx-border-radius: 6; -fx-border-width: 1; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="7D" textFill="#475569">
<font><Font size="11.0" /></font>
<padding><Insets bottom="5.0" left="10.0" right="10.0" top="5.0" /></padding>
</Button>
<Button fx:id="btnPreset30D" mnemonicParsing="false" onAction="#btnPreset30D" style="-fx-background-color: #f1f5f9; -fx-background-radius: 6; -fx-border-color: #e2e8f0; -fx-border-radius: 6; -fx-border-width: 1; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="30D" textFill="#475569">
<font><Font size="11.0" /></font>
<padding><Insets bottom="5.0" left="10.0" right="10.0" top="5.0" /></padding>
</Button>
<Button fx:id="btnPreset3M" mnemonicParsing="false" onAction="#btnPreset3M" style="-fx-background-color: #f1f5f9; -fx-background-radius: 6; -fx-border-color: #e2e8f0; -fx-border-radius: 6; -fx-border-width: 1; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="3M" textFill="#475569">
<font><Font size="11.0" /></font>
<padding><Insets bottom="5.0" left="10.0" right="10.0" top="5.0" /></padding>
</Button>
<Button fx:id="btnPreset1Y" mnemonicParsing="false" onAction="#btnPreset1Y" style="-fx-background-color: #f1f5f9; -fx-background-radius: 6; -fx-border-color: #e2e8f0; -fx-border-radius: 6; -fx-border-width: 1; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="1Y" textFill="#475569">
<font><Font size="11.0" /></font>
<padding><Insets bottom="5.0" left="10.0" right="10.0" top="5.0" /></padding>
</Button>
<Button fx:id="btnPresetAll" mnemonicParsing="false" onAction="#btnPresetAll" style="-fx-background-color: #f1f5f9; -fx-background-radius: 6; -fx-border-color: #e2e8f0; -fx-border-radius: 6; -fx-border-width: 1; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="All" textFill="#475569">
<font><Font size="11.0" /></font>
<padding><Insets bottom="5.0" left="10.0" right="10.0" top="5.0" /></padding>
</Button>
</children>
</FlowPane>
<HBox alignment="CENTER_LEFT" spacing="8.0">
<children>
<ComboBox fx:id="cbPaymentFilter" prefWidth="145.0" promptText="All Payments" />
<ComboBox fx:id="cbTopN" prefWidth="110.0" />
<Region HBox.hgrow="ALWAYS" />
<Button fx:id="btnApplyFilter" mnemonicParsing="false" onAction="#btnApplyFilter" style="-fx-background-color: #4ECDC4; -fx-background-radius: 6; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Apply" textFill="WHITE">
<font><Font name="System Bold" size="12.0" /></font>
<padding><Insets bottom="6.0" left="18.0" right="18.0" top="6.0" /></padding>
</Button>
<Button fx:id="btnResetFilter" mnemonicParsing="false" onAction="#btnResetFilter" style="-fx-background-color: transparent; -fx-border-color: #cbd5e1; -fx-border-radius: 6; -fx-background-radius: 6; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Reset" textFill="#64748b">
<font><Font size="12.0" /></font>
<padding><Insets bottom="6.0" left="14.0" right="14.0" top="6.0" /></padding>
</Button>
</children>
</HBox>
</children>
</VBox>
</children>
</VBox>
<Label fx:id="lblStatus" textFill="#64748b" visible="false" managed="true">
<font>
<Font size="13.0" />