Merge branch 'AttachmentsToChat'

This commit is contained in:
Alex
2026-04-14 23:20:16 -06:00
20 changed files with 528 additions and 27 deletions

View File

@@ -12,6 +12,8 @@ public class MessageResponse {
private String content;
private LocalDateTime timestamp;
private Boolean isRead;
private String attachmentName;
private String attachmentUrl;
public MessageResponse() {
}
@@ -87,4 +89,20 @@ public class MessageResponse {
public void setIsRead(Boolean isRead) {
this.isRead = isRead;
}
public String getAttachmentName() {
return attachmentName;
}
public void setAttachmentName(String attachmentName) {
this.attachmentName = attachmentName;
}
public String getAttachmentUrl() {
return attachmentUrl;
}
public void setAttachmentUrl(String attachmentUrl) {
this.attachmentUrl = attachmentUrl;
}
}

View File

@@ -5,6 +5,8 @@ import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse;
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
public class AuthApi {
private static final AuthApi INSTANCE = new AuthApi();
@@ -33,4 +35,10 @@ public class AuthApi {
public void deleteAvatar() throws Exception {
apiClient.delete("/api/v1/auth/me/avatar");
}
public void forgotPassword(String usernameOrEmail) throws Exception {
Map<String, String> body = new HashMap<>();
body.put("usernameOrEmail", usernameOrEmail);
apiClient.post("/api/v1/auth/forgot-password", body, Object.class);
}
}

View File

@@ -11,6 +11,9 @@ import javafx.scene.control.DatePicker;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.example.petshopdesktop.api.dto.analytics.DailySales;
import org.example.petshopdesktop.api.dto.analytics.DashboardResponse;
@@ -98,6 +101,20 @@ public class AnalyticsController {
@FXML
private ComboBox<String> cbTopN;
@FXML
private ComboBox<String> cbStoreFilter;
@FXML
private HBox hbViewToggle;
@FXML
private ToggleButton tbnMyAnalytics;
@FXML
private ToggleButton tbnStoreAnalytics;
private String viewMode = "store";
private List<SaleResponse> cachedSales = new ArrayList<>();
private FilterState currentFilter = new FilterState();
@@ -124,8 +141,29 @@ public class AnalyticsController {
cbPaymentFilter.setItems(FXCollections.observableArrayList("All"));
cbPaymentFilter.getSelectionModel().selectFirst();
cbStoreFilter.setItems(FXCollections.observableArrayList("All Stores"));
cbStoreFilter.getSelectionModel().selectFirst();
lblFilterSummary.setText("All time");
ToggleGroup tgViewMode = new ToggleGroup();
tbnMyAnalytics.setToggleGroup(tgViewMode);
tbnStoreAnalytics.setToggleGroup(tgViewMode);
tbnStoreAnalytics.setSelected(true);
tgViewMode.selectedToggleProperty().addListener((obs, oldVal, newVal) -> {
if (newVal == null) {
(viewMode.equals("mine") ? tbnMyAnalytics : tbnStoreAnalytics).setSelected(true);
return;
}
viewMode = (newVal == tbnMyAnalytics) ? "mine" : "store";
updateViewModeStyles();
updateStoreFilterVisibility();
applyCurrentFilter();
});
hbViewToggle.setVisible(true);
hbViewToggle.setManaged(true);
loadAnalyticsData();
}
@@ -182,6 +220,8 @@ public class AnalyticsController {
Platform.runLater(() -> {
cachedSales = sales;
derivePaymentMethods();
deriveStores();
updateStoreFilterVisibility();
applyCurrentFilter();
btnRefresh.setDisable(false);
});
@@ -196,9 +236,36 @@ public class AnalyticsController {
}).start();
}
private void updateViewModeStyles() {
String selectedStyle = "-fx-background-color: #4ECDC4; -fx-text-fill: white; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;";
String unselectedStyle = "-fx-background-color: #e2e8f0; -fx-text-fill: #475569; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;";
if (viewMode.equals("mine")) {
tbnMyAnalytics.setStyle(selectedStyle + " -fx-background-radius: 6 0 0 6;");
tbnStoreAnalytics.setStyle(unselectedStyle + " -fx-background-radius: 0 6 6 0;");
} else {
tbnMyAnalytics.setStyle(unselectedStyle + " -fx-background-radius: 6 0 0 6;");
tbnStoreAnalytics.setStyle(selectedStyle + " -fx-background-radius: 0 6 6 0;");
}
}
private void applyCurrentFilter() {
try {
List<SaleResponse> filtered = filterSales(cachedSales, currentFilter);
List<SaleResponse> salesForMode;
if (viewMode.equals("mine")) {
String myName = UserSession.getInstance().getEmployeeName();
salesForMode = cachedSales.stream()
.filter(s -> myName != null && myName.equalsIgnoreCase(s.getEmployeeName() != null ? s.getEmployeeName() : ""))
.collect(Collectors.toList());
} else {
salesForMode = cachedSales;
}
String storeFilter = currentFilter.storeFilter;
if (!storeFilter.equals("All Stores") && !storeFilter.isBlank()) {
salesForMode = salesForMode.stream()
.filter(s -> storeFilter.equalsIgnoreCase(s.getStoreName() != null ? s.getStoreName() : ""))
.collect(Collectors.toList());
}
List<SaleResponse> filtered = filterSales(salesForMode, currentFilter);
String start = currentFilter.startDate.isEmpty() ? LocalDate.now().minusDays(6).toString() : currentFilter.startDate;
String end = currentFilter.endDate.isEmpty() ? LocalDate.now().toString() : currentFilter.endDate;
@@ -256,6 +323,31 @@ public class AnalyticsController {
}
}
private void deriveStores() {
Set<String> stores = new TreeSet<>();
for (SaleResponse s : cachedSales) {
if (s.getStoreName() != null && !s.getStoreName().isBlank()) {
stores.add(s.getStoreName());
}
}
List<String> items = new ArrayList<>();
items.add("All Stores");
items.addAll(stores);
String current = cbStoreFilter.getValue();
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
if (current != null && items.contains(current)) {
cbStoreFilter.setValue(current);
} else {
cbStoreFilter.getSelectionModel().selectFirst();
}
}
private void updateStoreFilterVisibility() {
boolean show = UserSession.getInstance().isAdmin() && viewMode.equals("store");
cbStoreFilter.setVisible(show);
cbStoreFilter.setManaged(show);
}
private void updateFilterSummary() {
String start = currentFilter.startDate;
String end = currentFilter.endDate;
@@ -392,11 +484,12 @@ public class AnalyticsController {
}
private void applyRoleVisibility(boolean isAdmin) {
chartEmployeePerformance.setVisible(isAdmin);
chartEmployeePerformance.setManaged(isAdmin);
boolean showEmpChart = isAdmin && viewMode.equals("store");
chartEmployeePerformance.setVisible(showEmpChart);
chartEmployeePerformance.setManaged(showEmpChart);
if (chartEmployeePerformance.getParent() != null) {
chartEmployeePerformance.getParent().setVisible(isAdmin);
chartEmployeePerformance.getParent().setManaged(isAdmin);
chartEmployeePerformance.getParent().setVisible(showEmpChart);
chartEmployeePerformance.getParent().setManaged(showEmpChart);
}
}
@@ -612,6 +705,7 @@ public class AnalyticsController {
dpEndDate.setValue(null);
cbPaymentFilter.getSelectionModel().selectFirst();
cbTopN.getSelectionModel().selectFirst();
cbStoreFilter.getSelectionModel().selectFirst();
currentFilter = new FilterState();
applyCurrentFilter();
}
@@ -630,6 +724,8 @@ public class AnalyticsController {
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;
String sf = cbStoreFilter.getValue();
currentFilter.storeFilter = (sf != null && !sf.isBlank()) ? sf : "All Stores";
applyCurrentFilter();
}
@@ -638,5 +734,6 @@ public class AnalyticsController {
String endDate = "";
String paymentMethod = "All";
int topN = 5;
String storeFilter = "All Stores";
}
}

View File

@@ -7,13 +7,17 @@ import javafx.collections.transformation.FilteredList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.stage.Modality;
import javafx.stage.Stage;
import org.example.petshopdesktop.api.dto.user.UserResponse;
import org.example.petshopdesktop.api.endpoints.CustomerApi;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.DesktopImageSupport;
import org.example.petshopdesktop.util.TableViewSupport;
import java.util.Comparator;
@@ -25,6 +29,9 @@ public class CustomerAccountsController {
@FXML
private TableView<UserResponse> tvCustomers;
@FXML
private TableColumn<UserResponse, String> colCustomerAvatar;
@FXML
private TableColumn<UserResponse, String> colCustomerUsername;
@@ -69,6 +76,13 @@ public class CustomerAccountsController {
@FXML
public void initialize() {
colCustomerAvatar.setCellValueFactory(data -> {
Long id = data.getValue().getId();
if (id == null) return new javafx.beans.property.SimpleStringProperty("");
return new javafx.beans.property.SimpleStringProperty("/api/v1/users/" + id + "/avatar/file");
});
configureAvatarColumn(colCustomerAvatar);
colCustomerUsername.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getUsername()));
colCustomerName.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getFullName()));
colCustomerEmail.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getEmail()));
@@ -94,6 +108,27 @@ public class CustomerAccountsController {
refresh();
}
private void configureAvatarColumn(TableColumn<UserResponse, String> column) {
column.setCellFactory(col -> new TableCell<>() {
private final ImageView imageView = new ImageView();
private final StackPane container = new StackPane(imageView);
{
container.setAlignment(Pos.CENTER);
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null || item.isBlank()) {
setGraphic(null);
return;
}
DesktopImageSupport.loadImageInto(imageView, item, 48, 48);
setGraphic(container);
}
});
}
@FXML
void btnRefreshClicked(ActionEvent event) {
txtSearchCustomer.clear();

View File

@@ -1,12 +1,15 @@
package org.example.petshopdesktop.controllers;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.TextField;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
@@ -14,6 +17,7 @@ import org.example.petshopdesktop.api.ApiClient;
import org.example.petshopdesktop.api.dto.auth.LoginRequest;
import org.example.petshopdesktop.api.dto.auth.LoginResponse;
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
import org.example.petshopdesktop.api.endpoints.AuthApi;
import org.example.petshopdesktop.auth.Role;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.ui.SvgWebViewFactory;
@@ -105,6 +109,42 @@ public class LoginController {
}
}
@FXML
void lnkForgotPasswordClicked(ActionEvent event) {
TextInputDialog dialog = new TextInputDialog();
dialog.setTitle("Forgot Password");
dialog.setHeaderText("Reset your password");
dialog.setContentText("Enter your username or email:");
dialog.showAndWait().ifPresent(input -> {
if (input.trim().isEmpty()) return;
new Thread(() -> {
try {
AuthApi.getInstance().forgotPassword(input.trim());
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("Reset Link Sent");
alert.setHeaderText(null);
alert.setContentText("If this account exists, a password reset link has been sent to the associated email.");
alert.showAndWait();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException(
"LoginController.lnkForgotPasswordClicked",
e,
"Forgot password request for: " + input.trim());
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Error");
alert.setHeaderText(null);
alert.setContentText("Could not send reset link. Please try again.");
alert.showAndWait();
});
}
}).start();
});
}
private void openMainLayout() {
try {
FXMLLoader loader = new FXMLLoader(

View File

@@ -199,6 +199,12 @@ public class SaleController {
@FXML
private Label lblLoyaltyDiscount;
@FXML
private HBox hbPointsToEarn;
@FXML
private Label lblPointsToEarn;
private final ObservableList<SaleCartItem> cartItems = FXCollections.observableArrayList();
private final ObservableList<SaleLineItem> saleItems = FXCollections.observableArrayList();
private FilteredList<SaleLineItem> filteredSales;
@@ -389,12 +395,15 @@ public class SaleController {
task.setOnSucceeded(event -> {
selectedCustomerData = task.getValue();
if (selectedCustomerData != null && selectedCustomerData.getLoyaltyPoints() != null && selectedCustomerData.getLoyaltyPoints() >= 20) {
lblLoyaltyPoints.setText(selectedCustomerData.getLoyaltyPoints() + " pts available");
if (selectedCustomerData != null) {
int pts = selectedCustomerData.getLoyaltyPoints() != null ? selectedCustomerData.getLoyaltyPoints() : 0;
lblLoyaltyPoints.setText(pts + " pts available");
lblLoyaltyPoints.setVisible(true);
lblLoyaltyPoints.setManaged(true);
chkUseLoyaltyPoints.setVisible(true);
chkUseLoyaltyPoints.setManaged(true);
boolean canRedeem = pts >= 20;
chkUseLoyaltyPoints.setVisible(canRedeem);
chkUseLoyaltyPoints.setManaged(canRedeem);
if (!canRedeem) chkUseLoyaltyPoints.setSelected(false);
} else {
lblLoyaltyPoints.setVisible(false);
lblLoyaltyPoints.setManaged(false);
@@ -701,6 +710,8 @@ public class SaleController {
hbCouponDiscount.setManaged(false);
hbLoyaltyDiscount.setVisible(false);
hbLoyaltyDiscount.setManaged(false);
hbPointsToEarn.setVisible(false);
hbPointsToEarn.setManaged(false);
cbCustomer.setValue(null);
selectedCustomerData = null;
lblLoyaltyPoints.setVisible(false);
@@ -875,6 +886,16 @@ public class SaleController {
}
lblCartTotal.setText(currency.format(Math.max(0, total.doubleValue())));
if (selectedCustomerData != null) {
int pointsToEarn = (int) Math.max(0, total.doubleValue());
lblPointsToEarn.setText("+" + pointsToEarn + " pts");
hbPointsToEarn.setVisible(true);
hbPointsToEarn.setManaged(true);
} else {
hbPointsToEarn.setVisible(false);
hbPointsToEarn.setManaged(false);
}
}
private BigDecimal calculateCouponDiscount(BigDecimal subtotal) {

View File

@@ -7,8 +7,11 @@ import javafx.collections.transformation.FilteredList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
@@ -16,6 +19,7 @@ import org.example.petshopdesktop.api.dto.employee.EmployeeResponse;
import org.example.petshopdesktop.api.endpoints.EmployeeApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.DesktopImageSupport;
import org.example.petshopdesktop.util.TableViewSupport;
import java.util.Comparator;
@@ -26,6 +30,7 @@ public class StaffAccountsController {
@FXML private VBox staffSection;
@FXML private TableView<EmployeeResponse> tvStaff;
@FXML private TableColumn<EmployeeResponse, String> colStaffAvatar;
@FXML private TableColumn<EmployeeResponse, String> colUsername;
@FXML private TableColumn<EmployeeResponse, String> colName;
@FXML private TableColumn<EmployeeResponse, String> colEmail;
@@ -47,6 +52,13 @@ public class StaffAccountsController {
@FXML
public void initialize() {
colStaffAvatar.setCellValueFactory(data -> {
Long id = data.getValue().getId();
if (id == null) return new javafx.beans.property.SimpleStringProperty("");
return new javafx.beans.property.SimpleStringProperty("/api/v1/users/" + id + "/avatar/file");
});
configureAvatarColumn(colStaffAvatar);
colUsername.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getUsername()));
colName.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getFullName()));
colEmail.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getEmail()));
@@ -81,6 +93,27 @@ public class StaffAccountsController {
refresh();
}
private void configureAvatarColumn(TableColumn<EmployeeResponse, String> column) {
column.setCellFactory(col -> new TableCell<>() {
private final ImageView imageView = new ImageView();
private final StackPane container = new StackPane(imageView);
{
container.setAlignment(Pos.CENTER);
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null || item.isBlank()) {
setGraphic(null);
return;
}
DesktopImageSupport.loadImageInto(imageView, item, 48, 48);
setGraphic(container);
}
});
}
@FXML
void btnRefreshClicked(ActionEvent event) {
txtSearch.clear();

View File

@@ -2,6 +2,7 @@
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Hyperlink?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.PasswordField?>
<?import javafx.scene.control.TextField?>
@@ -83,6 +84,15 @@
<Insets top="8.0" />
</VBox.margin>
</Button>
<Hyperlink maxWidth="Infinity" mnemonicParsing="false" onAction="#lnkForgotPasswordClicked"
text="Forgot Password?"
style="-fx-text-fill: #91a4b7; -fx-border-color: transparent; -fx-cursor: hand;"
alignment="CENTER_RIGHT">
<font>
<Font size="12.0" />
</font>
</Hyperlink>
</children>
</VBox>
</children>

View File

@@ -8,6 +8,7 @@
<?import javafx.scene.chart.PieChart?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.ToggleButton?>
<?import javafx.scene.control.DatePicker?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Tab?>
@@ -30,6 +31,16 @@
</font>
</Label>
<Region HBox.hgrow="ALWAYS" />
<HBox fx:id="hbViewToggle" spacing="0.0" alignment="CENTER" visible="false" managed="false">
<ToggleButton fx:id="tbnMyAnalytics" text="My Analytics" style="-fx-background-color: #e2e8f0; -fx-text-fill: #475569; -fx-background-radius: 6 0 0 6; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;">
<font><Font size="12.0" /></font>
<padding><Insets bottom="6.0" left="14.0" right="14.0" top="6.0" /></padding>
</ToggleButton>
<ToggleButton fx:id="tbnStoreAnalytics" text="Store Analytics" style="-fx-background-color: #4ECDC4; -fx-text-fill: white; -fx-background-radius: 0 6 6 0; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;">
<font><Font size="12.0" /></font>
<padding><Insets bottom="6.0" left="14.0" right="14.0" top="6.0" /></padding>
</ToggleButton>
</HBox>
<Button fx:id="btnRefresh" onAction="#handleRefresh" style="-fx-background-color: #4ECDC4; -fx-text-fill: white; -fx-background-radius: 5; -fx-cursor: hand;" text="Refresh">
<font>
<Font size="13.0" />
@@ -91,6 +102,7 @@
</FlowPane>
<HBox alignment="CENTER_LEFT" spacing="8.0">
<children>
<ComboBox fx:id="cbStoreFilter" prefWidth="145.0" promptText="All Stores" visible="false" managed="false" />
<ComboBox fx:id="cbPaymentFilter" prefWidth="145.0" promptText="All Payments" />
<ComboBox fx:id="cbTopN" prefWidth="110.0" />
<Region HBox.hgrow="ALWAYS" />

View File

@@ -69,6 +69,7 @@
<TableView fx:id="tvCustomers" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="colCustomerAvatar" prefWidth="70.0" text="Avatar" sortable="false" />
<TableColumn fx:id="colCustomerUsername" prefWidth="130.0" text="Username" />
<TableColumn fx:id="colCustomerName" prefWidth="160.0" text="Name" />
<TableColumn fx:id="colCustomerEmail" prefWidth="200.0" text="Email" />

View File

@@ -173,6 +173,12 @@
<Label fx:id="lblCartTotal" text="" textFill="#2c3e50"><font><Font name="System Bold" size="16.0" /></font></Label>
</children>
</HBox>
<HBox fx:id="hbPointsToEarn" spacing="8.0" alignment="CENTER_LEFT" visible="false" managed="false">
<children>
<Label text="Points to earn:" textFill="#4ECDC4"><font><Font size="12.0" /></font></Label>
<Label fx:id="lblPointsToEarn" text="+0 pts" textFill="#4ECDC4"><font><Font size="12.0" /></font></Label>
</children>
</HBox>
</children>
</VBox>
<FlowPane hgap="8.0" prefWrapLength="220.0" vgap="8.0">

View File

@@ -78,6 +78,7 @@
<TableView fx:id="tvStaff" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="colStaffAvatar" prefWidth="70.0" text="Avatar" sortable="false" />
<TableColumn fx:id="colUsername" prefWidth="140.0" text="Username" />
<TableColumn fx:id="colName" prefWidth="170.0" text="Name" />
<TableColumn fx:id="colEmail" prefWidth="210.0" text="Email" />