Merge pull request #278 from RecentRunner/desktop-backend-fixes

Activity log, staff role, chat
This commit was merged in pull request #278.
This commit is contained in:
2026-04-14 20:17:21 -06:00
committed by GitHub
9 changed files with 102 additions and 34 deletions

View File

@@ -2,7 +2,9 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.activity.ActivityLogResponse; import com.petshop.backend.dto.activity.ActivityLogResponse;
import com.petshop.backend.service.ActivityLogService; import com.petshop.backend.service.ActivityLogService;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -26,8 +28,10 @@ public class ActivityLogController {
@RequestParam(defaultValue = "2000") int limit, @RequestParam(defaultValue = "2000") int limit,
@RequestParam(required = false) Long storeId, @RequestParam(required = false) Long storeId,
@RequestParam(required = false) String role, @RequestParam(required = false) String role,
@RequestParam(required = false) String search) { @RequestParam(required = false) String search,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
int safeLimit = Math.min(Math.max(1, limit), 10000); int safeLimit = Math.min(Math.max(1, limit), 10000);
return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search)); return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search, startDate, endDate));
} }
} }

View File

@@ -15,6 +15,7 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -52,6 +53,11 @@ public class ActivityLogService {
entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null); entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null);
entry.setActivity(activity.trim()); entry.setActivity(activity.trim());
activityLogRepository.save(entry); activityLogRepository.save(entry);
log.info("[ACTIVITY] {} | {} | {} | {}",
entry.getRoleSnapshot(),
entry.getUsernameSnapshot(),
entry.getStoreNameSnapshot() != null ? entry.getStoreNameSnapshot() : "no store",
entry.getActivity());
} catch (Exception ex) { } catch (Exception ex) {
log.warn("Failed to persist activity log", ex); log.warn("Failed to persist activity log", ex);
} }
@@ -65,7 +71,7 @@ public class ActivityLogService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search) { public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search, LocalDate startDate, LocalDate endDate) {
Specification<ActivityLog> spec = (root, query, cb) -> { Specification<ActivityLog> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>(); List<Predicate> predicates = new ArrayList<>();
@@ -87,6 +93,14 @@ public class ActivityLogService {
predicates.add(searchPredicate); predicates.add(searchPredicate);
} }
if (startDate != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("logTimestamp"), startDate.atStartOfDay()));
}
if (endDate != null) {
predicates.add(cb.lessThan(root.get("logTimestamp"), endDate.plusDays(1).atStartOfDay()));
}
return cb.and(predicates.toArray(new Predicate[0])); return cb.and(predicates.toArray(new Predicate[0]));
}; };
@@ -96,9 +110,14 @@ public class ActivityLogService {
.toList(); .toList();
} }
@Transactional(readOnly = true)
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search) {
return getLogs(limit, storeId, role, search, null, null);
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ActivityLogResponse> getLogs(int limit) { public List<ActivityLogResponse> getLogs(int limit) {
return getLogs(limit, null, null, null); return getLogs(limit, null, null, null, null, null);
} }
private ActivityLogResponse toResponse(ActivityLog entry) { private ActivityLogResponse toResponse(ActivityLog entry) {

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import org.example.petshopdesktop.api.ApiClient; import org.example.petshopdesktop.api.ApiClient;
import org.example.petshopdesktop.api.dto.activity.ActivityLogResponse; import org.example.petshopdesktop.api.dto.activity.ActivityLogResponse;
import java.time.LocalDate;
import java.util.List; import java.util.List;
public class ActivityLogApi { public class ActivityLogApi {
@@ -18,12 +19,18 @@ public class ActivityLogApi {
return INSTANCE; return INSTANCE;
} }
public List<ActivityLogResponse> getActivityLogs(int limit) throws Exception { public List<ActivityLogResponse> getActivityLogs(int limit, LocalDate startDate, LocalDate endDate) throws Exception {
String path = "/api/v1/activity-logs?limit=" + limit; StringBuilder path = new StringBuilder("/api/v1/activity-logs?limit=").append(limit);
String response = apiClient.getRawResponse(path); if (startDate != null) path.append("&startDate=").append(startDate);
if (endDate != null) path.append("&endDate=").append(endDate);
String response = apiClient.getRawResponse(path.toString());
return apiClient.getObjectMapper().readValue( return apiClient.getObjectMapper().readValue(
response, response,
new TypeReference<List<ActivityLogResponse>>() {} new TypeReference<List<ActivityLogResponse>>() {}
); );
} }
public List<ActivityLogResponse> getActivityLogs(int limit) throws Exception {
return getActivityLogs(limit, null, null);
}
} }

View File

@@ -3,9 +3,12 @@ package org.example.petshopdesktop.controllers;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.TableColumn; import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
@@ -40,6 +43,15 @@ public class ActivityLogController {
@FXML @FXML
private Button btnRefresh; private Button btnRefresh;
@FXML
private DatePicker dpStart;
@FXML
private DatePicker dpEnd;
@FXML
private CheckBox chkHideViewOnly;
@FXML @FXML
private Label lblStatus; private Label lblStatus;
@@ -47,6 +59,7 @@ public class ActivityLogController {
private Label lblError; private Label lblError;
private final ObservableList<ActivityLogResponse> activityLogs = FXCollections.observableArrayList(); private final ObservableList<ActivityLogResponse> activityLogs = FXCollections.observableArrayList();
private FilteredList<ActivityLogResponse> filteredLogs;
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final int DEFAULT_LIMIT = 2000; private static final int DEFAULT_LIMIT = 2000;
@@ -71,7 +84,8 @@ public class ActivityLogController {
colStore.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayStore(data.getValue()))); colStore.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayStore(data.getValue())));
colActivity.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(nullToBlank(data.getValue().getActivity()))); colActivity.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(nullToBlank(data.getValue().getActivity())));
TableViewSupport.bindSortedItems(tvActivityLogs, new javafx.collections.transformation.FilteredList<>(activityLogs, a -> true)); filteredLogs = new FilteredList<>(activityLogs, a -> true);
TableViewSupport.bindSortedItems(tvActivityLogs, filteredLogs);
loadLogs(); loadLogs();
} }
@@ -80,18 +94,46 @@ public class ActivityLogController {
loadLogs(); loadLogs();
} }
@FXML
void onDateChanged(ActionEvent event) {
loadLogs();
}
@FXML
void onHideViewOnlyChanged(ActionEvent event) {
applyViewOnlyFilter();
}
private void applyViewOnlyFilter() {
boolean hide = chkHideViewOnly != null && chkHideViewOnly.isSelected();
if (filteredLogs != null) {
filteredLogs.setPredicate(hide
? a -> {
String act = a.getActivity();
if (act == null) return true;
String trimmed = act.stripLeading();
return !trimmed.startsWith("View");
}
: a -> true);
}
}
private void loadLogs() { private void loadLogs() {
lblError.setText(""); lblError.setText("");
tvActivityLogs.setDisable(true); tvActivityLogs.setDisable(true);
btnRefresh.setDisable(true); btnRefresh.setDisable(true);
java.time.LocalDate startDate = dpStart != null ? dpStart.getValue() : null;
java.time.LocalDate endDate = dpEnd != null ? dpEnd.getValue() : null;
new Thread(() -> { new Thread(() -> {
try { try {
List<ActivityLogResponse> content = ActivityLogApi.getInstance().getActivityLogs(DEFAULT_LIMIT); List<ActivityLogResponse> content = ActivityLogApi.getInstance().getActivityLogs(DEFAULT_LIMIT, startDate, endDate);
List<ActivityLogResponse> safeContent = content != null ? content : List.of(); List<ActivityLogResponse> safeContent = content != null ? content : List.of();
Platform.runLater(() -> { Platform.runLater(() -> {
activityLogs.setAll(safeContent); activityLogs.setAll(safeContent);
applyViewOnlyFilter();
tvActivityLogs.setDisable(false); tvActivityLogs.setDisable(false);
btnRefresh.setDisable(false); btnRefresh.setDisable(false);
TableViewSupport.flashStatus(lblStatus, "Refreshed"); TableViewSupport.flashStatus(lblStatus, "Refreshed");

View File

@@ -32,7 +32,6 @@ public class StaffEditDialogController {
@FXML private PasswordField txtPassword; @FXML private PasswordField txtPassword;
@FXML private PasswordField txtPasswordConfirm; @FXML private PasswordField txtPasswordConfirm;
@FXML private ComboBox<String> cbRole; @FXML private ComboBox<String> cbRole;
@FXML private ComboBox<String> cbStaffRole;
@FXML private ComboBox<String> cbActive; @FXML private ComboBox<String> cbActive;
@FXML private ComboBox<DropdownOption> cbStore; @FXML private ComboBox<DropdownOption> cbStore;
@FXML private Label lblError; @FXML private Label lblError;
@@ -46,8 +45,6 @@ public class StaffEditDialogController {
TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone); TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone);
cbRole.setItems(FXCollections.observableArrayList("STAFF", "ADMIN")); cbRole.setItems(FXCollections.observableArrayList("STAFF", "ADMIN"));
cbStaffRole.setItems(FXCollections.observableArrayList(
"STORE_MANAGER", "SALES_ASSOCIATE", "GROOMER", "VETERINARIAN"));
cbActive.setItems(FXCollections.observableArrayList("Active", "Inactive")); cbActive.setItems(FXCollections.observableArrayList("Active", "Inactive"));
cbStore.setCellFactory(param -> new ListCell<>() { cbStore.setCellFactory(param -> new ListCell<>() {
@@ -109,7 +106,6 @@ public class StaffEditDialogController {
txtUsername.setText(emp.getUsername()); txtUsername.setText(emp.getUsername());
if (emp.getRole() != null) cbRole.setValue(emp.getRole()); if (emp.getRole() != null) cbRole.setValue(emp.getRole());
if (emp.getStaffRole() != null) cbStaffRole.setValue(emp.getStaffRole());
cbActive.setValue(Boolean.TRUE.equals(emp.getActive()) ? "Active" : "Inactive"); cbActive.setValue(Boolean.TRUE.equals(emp.getActive()) ? "Active" : "Inactive");
pendingStoreId = emp.getPrimaryStoreId(); pendingStoreId = emp.getPrimaryStoreId();
@@ -171,10 +167,6 @@ public class StaffEditDialogController {
lblError.setText("Role is required."); lblError.setText("Role is required.");
return; return;
} }
if (cbStaffRole.getValue() == null) {
lblError.setText("Staff role is required.");
return;
}
if (cbStore.getValue() == null) { if (cbStore.getValue() == null) {
lblError.setText("Primary store is required."); lblError.setText("Primary store is required.");
return; return;
@@ -183,7 +175,6 @@ public class StaffEditDialogController {
btnSave.setDisable(true); btnSave.setDisable(true);
String role = cbRole.getValue(); String role = cbRole.getValue();
String staffRole = cbStaffRole.getValue();
boolean active = "Active".equals(cbActive.getValue()); boolean active = "Active".equals(cbActive.getValue());
Long storeId = cbStore.getValue().getId(); Long storeId = cbStore.getValue().getId();
@@ -198,7 +189,6 @@ public class StaffEditDialogController {
request.setEmail(email); request.setEmail(email);
request.setPhone(phone); request.setPhone(phone);
request.setRole(role); request.setRole(role);
request.setStaffRole(staffRole);
request.setActive(active); request.setActive(active);
request.setPrimaryStoreId(storeId); request.setPrimaryStoreId(storeId);

View File

@@ -87,22 +87,12 @@
</children> </children>
</HBox> </HBox>
<HBox spacing="10.0"> <VBox spacing="6.0">
<children> <children>
<VBox spacing="6.0" HBox.hgrow="ALWAYS"> <Label text="Role" />
<children> <ComboBox fx:id="cbRole" maxWidth="1.7976931348623157E308" promptText="Select Role" />
<Label text="Role" />
<ComboBox fx:id="cbRole" maxWidth="1.7976931348623157E308" promptText="Select Role" />
</children>
</VBox>
<VBox spacing="6.0" HBox.hgrow="ALWAYS">
<children>
<Label text="Staff Role" />
<ComboBox fx:id="cbStaffRole" maxWidth="1.7976931348623157E308" promptText="Select Staff Role" />
</children>
</VBox>
</children> </children>
</HBox> </VBox>
<HBox spacing="10.0"> <HBox spacing="10.0">
<children> <children>

View File

@@ -2,6 +2,8 @@
<?import javafx.geometry.Insets?> <?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?> <?import javafx.scene.control.Button?>
<?import javafx.scene.control.CheckBox?>
<?import javafx.scene.control.DatePicker?>
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.TableColumn?> <?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?> <?import javafx.scene.control.TableView?>
@@ -30,6 +32,11 @@
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" /> <Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
</padding> </padding>
</Button> </Button>
<Label text="From" textFill="#64748b" />
<DatePicker fx:id="dpStart" onAction="#onDateChanged" promptText="Start date" prefWidth="140.0" />
<Label text="To" textFill="#64748b" />
<DatePicker fx:id="dpEnd" onAction="#onDateChanged" promptText="End date" prefWidth="140.0" />
<CheckBox fx:id="chkHideViewOnly" onAction="#onHideViewOnlyChanged" text="Hide view-only" />
</children> </children>
</HBox> </HBox>

View File

@@ -88,7 +88,7 @@
</ScrollPane> </ScrollPane>
<VBox spacing="10.0" style="-fx-background-color: #ffffff; -fx-background-radius: 16; -fx-padding: 14;"> <VBox spacing="10.0" style="-fx-background-color: #ffffff; -fx-background-radius: 16; -fx-padding: 14;">
<children> <children>
<TextArea fx:id="txtMessage" prefRowCount="4" promptText="Reply to the selected conversation..." wrapText="true" /> <TextArea fx:id="txtMessage" prefHeight="80.0" promptText="Reply to the selected conversation..." wrapText="true" />
<HBox alignment="CENTER_RIGHT" spacing="10.0"> <HBox alignment="CENTER_RIGHT" spacing="10.0">
<children> <children>
<Button fx:id="btnAttachment" mnemonicParsing="false" onAction="#btnAttachmentClicked" style="-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;" text="📎"> <Button fx:id="btnAttachment" mnemonicParsing="false" onAction="#btnAttachmentClicked" style="-fx-background-color: #e2e8f0; -fx-background-radius: 12; -fx-text-fill: #475569; -fx-cursor: hand;" text="📎">

View File

@@ -128,3 +128,12 @@
.chat-messages-scroll-pane > .viewport { .chat-messages-scroll-pane > .viewport {
-fx-background-color: transparent; -fx-background-color: transparent;
} }
.text-area:focused {
-fx-focus-color: transparent;
-fx-faint-focus-color: transparent;
}
.text-area .content {
-fx-border-color: transparent;
}