merge main

This commit is contained in:
2026-04-14 23:29:46 -06:00
127 changed files with 5010 additions and 859 deletions

View File

@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
"Bash(grep -E \"\\\\.js$|^d\")"
]
}
}

View File

@@ -1,6 +1,7 @@
package com.example.petstoremobile.activities;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;
import androidx.activity.EdgeToEdge;
@@ -9,14 +10,26 @@ import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.example.petstoremobile.api.auth.AuthApi;
import com.example.petstoremobile.databinding.ActivityForgotPasswordBinding;
import com.example.petstoremobile.utils.InputValidator;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Inject;
import dagger.hilt.android.AndroidEntryPoint;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
@AndroidEntryPoint
public class ForgotPasswordActivity extends AppCompatActivity {
@Inject
AuthApi authApi;
private ActivityForgotPasswordBinding binding;
@Override
@@ -33,14 +46,39 @@ public class ForgotPasswordActivity extends AppCompatActivity {
});
binding.btnSubmit.setOnClickListener(v -> {
if (InputValidator.isValidEmail(binding.etEmail)) {
String email = binding.etEmail.getText().toString().trim();
// TODO: Implement password reset logic here
Toast.makeText(this, "If this email is linked, a reset email will be sent.", Toast.LENGTH_LONG).show();
finish();
}
if (!InputValidator.isValidEmail(binding.etEmail)) return;
String email = binding.etEmail.getText().toString().trim();
sendResetLink(email);
});
binding.btnBackToLogin.setOnClickListener(v -> finish());
}
private void sendResetLink(String email) {
binding.btnSubmit.setEnabled(false);
Map<String, String> body = new HashMap<>();
body.put("usernameOrEmail", email);
authApi.forgotPassword(body).enqueue(new Callback<Void>() {
@Override
public void onResponse(Call<Void> call, Response<Void> response) {
if (binding == null) return;
binding.btnSubmit.setEnabled(true);
Toast.makeText(ForgotPasswordActivity.this,
"If this email is registered, a reset link will be sent.",
Toast.LENGTH_LONG).show();
finish();
}
@Override
public void onFailure(Call<Void> call, Throwable t) {
if (binding == null) return;
binding.btnSubmit.setEnabled(true);
Toast.makeText(ForgotPasswordActivity.this,
"Could not send reset link. Please try again.",
Toast.LENGTH_LONG).show();
}
});
}
}

View File

@@ -11,6 +11,7 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.model.GlideUrl;
import com.bumptech.glide.load.model.LazyHeaders;
import com.bumptech.glide.signature.ObjectKey;
import com.example.petstoremobile.R;
import com.example.petstoremobile.databinding.ItemMessageReceivedBinding;
import com.example.petstoremobile.databinding.ItemMessageSentBinding;
@@ -35,6 +36,13 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
public MessageAdapter(List<Message> messages, Long currentUserId) {
this.messages = messages;
this.currentUserId = currentUserId;
setHasStableIds(true);
}
@Override
public long getItemId(int position) {
Message m = messages.get(position);
return m.getId() != null ? m.getId() : position;
}
public void setCurrentUserId(Long id) {
@@ -150,6 +158,7 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
}
if (url == null) {
Glide.with(iv.getContext()).clear(iv);
iv.setVisibility(View.GONE);
tvName.setVisibility(View.GONE);
return;
@@ -166,18 +175,25 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
.build());
}
// Use a signature to prevent Glide from showing stale cached images for the same URL/ID
String signatureKey = (m.getTimestamp() != null ? m.getTimestamp() : "") + m.getId();
Glide.with(iv.getContext()).clear(iv);
Glide.with(iv.getContext())
.load(loadTarget)
.signature(new ObjectKey(signatureKey))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.placeholder(R.drawable.placeholder)
.error(R.drawable.placeholder)
.into(iv);
} else {
Glide.with(iv.getContext()).clear(iv);
iv.setVisibility(View.GONE);
tvName.setVisibility(View.VISIBLE);
tvName.setText(m.getAttachmentName() != null ? m.getAttachmentName() : "Attachment");
}
} else {
Glide.with(iv.getContext()).clear(iv);
iv.setVisibility(View.GONE);
tvName.setVisibility(View.GONE);
}

View File

@@ -48,6 +48,12 @@ public class SaleAdapter extends RecyclerView.Adapter<SaleAdapter.SaleViewHolder
binding.tvSaleId.setText("Sale #" + (s.getSaleId() != null ? s.getSaleId() : ""));
binding.tvSaleEmployee.setText("By: " + (s.getEmployeeName() != null ? s.getEmployeeName() : ""));
if (s.getCustomerName() != null && !s.getCustomerName().isEmpty()) {
binding.tvSaleCustomer.setText("Customer: " + s.getCustomerName());
binding.tvSaleCustomer.setVisibility(View.VISIBLE);
} else {
binding.tvSaleCustomer.setVisibility(View.GONE);
}
binding.tvSaleDate.setText(s.getSaleDate() != null ? s.getSaleDate().substring(0, Math.min(10, s.getSaleDate().length())) : "");
binding.tvSalePayment.setText(s.getPaymentMethod() != null ? s.getPaymentMethod() : "");
binding.tvSaleTotal.setText(s.getTotalAmount() != null ? "$" + s.getTotalAmount() : "");

View File

@@ -20,6 +20,7 @@ public interface SaleApi {
@Query("paymentMethod") String paymentMethod,
@Query("storeId") Long storeId,
@Query("isRefund") Boolean isRefund,
@Query("customerId") Long customerId,
@Query("sort") String sort);
@GET("api/v1/sales/{id}")

View File

@@ -43,4 +43,8 @@ public interface AuthApi {
@DELETE("api/v1/auth/me/avatar")
Call<Void> deleteAvatar();
//forgot password endpoint
@POST("api/v1/auth/forgot-password")
Call<Void> forgotPassword(@Body Map<String, String> body);
}

View File

@@ -182,6 +182,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
LinearLayoutManager lm = new LinearLayoutManager(getContext());
lm.setStackFromEnd(true);
binding.rvMessages.setLayoutManager(lm);
binding.rvMessages.setItemAnimator(null);
binding.rvMessages.setAdapter(messageAdapter);
setConversationActive(false, null);
}
@@ -285,9 +286,14 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
});
viewModel.getMessageList().observe(getViewLifecycleOwner(), list -> {
int prevSize = messageList.size();
messageList.clear();
messageList.addAll(list);
messageAdapter.notifyDataSetChanged();
if (prevSize > 0 && list.size() == prevSize + 1) {
messageAdapter.notifyItemInserted(list.size() - 1);
} else {
messageAdapter.notifyDataSetChanged();
}
scrollToBottom();
});

View File

@@ -194,7 +194,7 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
}
private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Completed", "Pending", "Cancelled"};
String[] statuses = {"All Statuses", "Completed", "Pending", "Missed", "Cancelled"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusAdoption, statuses, this::loadAdoptions);
}

View File

@@ -7,11 +7,13 @@ import android.widget.*;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentAnalyticsBinding;
import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.AnalyticsViewModel;
import dagger.hilt.android.AndroidEntryPoint;
import javax.inject.Inject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
@@ -19,6 +21,9 @@ import java.util.*;
@AndroidEntryPoint
public class AnalyticsFragment extends Fragment {
@Inject
TokenManager tokenManager;
private FragmentAnalyticsBinding binding;
private AnalyticsViewModel viewModel;
private boolean filtersExpanded = false;
@@ -33,6 +38,7 @@ public class AnalyticsFragment extends Fragment {
viewModel = new ViewModelProvider(this).get(AnalyticsViewModel.class);
setupFilterPanel();
setupViewModeToggle();
observeViewModel();
viewModel.loadAnalytics();
@@ -42,6 +48,39 @@ public class AnalyticsFragment extends Fragment {
return binding.getRoot();
}
private static final int COLOR_SELECTED = 0xFF4ECDC4;
private static final int COLOR_UNSELECTED = 0xFFCBD5E1;
private void setupViewModeToggle() {
updateViewModeButtonStyles(viewModel.getViewMode());
binding.btnMyAnalytics.setOnClickListener(v -> {
viewModel.setViewMode("mine");
updateViewModeButtonStyles("mine");
updateStoreFilterVisibility("mine");
});
binding.btnStoreAnalytics.setOnClickListener(v -> {
viewModel.setViewMode("store");
updateViewModeButtonStyles("store");
updateStoreFilterVisibility("store");
});
}
private void updateViewModeButtonStyles(String mode) {
binding.btnMyAnalytics.setBackgroundTintList(
android.content.res.ColorStateList.valueOf(mode.equals("mine") ? COLOR_SELECTED : COLOR_UNSELECTED));
binding.btnStoreAnalytics.setBackgroundTintList(
android.content.res.ColorStateList.valueOf(mode.equals("store") ? COLOR_SELECTED : COLOR_UNSELECTED));
}
private void updateStoreFilterVisibility(String mode) {
boolean isAdmin = "ADMIN".equalsIgnoreCase(tokenManager.getRole());
int vis = (isAdmin && mode.equals("store")) ? View.VISIBLE : View.GONE;
binding.tvStoreFilterLabel.setVisibility(vis);
binding.spinnerFilterStore.setVisibility(vis);
}
// Filter Panel
private void setupFilterPanel() {
@@ -96,6 +135,9 @@ public class AnalyticsFragment extends Fragment {
int topNPos = binding.spinnerTopN.getSelectedItemPosition();
filter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
Object store = binding.spinnerFilterStore.getSelectedItem();
viewModel.setStoreFilter(store != null ? store.toString() : "All Stores");
updateFilterSummary();
viewModel.applyFilter(filter);
}
@@ -104,8 +146,8 @@ public class AnalyticsFragment extends Fragment {
binding.etFilterStartDate.setText("");
binding.etFilterEndDate.setText("");
binding.spinnerTopN.setSelection(0);
// Reset payment method to "All"
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, "All");
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, "All Stores");
updateFilterSummary();
viewModel.resetFilter();
}
@@ -162,6 +204,16 @@ public class AnalyticsFragment extends Fragment {
methods.toArray(new String[0]));
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, currentSelection);
});
viewModel.getAvailableStores().observe(getViewLifecycleOwner(), stores -> {
if (stores == null || stores.isEmpty()) return;
String currentSelection = binding.spinnerFilterStore.getSelectedItem() != null
? binding.spinnerFilterStore.getSelectedItem().toString() : "All Stores";
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerFilterStore,
stores.toArray(new String[0]));
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, currentSelection);
updateStoreFilterVisibility(viewModel.getViewMode());
});
}
@Override
@@ -224,17 +276,22 @@ public class AnalyticsFragment extends Fragment {
}
// Employee Performance
binding.llEmployeePerformance.removeAllViews();
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
for (Map.Entry<String, BigDecimal> e : data.employeePerformance) {
addBarRow(binding.llEmployeePerformance, e.getKey(),
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
boolean showEmployeeSection = viewModel.getViewMode().equals("store");
View empParent = (View) binding.llEmployeePerformance.getParent();
if (empParent != null) empParent.setVisibility(showEmployeeSection ? View.VISIBLE : View.GONE);
if (showEmployeeSection) {
binding.llEmployeePerformance.removeAllViews();
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
for (Map.Entry<String, BigDecimal> e : data.employeePerformance) {
addBarRow(binding.llEmployeePerformance, e.getKey(),
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
}
} else {
addEmptyRow(binding.llEmployeePerformance, "No data");
}
} else {
addEmptyRow(binding.llEmployeePerformance, "No data");
}
// Daily Revenue

View File

@@ -17,6 +17,7 @@ import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.SaleAdapter;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.databinding.FragmentSaleBinding;
import com.example.petstoremobile.dtos.CustomerDTO;
import com.example.petstoremobile.dtos.SaleDTO;
import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.utils.SpinnerUtils;
@@ -57,6 +58,7 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
setupStoreFilter();
setupPaymentMethodFilter();
setupRefundStatusFilter();
setupCustomerFilter();
setupSwipeRefresh();
setupFilterToggle();
observeViewModel();
@@ -89,6 +91,11 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
StoreDTO::getStoreName, "All Stores", null, StoreDTO::getStoreId);
});
viewModel.getCustomers().observe(getViewLifecycleOwner(), list -> {
SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerCustomer, list,
CustomerDTO::getFullName, "All Customers", null, CustomerDTO::getCustomerId);
});
viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading -> {
binding.swipeRefreshSale.setRefreshing(loading);
});
@@ -98,16 +105,17 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
public void onResume() {
super.onResume();
if (!isStaff()) viewModel.loadStores();
viewModel.loadCustomers();
}
private void setupFilterToggle() {
if (isStaff()) {
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, binding.etSearchSale,
binding.spinnerPaymentMethod, binding.spinnerRefundStatus);
binding.spinnerPaymentMethod, binding.spinnerRefundStatus, binding.spinnerCustomer);
binding.spinnerStore.setVisibility(View.GONE);
} else {
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, binding.etSearchSale,
binding.spinnerPaymentMethod, binding.spinnerStore, binding.spinnerRefundStatus);
binding.spinnerPaymentMethod, binding.spinnerStore, binding.spinnerRefundStatus, binding.spinnerCustomer);
}
}
@@ -133,6 +141,10 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerRefundStatus, refundStatuses, () -> loadSales(true));
}
private void setupCustomerFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerCustomer, () -> loadSales(true));
}
private void setupRecyclerView() {
adapter = new SaleAdapter(saleList, this);
binding.recyclerViewSales.setLayoutManager(new LinearLayoutManager(getContext()));
@@ -189,7 +201,13 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
isRefund = binding.spinnerRefundStatus.getSelectedItemPosition() == 2;
}
viewModel.loadSales(reset, query, paymentMethod, storeId, isRefund);
Long customerId = null;
List<CustomerDTO> customerList = viewModel.getCustomers().getValue();
if (binding.spinnerCustomer.getSelectedItemPosition() > 0 && customerList != null && !customerList.isEmpty()) {
customerId = customerList.get(binding.spinnerCustomer.getSelectedItemPosition() - 1).getCustomerId();
}
viewModel.loadSales(reset, query, paymentMethod, storeId, isRefund, customerId);
}
@Override

View File

@@ -111,6 +111,7 @@ public class SaleDetailFragment extends Fragment {
binding.llLoyaltyPoints.setVisibility(View.GONE);
binding.cbUseLoyaltyPoints.setChecked(false);
}
updateTotal();
});
}
@@ -420,6 +421,15 @@ public class SaleDetailFragment extends Fragment {
}
binding.tvSaleDetailTotal.setText("Total: $" + String.format(Locale.getDefault(), "%.2f", total));
CustomerDTO customer = viewModel.getSelectedCustomerData().getValue();
if (customer != null && !viewModel.isViewOnly()) {
int pointsToEarn = total.max(BigDecimal.ZERO).intValue();
binding.tvPointsToEarn.setText("+" + pointsToEarn + " pts");
binding.llPointsToEarn.setVisibility(View.VISIBLE);
} else {
binding.llPointsToEarn.setVisibility(View.GONE);
}
}
private void saveSale() {

View File

@@ -28,8 +28,6 @@ public class StaffDetailFragment extends Fragment {
private FragmentStaffDetailBinding binding;
private StaffDetailViewModel viewModel;
private final String[] ROLES = {"STAFF", "ADMIN"};
private final String[] STAFF_ROLES = {"STORE_MANAGER", "SALES_ASSOCIATE", "GROOMER", "VETERINARIAN"};
private final String[] STATUSES = {"Active", "Inactive"};
private long preselectedStoreId = -1;
@@ -59,8 +57,6 @@ public class StaffDetailFragment extends Fragment {
}
private void setupSpinners() {
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffRole, ROLES);
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffType, STAFF_ROLES);
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffStatus, STATUSES);
}
@@ -112,8 +108,6 @@ public class StaffDetailFragment extends Fragment {
binding.etStaffEmail.setText(e.getEmail());
binding.etStaffPhone.setText(e.getPhone());
SpinnerUtils.setSelectionByValue(binding.spinnerStaffRole, e.getRole());
SpinnerUtils.setSelectionByValue(binding.spinnerStaffType, e.getStaffRole());
binding.spinnerStaffStatus.setSelection(Boolean.TRUE.equals(e.getActive()) ? 0 : 1);
preselectedStoreId = e.getPrimaryStoreId() != null ? e.getPrimaryStoreId() : -1;
@@ -132,14 +126,32 @@ public class StaffDetailFragment extends Fragment {
private void save() {
if (!InputValidator.isNotEmpty(binding.etStaffUsername, "Username")) return;
String password = binding.etStaffPassword.getText().toString().trim();
String confirmPassword = binding.etStaffConfirmPassword.getText().toString().trim();
if (!viewModel.isEditing()) {
if (!InputValidator.isNotEmpty(binding.etStaffPassword, "Password")) return;
String pass = binding.etStaffPassword.getText().toString();
if (pass.length() < 6) {
if (password.length() < 6) {
binding.etStaffPassword.setError("At least 6 characters");
binding.etStaffPassword.requestFocus();
return;
}
if (!password.equals(confirmPassword)) {
binding.etStaffConfirmPassword.setError("Passwords do not match");
binding.etStaffConfirmPassword.requestFocus();
return;
}
} else if (!password.isEmpty()) {
if (password.length() < 6) {
binding.etStaffPassword.setError("At least 6 characters");
binding.etStaffPassword.requestFocus();
return;
}
if (!password.equals(confirmPassword)) {
binding.etStaffConfirmPassword.setError("Passwords do not match");
binding.etStaffConfirmPassword.requestFocus();
return;
}
}
if (!InputValidator.isNotEmpty(binding.etStaffFirstName, "First Name")) return;
@@ -149,13 +161,12 @@ public class StaffDetailFragment extends Fragment {
if (!InputValidator.isSpinnerSelected(binding.spinnerStaffStore, "Primary Store")) return;
String username = binding.etStaffUsername.getText().toString().trim();
String password = binding.etStaffPassword.getText().toString().trim();
String firstName = binding.etStaffFirstName.getText().toString().trim();
String lastName = binding.etStaffLastName.getText().toString().trim();
String email = binding.etStaffEmail.getText().toString().trim();
String phone = binding.etStaffPhone.getText().toString().trim();
String role = ROLES[binding.spinnerStaffRole.getSelectedItemPosition()];
String staffRole = STAFF_ROLES[binding.spinnerStaffType.getSelectedItemPosition()];
String role = "STAFF";
String staffRole = null;
boolean active = binding.spinnerStaffStatus.getSelectedItemPosition() == 0;
List<DropdownDTO> stores = viewModel.getStoreList().getValue();

View File

@@ -20,8 +20,8 @@ public class SaleRepository extends BaseRepository {
this.saleApi = saleApi;
}
public LiveData<Resource<PageResponse<SaleDTO>>> getAllSales(int page, int size, String query, String paymentMethod, Long storeId, Boolean isRefund, String sortBy) {
return executeCall(saleApi.getAllSales(page, size, query, paymentMethod, storeId, isRefund, sortBy));
public LiveData<Resource<PageResponse<SaleDTO>>> getAllSales(int page, int size, String query, String paymentMethod, Long storeId, Boolean isRefund, Long customerId, String sortBy) {
return executeCall(saleApi.getAllSales(page, size, query, paymentMethod, storeId, isRefund, customerId, sortBy));
}
public LiveData<Resource<SaleDTO>> getSaleById(Long id) {

View File

@@ -100,13 +100,12 @@ public class InputValidator {
return true;
}
// Checks if the phone number is valid in (XXX) XXX-XXXX format
// Checks if the phone number is valid: XXX-XXX-XXXX, (XXX) XXX-XXXX, or XXXXXXXXXX
public static boolean isValidPhone(EditText field) {
String phone = field.getText().toString().trim();
// Matches (XXX) XXX-XXXX format
String pattern = "^\\(\\d{3}\\) \\d{3}-\\d{4}$";
String pattern = "^(\\(\\d{3}\\) \\d{3}-\\d{4}|\\d{3}-\\d{3}-\\d{4}|\\d{10})$";
if (phone.isEmpty() || !phone.matches(pattern)) {
field.setError("Enter a valid phone number: (XXX) XXX-XXXX");
field.setError("Enter a valid phone number");
field.requestFocus();
return false;
}

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.api.auth.TokenManager;
import com.example.petstoremobile.dtos.SaleDTO;
import com.example.petstoremobile.repositories.SaleRepository;
import com.example.petstoremobile.utils.Resource;
@@ -21,6 +22,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import javax.inject.Inject;
@@ -29,33 +31,40 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
@HiltViewModel
public class AnalyticsViewModel extends ViewModel {
private final SaleRepository saleRepository;
private final TokenManager tokenManager;
private final MutableLiveData<AnalyticsData> analyticsData = new MutableLiveData<>();
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
private final MutableLiveData<List<String>> availablePaymentMethods = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<String>> availableStores = new MutableLiveData<>(new ArrayList<>());
private List<SaleDTO> cachedSales = new ArrayList<>();
private FilterState currentFilter = new FilterState();
private String viewMode = "store";
private String storeFilter = "All Stores";
@Inject
public AnalyticsViewModel(SaleRepository saleRepository) {
public AnalyticsViewModel(SaleRepository saleRepository, TokenManager tokenManager) {
this.saleRepository = saleRepository;
this.tokenManager = tokenManager;
}
public LiveData<AnalyticsData> getAnalyticsData() { return analyticsData; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public LiveData<String> getErrorMessage() { return errorMessage; }
public LiveData<List<String>> getAvailablePaymentMethods() { return availablePaymentMethods; }
public LiveData<List<String>> getAvailableStores() { return availableStores; }
public void loadAnalytics() {
isLoading.setValue(true);
errorMessage.setValue(null);
observeOnce(saleRepository.getAllSales(0, 2000, null, null, null, null, "saleDate,desc"), resource -> {
observeOnce(saleRepository.getAllSales(0, 2000, null, null, null, null, null, "saleDate,desc"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
cachedSales = resource.data.getContent();
derivePaymentMethods();
deriveStores();
applyCurrentFilter();
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
@@ -73,14 +82,61 @@ public class AnalyticsViewModel extends ViewModel {
public void resetFilter() {
currentFilter = new FilterState();
storeFilter = "All Stores";
applyCurrentFilter();
}
public void setViewMode(String mode) {
viewMode = mode;
applyCurrentFilter();
}
public String getViewMode() {
return viewMode;
}
public void setStoreFilter(String store) {
storeFilter = (store != null && !store.isEmpty()) ? store : "All Stores";
applyCurrentFilter();
}
public String getStoreFilter() {
return storeFilter;
}
private void applyCurrentFilter() {
List<SaleDTO> filtered = filterSales(cachedSales, currentFilter);
List<SaleDTO> salesForMode;
if (viewMode.equals("mine")) {
String currentUser = tokenManager.getUsername();
salesForMode = cachedSales.stream()
.filter(s -> currentUser != null && currentUser.equalsIgnoreCase(s.getEmployeeName() != null ? s.getEmployeeName() : ""))
.collect(Collectors.toList());
} else {
salesForMode = cachedSales;
}
if (!storeFilter.equals("All Stores") && !storeFilter.isEmpty()) {
final String sf = storeFilter;
salesForMode = salesForMode.stream()
.filter(s -> sf.equalsIgnoreCase(s.getStoreName() != null ? s.getStoreName() : ""))
.collect(Collectors.toList());
}
List<SaleDTO> filtered = filterSales(salesForMode, currentFilter);
computeAnalytics(filtered, currentFilter);
}
private void deriveStores() {
java.util.Set<String> stores = new java.util.TreeSet<>();
for (SaleDTO s : cachedSales) {
if (s.getStoreName() != null && !s.getStoreName().isEmpty()) {
stores.add(s.getStoreName());
}
}
List<String> result = new ArrayList<>();
result.add("All Stores");
result.addAll(stores);
availableStores.setValue(result);
}
private void derivePaymentMethods() {
java.util.Set<String> methods = new java.util.TreeSet<>();
for (SaleDTO s : cachedSales) {

View File

@@ -35,7 +35,7 @@ public class RefundViewModel extends ViewModel {
}
public LiveData<Resource<PageResponse<SaleDTO>>> loadAllSales() {
return saleRepository.getAllSales(0, 1000, null, null, null, null, "saleDate,desc");
return saleRepository.getAllSales(0, 1000, null, null, null, null, null, "saleDate,desc");
}
public void setAllSales(List<SaleDTO> sales) {

View File

@@ -4,9 +4,11 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.dtos.CustomerDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.SaleDTO;
import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.repositories.CustomerRepository;
import com.example.petstoremobile.repositories.SaleRepository;
import com.example.petstoremobile.repositories.StoreRepository;
import com.example.petstoremobile.utils.Resource;
@@ -24,9 +26,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
public class SaleListViewModel extends ViewModel {
private final SaleRepository saleRepository;
private final StoreRepository storeRepository;
private final CustomerRepository customerRepository;
private final MutableLiveData<List<SaleDTO>> sales = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<CustomerDTO>> customers = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
@@ -34,17 +38,19 @@ public class SaleListViewModel extends ViewModel {
private static final int PAGE_SIZE = 20;
@Inject
public SaleListViewModel(SaleRepository saleRepository, StoreRepository storeRepository) {
public SaleListViewModel(SaleRepository saleRepository, StoreRepository storeRepository, CustomerRepository customerRepository) {
this.saleRepository = saleRepository;
this.storeRepository = storeRepository;
this.customerRepository = customerRepository;
}
public LiveData<List<SaleDTO>> getSales() { return sales; }
public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<List<CustomerDTO>> getCustomers() { return customers; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadSales(boolean reset, String query, String paymentMethod, Long storeId, Boolean isRefund) {
public void loadSales(boolean reset, String query, String paymentMethod, Long storeId, Boolean isRefund, Long customerId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
@@ -53,7 +59,7 @@ public class SaleListViewModel extends ViewModel {
}
isLoading.setValue(true);
observeOnce(saleRepository.getAllSales(currentPage, PAGE_SIZE, query, paymentMethod, storeId, isRefund, "saleDate,desc"), resource -> {
observeOnce(saleRepository.getAllSales(currentPage, PAGE_SIZE, query, paymentMethod, storeId, isRefund, customerId, "saleDate,desc"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
List<SaleDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(sales.getValue());
@@ -77,6 +83,14 @@ public class SaleListViewModel extends ViewModel {
});
}
public void loadCustomers() {
observeOnce(customerRepository.getAllCustomers(0, 500), resource -> {
if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) {
customers.setValue(resource.data.getContent());
}
});
}
private <T> void observeOnce(LiveData<Resource<T>> liveData, Observer<Resource<T>> handler) {
liveData.observeForever(new Observer<Resource<T>>() {
@Override

View File

@@ -45,6 +45,40 @@
</LinearLayout>
<LinearLayout
android:id="@+id/llViewModeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp">
<Button
android:id="@+id/btnMyAnalytics"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:text="My Analytics"
android:textSize="12sp"
android:backgroundTint="#CBD5E1"
android:textColor="@color/white"
android:layout_marginEnd="4dp"/>
<Button
android:id="@+id/btnStoreAnalytics"
android:layout_width="0dp"
android:layout_height="36dp"
android:layout_weight="1"
android:text="Store Analytics"
android:textSize="12sp"
android:backgroundTint="@color/primary_medium"
android:textColor="@color/white"
android:layout_marginStart="4dp"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -102,6 +136,23 @@
android:layout_marginTop="12dp"
android:visibility="gone">
<TextView
android:id="@+id/tvStoreFilterLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Store"
android:textColor="@color/text_light"
android:textSize="11sp"
android:layout_marginBottom="4dp"
android:visibility="gone"/>
<Spinner
android:id="@+id/spinnerFilterStore"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@@ -151,6 +151,15 @@
android:layout_marginStart="4dp"/>
</LinearLayout>
<Spinner
android:id="@+id/spinnerCustomer"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_spinner"
android:paddingStart="12dp"
android:paddingEnd="8dp"/>
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout

View File

@@ -444,6 +444,30 @@
android:textColor="@color/accent_coral"
android:layout_gravity="end"
android:layout_marginTop="12dp"/>
<LinearLayout
android:id="@+id/llPointsToEarn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_gravity="end"
android:layout_marginTop="4dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Points to earn: "
android:textColor="#4ECDC4"
android:textSize="13sp"/>
<TextView
android:id="@+id/tvPointsToEarn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="+0 pts"
android:textColor="#4ECDC4"
android:textSize="13sp"
android:textStyle="bold"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -101,6 +101,23 @@
android:inputType="textPassword"
android:layout_marginBottom="16dp"/>
<!-- Confirm Password -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Confirm Password"
android:textColor="@color/text_dark"
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<EditText
android:id="@+id/etStaffConfirmPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Re-enter password"
android:inputType="textPassword"
android:layout_marginBottom="16dp"/>
<!-- First Name -->
<TextView
android:layout_width="wrap_content"
@@ -169,35 +186,6 @@
android:inputType="phone"
android:layout_marginBottom="16dp"/>
<!-- Role -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="User Role"
android:textColor="@color/text_dark"
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<Spinner
android:id="@+id/spinnerStaffRole"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Staff Role"
android:textColor="@color/text_dark"
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<Spinner
android:id="@+id/spinnerStaffType"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@@ -74,6 +74,15 @@
android:textColor="#888888"
android:textSize="14sp" />
<TextView
android:id="@+id/tvSaleCustomer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#888888"
android:textSize="14sp"
android:visibility="gone" />
<TextView
android:id="@+id/tvSaleDate"
android:layout_width="wrap_content"

View File

@@ -1,2 +1,4 @@
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
OPENROUTER_API_KEY=sk-or-v1-...
RESEND_API_KEY=re_...

3
backend/.gitignore vendored
View File

@@ -44,7 +44,8 @@ build/
.env
### Project Specific ###
src/test/
!src/test/
!src/test/**
tmp/
uploads/*
!uploads/avatars/

View File

@@ -96,6 +96,12 @@
<version>25.3.0</version>
</dependency>
<dependency>
<groupId>com.resend</groupId>
<artifactId>resend-java</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

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

View File

@@ -8,6 +8,7 @@ import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.OpenRouterService;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.ContentFilter;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -49,6 +50,7 @@ public class AiChatController {
if (request.getMessage() == null || request.getMessage().isBlank()) {
return ResponseEntity.badRequest().body(AiChatResponse.fail("Message cannot be empty"));
}
ContentFilter.validate(request.getMessage());
User user = getCurrentUser();

View File

@@ -17,6 +17,7 @@ import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil;
import com.petshop.backend.service.ActivityLogService;
import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.EmailService;
import com.petshop.backend.service.PasswordResetService;
import com.petshop.backend.util.AuthenticationHelper;
import com.petshop.backend.util.PhoneUtils;
@@ -55,8 +56,9 @@ public class AuthController {
private final AvatarStorageService avatarStorageService;
private final ActivityLogService activityLogService;
private final PasswordResetService passwordResetService;
private final EmailService emailService;
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService) {
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) {
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
@@ -64,6 +66,7 @@ public class AuthController {
this.avatarStorageService = avatarStorageService;
this.activityLogService = activityLogService;
this.passwordResetService = passwordResetService;
this.emailService = emailService;
}
@PostMapping("/register")
@@ -108,6 +111,8 @@ public class AuthController {
User savedUser = userRepository.save(user);
emailService.sendWelcome(savedUser);
String token = jwtUtil.generateToken(savedUser);
return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse(

View File

@@ -3,6 +3,7 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.user.UserRequest;
import com.petshop.backend.dto.user.UserResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.service.UserService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
@@ -32,30 +33,30 @@ public class CustomerController {
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getCustomerById(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
return ResponseEntity.ok(userService.getUserById(id, User.Role.CUSTOMER));
}
@PostMapping
public ResponseEntity<UserResponse> createCustomer(@Valid @RequestBody UserRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request, User.Role.CUSTOMER));
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateCustomer(
@PathVariable Long id,
@Valid @RequestBody UserRequest request) {
return ResponseEntity.ok(userService.updateUser(id, request));
return ResponseEntity.ok(userService.updateUser(id, request, User.Role.CUSTOMER));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCustomer(@PathVariable Long id) {
userService.deleteUser(id);
userService.deleteUser(id, User.Role.CUSTOMER);
return ResponseEntity.noContent().build();
}
@PostMapping("/bulk-delete")
public ResponseEntity<Void> bulkDeleteCustomers(@Valid @RequestBody BulkDeleteRequest request) {
userService.bulkDeleteUsers(request);
userService.bulkDeleteUsers(request, User.Role.CUSTOMER);
return ResponseEntity.noContent().build();
}
}

View File

@@ -2,6 +2,7 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.user.UserRequest;
import com.petshop.backend.dto.user.UserResponse;
import com.petshop.backend.entity.User;
import com.petshop.backend.service.UserService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
@@ -31,24 +32,24 @@ public class EmployeeController {
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getEmployeeById(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
return ResponseEntity.ok(userService.getUserById(id, User.Role.STAFF));
}
@PostMapping
public ResponseEntity<UserResponse> createEmployee(@Valid @RequestBody UserRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request, User.Role.STAFF));
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateEmployee(
@PathVariable Long id,
@Valid @RequestBody UserRequest request) {
return ResponseEntity.ok(userService.updateUser(id, request));
return ResponseEntity.ok(userService.updateUser(id, request, User.Role.STAFF));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteEmployee(@PathVariable Long id) {
userService.deleteUser(id);
userService.deleteUser(id, User.Role.STAFF);
return ResponseEntity.noContent().build();
}
}

View File

@@ -30,7 +30,7 @@ public class RefundController {
}
@PostMapping
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF')")
public ResponseEntity<RefundResponse> createRefund(@Valid @RequestBody RefundRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String role = authentication.getAuthorities().stream()
@@ -85,7 +85,7 @@ public class RefundController {
}
@PutMapping("/{id}")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
@PreAuthorize("hasRole('STAFF')")
public ResponseEntity<RefundResponse> updateRefund(@PathVariable Long id, @Valid @RequestBody RefundUpdateRequest request) {
return ResponseEntity.ok(refundService.updateRefundStatus(id, request.getStatus()));
}

View File

@@ -28,8 +28,9 @@ public class SaleController {
@RequestParam(required = false) String paymentMethod,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) Boolean isRefund,
@RequestParam(required = false) Long customerId,
Pageable pageable) {
return ResponseEntity.ok(saleService.getAllSales(q, paymentMethod, storeId, isRefund, pageable));
return ResponseEntity.ok(saleService.getAllSales(q, paymentMethod, storeId, isRefund, customerId, pageable));
}
@GetMapping("/{id}")
@@ -39,7 +40,7 @@ public class SaleController {
}
@PostMapping
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
@PreAuthorize("hasRole('STAFF')")
public ResponseEntity<SaleResponse> createSale(@Valid @RequestBody SaleRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(saleService.createSale(request));
}

View File

@@ -25,8 +25,9 @@ public class ServiceController {
@GetMapping
public ResponseEntity<Page<ServiceResponse>> getAllServices(
@RequestParam(required = false) String q,
@RequestParam(required = false) String species,
Pageable pageable) {
return ResponseEntity.ok(serviceService.getAllServices(q, pageable));
return ResponseEntity.ok(serviceService.getAllServices(q, species, pageable));
}
@GetMapping("/{id}")

View File

@@ -24,7 +24,7 @@ public class UserAvatarController {
}
@GetMapping("/{userId}/avatar/file")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Resource> getUserAvatarFile(@PathVariable Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user == null || !avatarStorageService.hasAvatar(user)) {

View File

@@ -11,21 +11,19 @@ public class PurchaseOrderResponse {
private Long storeId;
private String storeName;
private LocalDate orderDate;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public PurchaseOrderResponse() {
}
public PurchaseOrderResponse(Long purchaseOrderId, Long supId, String supplierName, Long storeId, String storeName, LocalDate orderDate, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
public PurchaseOrderResponse(Long purchaseOrderId, Long supId, String supplierName, Long storeId, String storeName, LocalDate orderDate, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.purchaseOrderId = purchaseOrderId;
this.supId = supId;
this.supplierName = supplierName;
this.storeId = storeId;
this.storeName = storeName;
this.orderDate = orderDate;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
@@ -78,14 +76,6 @@ public class PurchaseOrderResponse {
this.orderDate = orderDate;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
@@ -107,12 +97,12 @@ public class PurchaseOrderResponse {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PurchaseOrderResponse that = (PurchaseOrderResponse) o;
return Objects.equals(purchaseOrderId, that.purchaseOrderId) && Objects.equals(supId, that.supId) && Objects.equals(supplierName, that.supplierName) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName) && Objects.equals(orderDate, that.orderDate) && Objects.equals(status, that.status) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
return Objects.equals(purchaseOrderId, that.purchaseOrderId) && Objects.equals(supId, that.supId) && Objects.equals(supplierName, that.supplierName) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName) && Objects.equals(orderDate, that.orderDate) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
}
@Override
public int hashCode() {
return Objects.hash(purchaseOrderId, supId, supplierName, storeId, storeName, orderDate, status, createdAt, updatedAt);
return Objects.hash(purchaseOrderId, supId, supplierName, storeId, storeName, orderDate, createdAt, updatedAt);
}
@Override
@@ -124,7 +114,6 @@ public class PurchaseOrderResponse {
", storeId=" + storeId +
", storeName='" + storeName + '\'' +
", orderDate=" + orderDate +
", status='" + status + '\'' +
", createdAt=" + createdAt +
", updatedAt=" + updatedAt +
'}';

View File

@@ -28,6 +28,8 @@ public class SaleRequest {
private Long cartId;
private Integer pointsUsed;
public Long getStoreId() {
return storeId;
@@ -101,6 +103,14 @@ public class SaleRequest {
this.cartId = cartId;
}
public Integer getPointsUsed() {
return pointsUsed;
}
public void setPointsUsed(Integer pointsUsed) {
this.pointsUsed = pointsUsed;
}
@Override
public boolean equals(Object o) {
@@ -115,12 +125,13 @@ public class SaleRequest {
Objects.equals(customerId, that.customerId) &&
Objects.equals(channel, that.channel) &&
Objects.equals(couponId, that.couponId) &&
Objects.equals(cartId, that.cartId);
Objects.equals(cartId, that.cartId) &&
Objects.equals(pointsUsed, that.pointsUsed);
}
@Override
public int hashCode() {
return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId);
return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId, pointsUsed);
}
@Override
@@ -135,6 +146,7 @@ public class SaleRequest {
", channel='" + channel + '\'' +
", couponId=" + couponId +
", cartId=" + cartId +
", pointsUsed=" + pointsUsed +
'}';
}
}

View File

@@ -27,9 +27,6 @@ public class PurchaseOrder {
@Column(nullable = false)
private LocalDate orderDate;
@Column(nullable = false, length = 50)
private String status;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@@ -41,11 +38,10 @@ public class PurchaseOrder {
public PurchaseOrder() {
}
public PurchaseOrder(Long purchaseOrderId, Supplier supplier, LocalDate orderDate, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
public PurchaseOrder(Long purchaseOrderId, Supplier supplier, LocalDate orderDate, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.purchaseOrderId = purchaseOrderId;
this.supplier = supplier;
this.orderDate = orderDate;
this.status = status;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
@@ -82,14 +78,6 @@ public class PurchaseOrder {
this.orderDate = orderDate;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
@@ -125,7 +113,6 @@ public class PurchaseOrder {
"purchaseOrderId=" + purchaseOrderId +
", supplier=" + supplier +
", orderDate=" + orderDate +
", status='" + status + '\'' +
", createdAt=" + createdAt +
", updatedAt=" + updatedAt +
'}';

View File

@@ -42,9 +42,10 @@ public class GlobalExceptionHandler {
errors.put(fieldName, errorMessage);
});
String firstMessage = errors.values().stream().findFirst().orElse("Validation failed");
Map<String, Object> response = new HashMap<>();
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("message", "Validation failed");
response.put("message", firstMessage);
response.put("errors", errors);
response.put("details", buildDetails(ex));
response.put("path", request.getRequestURI());

View File

@@ -40,4 +40,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
boolean existsByPet_IdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus);
List<Adoption> findByCustomer_IdAndAdoptionStatusIgnoreCase(Long customerId, String adoptionStatus);
List<Adoption> findByAdoptionDateAndAdoptionStatusIgnoreCase(LocalDate date, String status);
}

View File

@@ -52,4 +52,6 @@ public interface AppointmentRepository extends JpaRepository<Appointment, Long>
List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
List<Appointment> findByPet_Id(Long petId);
List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status);
}

View File

@@ -11,6 +11,10 @@ import org.springframework.stereotype.Repository;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
boolean existsByProdNameIgnoreCase(String prodName);
boolean existsByProdNameIgnoreCaseAndProdIdNot(String prodName, Long prodId);
@Query("SELECT p FROM Product p WHERE " +
"(:q IS NULL OR LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.prodDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:categoryId IS NULL OR p.category.categoryId = :categoryId)")

View File

@@ -22,8 +22,9 @@ public interface SaleRepository extends JpaRepository<Sale, Long> {
")) AND " +
"(:paymentMethod IS NULL OR LOWER(s.paymentMethod) = LOWER(:paymentMethod)) AND " +
"(:isRefund IS NULL OR s.isRefund = :isRefund) AND " +
"(:storeId IS NULL OR s.store.storeId = :storeId)")
Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, Pageable pageable);
"(:storeId IS NULL OR s.store.storeId = :storeId) AND " +
"(:customerId IS NULL OR s.customer.id = :customerId)")
Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, @Param("customerId") Long customerId, Pageable pageable);
List<Sale> findByOriginalSaleSaleId(Long originalSaleId);

View File

@@ -11,8 +11,8 @@ import org.springframework.stereotype.Repository;
@Repository
public interface ServiceRepository extends JpaRepository<Service, Long> {
@Query("SELECT s FROM Service s WHERE " +
"LOWER(s.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(s.serviceDesc) LIKE LOWER(CONCAT('%', :q, '%'))")
Page<Service> searchServices(@Param("q") String query, Pageable pageable);
@Query("SELECT DISTINCT s FROM Service s LEFT JOIN s.species sp WHERE " +
"(:q IS NULL OR LOWER(s.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(s.serviceDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(sp) = LOWER(:species))")
Page<Service> searchServices(@Param("q") String q, @Param("species") String species, Pageable pageable);
}

View File

@@ -0,0 +1,62 @@
package com.petshop.backend.security;
import com.petshop.backend.exception.ApiErrorResponder;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private static final Map<String, int[]> RULES = Map.of(
"/api/v1/auth/login", new int[]{10, 15},
"/api/v1/auth/register", new int[]{5, 60},
"/api/v1/auth/forgot-password", new int[]{3, 10},
"/api/v1/auth/reset-password", new int[]{10, 15}
);
private final RateLimiterService rateLimiterService;
private final ApiErrorResponder apiErrorResponder;
public RateLimitFilter(RateLimiterService rateLimiterService, ApiErrorResponder apiErrorResponder) {
this.rateLimiterService = rateLimiterService;
this.apiErrorResponder = apiErrorResponder;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
int[] rule = RULES.get(path);
if (rule != null) {
String ip = extractIp(request);
String key = path + ":" + ip;
if (!rateLimiterService.isAllowed(key, rule[0], Duration.ofMinutes(rule[1]))) {
apiErrorResponder.write(response, HttpStatus.TOO_MANY_REQUESTS,
"Too many requests. Please try again later.", null, path);
return;
}
}
filterChain.doFilter(request, response);
}
private String extractIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -0,0 +1,45 @@
package com.petshop.backend.security;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RateLimiterService {
private final Map<String, Deque<Instant>> buckets = new ConcurrentHashMap<>();
public boolean isAllowed(String key, int maxRequests, Duration window) {
Instant now = Instant.now();
Instant windowStart = now.minus(window);
Deque<Instant> timestamps = buckets.computeIfAbsent(key, k -> new ArrayDeque<>());
synchronized (timestamps) {
while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(windowStart)) {
timestamps.pollFirst();
}
if (timestamps.size() >= maxRequests) {
return false;
}
timestamps.addLast(now);
return true;
}
}
@Scheduled(fixedDelay = 300_000)
public void evictStale() {
Instant cutoff = Instant.now().minus(Duration.ofHours(2));
buckets.entrySet().removeIf(entry -> {
Deque<Instant> timestamps = entry.getValue();
synchronized (timestamps) {
return timestamps.isEmpty() || timestamps.peekLast().isBefore(cutoff);
}
});
}
}

View File

@@ -31,15 +31,18 @@ import java.util.List;
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final RateLimitFilter rateLimitFilter;
private final UserDetailsService userDetailsService;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final RestAccessDeniedHandler restAccessDeniedHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
RateLimitFilter rateLimitFilter,
UserDetailsService userDetailsService,
RestAuthenticationEntryPoint restAuthenticationEntryPoint,
RestAccessDeniedHandler restAccessDeniedHandler) {
this.jwtAuthFilter = jwtAuthFilter;
this.rateLimitFilter = rateLimitFilter;
this.userDetailsService = userDetailsService;
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
this.restAccessDeniedHandler = restAccessDeniedHandler;
@@ -76,6 +79,7 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authenticationProvider(daoAuthenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(rateLimitFilter, JwtAuthenticationFilter.class);
http.addFilterAfter(activityLoggingFilter, JwtAuthenticationFilter.class);
return http.build();

View File

@@ -15,6 +15,7 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@@ -52,6 +53,11 @@ public class ActivityLogService {
entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null);
entry.setActivity(activity.trim());
activityLogRepository.save(entry);
log.info("[ACTIVITY] {} | {} | {} | {}",
entry.getRoleSnapshot(),
entry.getUsernameSnapshot(),
entry.getStoreNameSnapshot() != null ? entry.getStoreNameSnapshot() : "no store",
entry.getActivity());
} catch (Exception ex) {
log.warn("Failed to persist activity log", ex);
}
@@ -65,7 +71,7 @@ public class ActivityLogService {
}
@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) -> {
List<Predicate> predicates = new ArrayList<>();
@@ -87,6 +93,14 @@ public class ActivityLogService {
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]));
};
@@ -96,9 +110,14 @@ public class ActivityLogService {
.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)
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) {

View File

@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class AdoptionService {
@@ -39,13 +40,15 @@ public class AdoptionService {
private final UserRepository userRepository;
private final StoreRepository storeRepository;
private final SaleRepository saleRepository;
private final EmailService emailService;
public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository) {
public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository, EmailService emailService) {
this.adoptionRepository = adoptionRepository;
this.petRepository = petRepository;
this.userRepository = userRepository;
this.storeRepository = storeRepository;
this.saleRepository = saleRepository;
this.emailService = emailService;
}
public Page<AdoptionResponse> getAllAdoptions(String query, Long customerId, String status, Long storeId, LocalDate date, Pageable pageable) {
@@ -106,6 +109,9 @@ public class AdoptionService {
if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
createSaleForAdoption(adoption, request.getPaymentMethod());
}
if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
emailService.sendAdoptionConfirmation(adoption);
}
return mapToResponse(adoption);
}
@@ -125,6 +131,7 @@ public class AdoptionService {
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId()))
: null;
boolean wasCompleted = ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoption.getAdoptionStatus());
String previousStatus = adoption.getAdoptionStatus();
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null;
validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId);
@@ -144,6 +151,10 @@ public class AdoptionService {
if (!wasCompleted && ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
createSaleForAdoption(adoption, request.getPaymentMethod());
}
boolean statusChanged = !adoptionStatus.equalsIgnoreCase(previousStatus);
if (statusChanged && (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus))) {
emailService.sendAdoptionConfirmation(adoption);
}
return mapToResponse(adoption);
}
@@ -200,15 +211,37 @@ public class AdoptionService {
@Transactional
public void deleteAdoption(Long id) {
if (!adoptionRepository.existsById(id)) {
throw new ResourceNotFoundException("Adoption not found with id: " + id);
}
adoptionRepository.deleteById(id);
Adoption adoption = adoptionRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + id));
Pet pet = adoption.getPet();
String status = adoption.getAdoptionStatus();
adoptionRepository.delete(adoption);
resetPetIfPending(pet, status);
}
@Transactional
public void bulkDeleteAdoptions(BulkDeleteRequest request) {
adoptionRepository.deleteAllById(request.getIds());
List<Adoption> adoptions = adoptionRepository.findAllById(request.getIds());
adoptionRepository.deleteAll(adoptions);
for (Adoption adoption : adoptions) {
resetPetIfPending(adoption.getPet(), adoption.getAdoptionStatus());
}
}
private void resetPetIfPending(Pet pet, String deletedAdoptionStatus) {
if (!ADOPTION_STATUS_PENDING.equalsIgnoreCase(deletedAdoptionStatus)) {
return;
}
if (!PET_STATUS_PENDING.equalsIgnoreCase(pet.getPetStatus())) {
return;
}
boolean completedElsewhere = adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCase(
pet.getPetId(), ADOPTION_STATUS_COMPLETED);
if (!completedElsewhere) {
pet.setPetStatus(PET_STATUS_AVAILABLE);
pet.setOwner(null);
petRepository.save(pet);
}
}
private String normalizeFilter(String value) {
@@ -309,7 +342,8 @@ public class AdoptionService {
sale.setPaymentMethod(paymentMethod != null && !paymentMethod.isBlank() ? paymentMethod : "Cash");
sale.setIsRefund(false);
sale.setChannel("ADOPTION");
saleRepository.save(sale);
Sale savedSale = saleRepository.save(sale);
emailService.sendPurchaseReceipt(savedSale);
}
private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId, User customer) {

View File

@@ -3,11 +3,14 @@ package com.petshop.backend.service;
import com.petshop.backend.dto.appointment.AppointmentRequest;
import com.petshop.backend.dto.appointment.AppointmentResponse;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.Adoption;
import com.petshop.backend.entity.Appointment;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.AdoptionRepository;
import com.petshop.backend.repository.AppointmentRepository;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.ServiceRepository;
@@ -26,6 +29,7 @@ import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@@ -36,13 +40,17 @@ public class AppointmentService {
private final PetRepository petRepository;
private final StoreRepository storeRepository;
private final UserRepository userRepository;
private final AdoptionRepository adoptionRepository;
private final EmailService emailService;
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository) {
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, AdoptionRepository adoptionRepository, EmailService emailService) {
this.appointmentRepository = appointmentRepository;
this.serviceRepository = serviceRepository;
this.petRepository = petRepository;
this.storeRepository = storeRepository;
this.userRepository = userRepository;
this.adoptionRepository = adoptionRepository;
this.emailService = emailService;
}
@Transactional(readOnly = true)
@@ -118,6 +126,7 @@ public class AppointmentService {
validateSpeciesServiceCompatibility(pet, service);
validateStoreAccess(store.getStoreId(), authenticatedUser);
validatePetServiceCompatibility(pet, service);
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
Appointment appointment = new Appointment();
@@ -131,6 +140,7 @@ public class AppointmentService {
appointment.setPet(pet);
appointment = appointmentRepository.save(appointment);
emailService.sendAppointmentConfirmation(appointment);
return mapToResponse(appointment);
}
@@ -156,6 +166,7 @@ public class AppointmentService {
User employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId());
validateStoreAccess(store.getStoreId(), authenticatedUser);
validatePetServiceCompatibility(pet, service);
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), id);
appointment.setCustomer(customer);
@@ -168,6 +179,7 @@ public class AppointmentService {
appointment.setEmployee(employee);
appointment = appointmentRepository.save(appointment);
emailService.sendAppointmentConfirmation(appointment);
return mapToResponse(appointment);
}
@@ -243,7 +255,7 @@ public class AppointmentService {
return availableSlots;
}
//Update booked status to completed at every midnight
//Update booked status to completed at every midnight, and send 24h reminders
@Scheduled(cron = "0 0 0 * * ?")
@Transactional
public void updatePastAppointmentsStatus() {
@@ -255,6 +267,20 @@ public class AppointmentService {
appointment.setAppointmentStatus("COMPLETED");
appointmentRepository.save(appointment);
}
LocalDate tomorrow = currentDate.plusDays(1);
List<Appointment> tomorrowAppointments = appointmentRepository
.findByAppointmentDateAndAppointmentStatusIgnoreCase(tomorrow, "Booked");
for (Appointment appointment : tomorrowAppointments) {
emailService.sendAppointmentReminder(appointment);
}
List<Adoption> tomorrowAdoptions = adoptionRepository
.findByAdoptionDateAndAdoptionStatusIgnoreCase(tomorrow, "Pending");
for (Adoption adoption : tomorrowAdoptions) {
emailService.sendAdoptionReminder(adoption);
}
}
private String normalizeFilter(String value) {
@@ -265,6 +291,17 @@ public class AppointmentService {
return trimmed.isEmpty() ? null : trimmed;
}
private void validatePetServiceCompatibility(Pet pet, com.petshop.backend.entity.Service service) {
if (pet == null) return;
Set<String> allowed = service.getSpecies();
if (allowed == null || allowed.isEmpty()) return;
boolean compatible = allowed.stream().anyMatch(s -> s.equalsIgnoreCase(pet.getPetSpecies()));
if (!compatible) {
throw new BusinessException(
"Service \"" + service.getServiceName() + "\" is not available for " + pet.getPetSpecies());
}
}
private void validateAppointmentRequest(AppointmentRequest request) {
if ("Booked".equalsIgnoreCase(request.getAppointmentStatus())) {
LocalDateTime appointmentDateTime = LocalDateTime.of(request.getAppointmentDate(), request.getAppointmentTime());

View File

@@ -22,7 +22,6 @@ import java.util.UUID;
public class AvatarStorageService {
private static final String STORED_PREFIX = "/uploads/avatars/";
private static final String OWNER_ENDPOINT = "/api/v1/auth/me/avatar/file";
@Value("${app.upload.base-dir:uploads}")
private String uploadBaseDir;
@@ -65,7 +64,7 @@ public class AvatarStorageService {
}
public String toOwnerAvatarUrl(User user) {
return hasAvatar(user) ? OWNER_ENDPOINT : null;
return hasAvatar(user) ? "/api/v1/users/" + user.getId() + "/avatar/file" : null;
}
public String toStoredAvatarUrl(String avatarFilenamePath) {

View File

@@ -9,6 +9,7 @@ import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.util.ContentFilter;
import com.petshop.backend.repository.ConversationRepository;
import com.petshop.backend.repository.MessageRepository;
import com.petshop.backend.repository.UserRepository;
@@ -31,17 +32,20 @@ public class ChatService {
private final UserRepository userRepository;
private final AvatarStorageService avatarStorageService;
private final ChatAttachmentStorageService attachmentStorageService;
private final EmailService emailService;
public ChatService(ConversationRepository conversationRepository,
MessageRepository messageRepository,
UserRepository userRepository,
AvatarStorageService avatarStorageService,
ChatAttachmentStorageService attachmentStorageService) {
ChatAttachmentStorageService attachmentStorageService,
EmailService emailService) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.userRepository = userRepository;
this.avatarStorageService = avatarStorageService;
this.attachmentStorageService = attachmentStorageService;
this.emailService = emailService;
}
@Transactional
@@ -135,6 +139,8 @@ public class ChatService {
}
}
ContentFilter.validate(request.getContent());
Message message = new Message();
message.setConversationId(conversationId);
message.setSenderId(userId);
@@ -261,13 +267,19 @@ public class ChatService {
}
conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus()));
conversation = conversationRepository.save(conversation);
Conversation savedConversation = conversationRepository.save(conversation);
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
if (Conversation.ConversationStatus.CLOSED.name().equals(request.getStatus())) {
userRepository.findById(savedConversation.getCustomerId()).ifPresent(customer ->
emailService.sendChatTranscript(savedConversation, messages, customer));
}
Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
Long lastSenderId = last != null ? last.getSenderId() : null;
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
return ConversationResponse.fromEntity(savedConversation, lastMessage, lastSenderId);
}
public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {

View File

@@ -0,0 +1,308 @@
package com.petshop.backend.service;
import com.petshop.backend.entity.Adoption;
import com.petshop.backend.entity.Appointment;
import com.petshop.backend.entity.Conversation;
import com.petshop.backend.entity.Message;
import com.petshop.backend.entity.Sale;
import com.petshop.backend.entity.SaleItem;
import com.petshop.backend.entity.User;
import com.resend.Resend;
import com.resend.services.emails.model.CreateEmailOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
public class EmailService {
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("MMMM d, yyyy");
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("h:mm a");
private final Resend resend;
private final String from;
private final String frontendUrl;
private final ActivityLogService activityLogService;
public EmailService(
@Value("${resend.api-key}") String apiKey,
@Value("${resend.from}") String from,
@Value("${app.frontend-url}") String frontendUrl,
ActivityLogService activityLogService) {
this.resend = new Resend(apiKey);
this.from = from;
this.frontendUrl = frontendUrl;
this.activityLogService = activityLogService;
}
public void sendWelcome(User user) {
if (user.getEmail() == null || user.getEmail().isBlank()) return;
String subject = "Welcome to PetShop!";
String html = """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Welcome to PetShop, %s!</h2>
<p>Your account has been created successfully.</p>
<p>You can now log in and start exploring our pets, products, and services.</p>
<p>Thank you for joining us!</p>
</div>""".formatted(firstName(user));
send(user.getId(), user.getEmail(), subject, html);
}
public void sendPasswordResetLink(User user, String rawToken) {
if (user.getEmail() == null || user.getEmail().isBlank()) return;
String link = frontendUrl + "/reset-password?token=" + rawToken;
String subject = "Reset your PetShop password";
String html = """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Password Reset Request</h2>
<p>Hi %s,</p>
<p>We received a request to reset your password. Click the button below to proceed.</p>
<p><a href="%s" style="background:#2563eb;color:#fff;padding:10px 20px;border-radius:4px;text-decoration:none;display:inline-block">Reset Password</a></p>
<p>This link expires in <strong>30 minutes</strong>. If you did not request a reset, you can safely ignore this email.</p>
</div>""".formatted(firstName(user), link);
send(user.getId(), user.getEmail(), subject, html);
}
public void sendPurchaseReceipt(Sale sale) {
User customer = sale.getCustomer();
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
String subject = "Your PetShop receipt";
String html = buildReceiptHtml(sale);
send(customer.getId(), customer.getEmail(), subject, html);
}
public void sendAdoptionConfirmation(Adoption adoption) {
User customer = adoption.getCustomer();
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
String subject = "Your adoption — " + adoption.getPet().getPetName();
send(customer.getId(), customer.getEmail(), subject, buildAdoptionHtml(adoption, customer));
}
User employee = adoption.getEmployee();
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
String subject = "Adoption assigned — " + adoption.getPet().getPetName();
send(employee.getId(), employee.getEmail(), subject, buildAdoptionHtml(adoption, customer));
}
}
public void sendAdoptionReminder(Adoption adoption) {
User customer = adoption.getCustomer();
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName();
send(customer.getId(), customer.getEmail(), subject, buildAdoptionReminderHtml(adoption));
}
User employee = adoption.getEmployee();
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName();
send(employee.getId(), employee.getEmail(), subject, buildAdoptionReminderHtml(adoption));
}
}
public void sendAppointmentConfirmation(Appointment appointment) {
User customer = appointment.getCustomer();
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
String subject = "Appointment confirmed — " + appointment.getService().getServiceName();
send(customer.getId(), customer.getEmail(), subject, buildAppointmentHtml(appointment));
}
User employee = appointment.getEmployee();
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
String subject = "Appointment assigned — " + appointment.getService().getServiceName();
send(employee.getId(), employee.getEmail(), subject, buildAppointmentHtml(appointment));
}
}
public void sendAppointmentReminder(Appointment appointment) {
User customer = appointment.getCustomer();
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName();
send(customer.getId(), customer.getEmail(), subject, buildAppointmentReminderHtml(appointment));
}
User employee = appointment.getEmployee();
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName();
send(employee.getId(), employee.getEmail(), subject, buildAppointmentReminderHtml(appointment));
}
}
public void sendChatTranscript(Conversation conversation, List<Message> messages, User customer) {
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
String subject = "Your PetShop support transcript";
String html = buildTranscriptHtml(conversation, messages, customer);
send(customer.getId(), customer.getEmail(), subject, html);
}
private void send(Long recipientUserId, String to, String subject, String html) {
try {
CreateEmailOptions options = CreateEmailOptions.builder()
.from(from)
.to(List.of(to))
.subject(subject)
.html(html)
.build();
resend.emails().send(options);
activityLogService.record(recipientUserId, "Email sent: " + subject + " → " + to);
} catch (Exception ex) {
log.error("Failed to send email '{}' to {}: {}", subject, to, ex.getMessage());
activityLogService.record(recipientUserId, "Email failed: " + subject + " → " + to);
}
}
private String buildReceiptHtml(Sale sale) {
StringBuilder rows = new StringBuilder();
List<SaleItem> items = sale.getItems();
if (items != null) {
for (SaleItem item : items) {
String name = item.getProduct() != null ? item.getProduct().getProdName() : "—";
rows.append("<tr><td style='padding:6px 8px'>").append(esc(name)).append("</td>")
.append("<td style='padding:6px 8px;text-align:center'>").append(item.getQuantity()).append("</td>")
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice())).append("</td>")
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))).append("</td></tr>");
}
}
String date = sale.getSaleDate() != null ? sale.getSaleDate().format(DateTimeFormatter.ofPattern("MMMM d, yyyy h:mm a")) : "—";
return """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Thank you for your purchase!</h2>
<p><strong>Date:</strong> %s</p>
<p><strong>Payment method:</strong> %s</p>
<table style="width:100%%;border-collapse:collapse;margin:16px 0">
<thead><tr style="background:#f3f4f6">
<th style="padding:6px 8px;text-align:left">Item</th>
<th style="padding:6px 8px;text-align:center">Qty</th>
<th style="padding:6px 8px;text-align:right">Unit price</th>
<th style="padding:6px 8px;text-align:right">Total</th>
</tr></thead>
<tbody>%s</tbody>
</table>
<p><strong>Subtotal:</strong> $%s</p>
%s
%s
%s
<p style="font-size:1.1em"><strong>Total:</strong> $%s</p>
%s
</div>""".formatted(
date,
esc(sale.getPaymentMethod()),
rows.toString(),
fmt(sale.getSubtotalAmount()),
discountLine("Coupon discount", sale.getCouponDiscountAmount()),
discountLine("Employee discount", sale.getEmployeeDiscountAmount()),
discountLine("Loyalty discount", sale.getLoyaltyDiscountAmount()),
fmt(sale.getTotalAmount()),
sale.getPointsEarned() != null && sale.getPointsEarned() > 0
? "<p>You earned <strong>" + sale.getPointsEarned() + " loyalty points</strong> on this order.</p>"
: ""
);
}
private String buildAdoptionHtml(Adoption adoption, User customer) {
String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—";
String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—";
String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—";
String customerName = customer != null ? firstName(customer) : "—";
return """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Adoption update — %s</h2>
<p><strong>Customer:</strong> %s</p>
<p><strong>Pet:</strong> %s</p>
<p><strong>Date:</strong> %s</p>
<p><strong>Store:</strong> %s</p>
<p><strong>Status:</strong> %s</p>
</div>""".formatted(esc(petName), esc(customerName), esc(petName), date, esc(storeName), esc(adoption.getAdoptionStatus()));
}
private String buildAdoptionReminderHtml(Adoption adoption) {
String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—";
String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—";
String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—";
return """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Reminder: adoption tomorrow</h2>
<p>This is a reminder that the adoption of <strong>%s</strong> is scheduled for <strong>%s</strong> at <strong>%s</strong>.</p>
</div>""".formatted(esc(petName), date, esc(storeName));
}
private String buildAppointmentHtml(Appointment appointment) {
String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—";
String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—";
String date = appointment.getAppointmentDate() != null ? appointment.getAppointmentDate().format(DATE_FMT) : "—";
String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "—";
String employee = appointment.getEmployee() != null
? appointment.getEmployee().getFirstName() + " " + appointment.getEmployee().getLastName() : "—";
String pet = appointment.getPet() != null ? appointment.getPet().getPetName() : null;
return """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Appointment confirmed</h2>
<p><strong>Service:</strong> %s</p>
<p><strong>Date:</strong> %s</p>
<p><strong>Time:</strong> %s</p>
<p><strong>Location:</strong> %s</p>
<p><strong>Staff:</strong> %s</p>
%s
</div>""".formatted(esc(service), date, time, esc(store), esc(employee),
pet != null ? "<p><strong>Pet:</strong> " + esc(pet) + "</p>" : "");
}
private String buildAppointmentReminderHtml(Appointment appointment) {
String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—";
String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—";
String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "—";
return """
<div style="font-family:sans-serif;max-width:600px;margin:auto">
<h2>Reminder: appointment tomorrow</h2>
<p>Your <strong>%s</strong> appointment is scheduled for tomorrow at <strong>%s</strong> at <strong>%s</strong>.</p>
</div>""".formatted(esc(service), time, esc(store));
}
private String buildTranscriptHtml(Conversation conversation, List<Message> messages, User customer) {
StringBuilder rows = new StringBuilder();
for (Message msg : messages) {
String sender = msg.getSenderId() != null && msg.getSenderId().equals(customer.getId())
? firstName(customer) : "Support";
String ts = msg.getTimestamp() != null
? msg.getTimestamp().format(DateTimeFormatter.ofPattern("MMM d, h:mm a")) : "";
String content = msg.getContent() != null ? esc(msg.getContent()) : "";
rows.append("<tr><td style='padding:6px 8px;white-space:nowrap'>").append(ts).append("</td>")
.append("<td style='padding:6px 8px;white-space:nowrap'>").append(esc(sender)).append("</td>")
.append("<td style='padding:6px 8px'>").append(content).append("</td></tr>");
}
return """
<div style="font-family:sans-serif;max-width:700px;margin:auto">
<h2>Support chat transcript</h2>
<p>Hi %s, here is the transcript of your support conversation.</p>
<table style="width:100%%;border-collapse:collapse;margin:16px 0;font-size:0.9em">
<thead><tr style="background:#f3f4f6">
<th style="padding:6px 8px;text-align:left">Time</th>
<th style="padding:6px 8px;text-align:left">Sender</th>
<th style="padding:6px 8px;text-align:left">Message</th>
</tr></thead>
<tbody>%s</tbody>
</table>
</div>""".formatted(firstName(customer), rows.toString());
}
private String firstName(User user) {
if (user.getFirstName() != null && !user.getFirstName().isBlank()) return esc(user.getFirstName());
if (user.getFullName() != null && !user.getFullName().isBlank()) return esc(user.getFullName().split("\\s+")[0]);
return esc(user.getUsername());
}
private String fmt(BigDecimal value) {
return value != null ? String.format("%.2f", value) : "0.00";
}
private String discountLine(String label, BigDecimal value) {
if (value == null || value.compareTo(BigDecimal.ZERO) == 0) return "";
return "<p><strong>" + label + ":</strong> -$" + fmt(value) + "</p>";
}
private String esc(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
}
}

View File

@@ -28,14 +28,17 @@ public class PasswordResetService {
private final PasswordResetTokenRepository passwordResetTokenRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
private final SecureRandom secureRandom = new SecureRandom();
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
UserRepository userRepository,
PasswordEncoder passwordEncoder) {
PasswordEncoder passwordEncoder,
EmailService emailService) {
this.passwordResetTokenRepository = passwordResetTokenRepository;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.emailService = emailService;
}
@Transactional
@@ -66,9 +69,11 @@ public class PasswordResetService {
resetToken.setExpiresAt(now.plusMinutes(RESET_TOKEN_MINUTES));
passwordResetTokenRepository.save(resetToken);
emailService.sendPasswordResetLink(managedUser, rawToken);
return new ForgotPasswordResponse(
"If an account matches that username or email, a reset token has been generated.",
rawToken
"If an account matches that username or email, a reset link has been sent.",
null
);
}

View File

@@ -5,6 +5,7 @@ import com.petshop.backend.dto.product.ProductRequest;
import com.petshop.backend.dto.product.ProductResponse;
import com.petshop.backend.entity.Category;
import com.petshop.backend.entity.Product;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.CategoryRepository;
import com.petshop.backend.repository.ProductRepository;
@@ -45,6 +46,10 @@ public class ProductService {
@Transactional
public ProductResponse createProduct(ProductRequest request) {
if (productRepository.existsByProdNameIgnoreCase(request.getProdName())) {
throw new BusinessException("A product with this name already exists");
}
Category category = categoryRepository.findById(request.getCategoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
@@ -63,6 +68,10 @@ public class ProductService {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
if (productRepository.existsByProdNameIgnoreCaseAndProdIdNot(request.getProdName(), id)) {
throw new BusinessException("A product with this name already exists");
}
Category category = categoryRepository.findById(request.getCategoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));

View File

@@ -47,7 +47,6 @@ public class PurchaseOrderService {
store != null ? store.getStoreId() : null,
store != null ? store.getStoreName() : null,
purchaseOrder.getOrderDate(),
purchaseOrder.getStatus(),
purchaseOrder.getCreatedAt(),
purchaseOrder.getUpdatedAt()
);

View File

@@ -31,8 +31,9 @@ public class SaleService {
private final UserRepository userRepository;
private final CouponRepository couponRepository;
private final CartRepository cartRepository;
private final EmailService emailService;
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository) {
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository, EmailService emailService) {
this.saleRepository = saleRepository;
this.productRepository = productRepository;
this.storeRepository = storeRepository;
@@ -40,11 +41,12 @@ public class SaleService {
this.userRepository = userRepository;
this.couponRepository = couponRepository;
this.cartRepository = cartRepository;
this.emailService = emailService;
}
@Transactional(readOnly = true)
public Page<SaleResponse> getAllSales(String query, String paymentMethod, Long storeId, Boolean isRefund, Pageable pageable) {
Page<Sale> sales = saleRepository.searchSales(normalizeFilter(query), normalizeFilter(paymentMethod), storeId, isRefund, pageable);
public Page<SaleResponse> getAllSales(String query, String paymentMethod, Long storeId, Boolean isRefund, Long customerId, Pageable pageable) {
Page<Sale> sales = saleRepository.searchSales(normalizeFilter(query), normalizeFilter(paymentMethod), storeId, isRefund, customerId, pageable);
return sales.map(this::mapToResponse);
}
@@ -162,10 +164,41 @@ public class SaleService {
}
subtotalAmount = subtotalAmount.negate();
sale.setSubtotalAmount(subtotalAmount);
sale.setTotalAmount(subtotalAmount);
sale.setCouponDiscountAmount(BigDecimal.ZERO);
Sale originalSale = sale.getOriginalSale();
BigDecimal originalSubtotal = originalSale.getSubtotalAmount();
BigDecimal loyaltyDiscountRefunded = BigDecimal.ZERO;
BigDecimal couponDiscountRefunded = BigDecimal.ZERO;
BigDecimal refundTotal;
if (originalSubtotal != null && originalSubtotal.compareTo(BigDecimal.ZERO) > 0) {
BigDecimal ratio = subtotalAmount.divide(originalSubtotal, 10, RoundingMode.HALF_UP);
refundTotal = originalSale.getTotalAmount().abs().multiply(ratio).negate().setScale(2, RoundingMode.HALF_UP);
if (originalSale.getLoyaltyDiscountAmount() != null) {
loyaltyDiscountRefunded = originalSale.getLoyaltyDiscountAmount().multiply(ratio).setScale(2, RoundingMode.HALF_UP);
}
if (originalSale.getCouponDiscountAmount() != null) {
couponDiscountRefunded = originalSale.getCouponDiscountAmount().multiply(ratio).setScale(2, RoundingMode.HALF_UP);
}
User refundCustomer = originalSale.getCustomer();
if (refundCustomer != null) {
sale.setCustomer(refundCustomer);
int pointsToRestore = toPointsUsed(loyaltyDiscountRefunded);
int pointsEarnedToReverse = originalSale.getPointsEarned() != null
? ratio.multiply(BigDecimal.valueOf(originalSale.getPointsEarned())).setScale(0, RoundingMode.FLOOR).intValue()
: 0;
int currentPoints = refundCustomer.getLoyaltyPoints() != null ? refundCustomer.getLoyaltyPoints() : 0;
refundCustomer.setLoyaltyPoints(Math.max(0, currentPoints + pointsToRestore - pointsEarnedToReverse));
userRepository.save(refundCustomer);
}
} else {
refundTotal = subtotalAmount.negate();
}
sale.setTotalAmount(refundTotal);
sale.setCouponDiscountAmount(couponDiscountRefunded);
sale.setEmployeeDiscountAmount(BigDecimal.ZERO);
sale.setLoyaltyDiscountAmount(BigDecimal.ZERO);
sale.setLoyaltyDiscountAmount(loyaltyDiscountRefunded);
sale.setPointsEarned(0);
} else {
if (request.getItems() == null || request.getItems().isEmpty()) {
@@ -206,18 +239,29 @@ public class SaleService {
BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount));
sale.setEmployeeDiscountAmount(employeeDiscount);
boolean useLoyaltyPoints = sale.getCart() != null && Boolean.TRUE.equals(sale.getCart().getPointsApplied());
BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(customer, subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount), useLoyaltyPoints);
BigDecimal remainingAfterDiscounts = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount);
BigDecimal loyaltyDiscount;
int pointsDeducted;
if (request.getPointsUsed() != null && request.getPointsUsed() > 0) {
loyaltyDiscount = BigDecimal.valueOf(request.getPointsUsed())
.divide(BigDecimal.valueOf(LOYALTY_POINTS_PER_DOLLAR), 2, RoundingMode.HALF_UP)
.min(remainingAfterDiscounts.max(BigDecimal.ZERO))
.setScale(2, RoundingMode.HALF_UP);
pointsDeducted = request.getPointsUsed();
} else {
boolean useLoyaltyPoints = sale.getCart() != null && Boolean.TRUE.equals(sale.getCart().getPointsApplied());
loyaltyDiscount = calculateLoyaltyDiscount(customer, remainingAfterDiscounts, useLoyaltyPoints);
pointsDeducted = toPointsUsed(loyaltyDiscount);
}
sale.setLoyaltyDiscountAmount(loyaltyDiscount);
BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount).subtract(loyaltyDiscount);
sale.setTotalAmount(finalTotal.max(BigDecimal.ZERO));
int pointsUsed = toPointsUsed(loyaltyDiscount);
sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue());
if (customer != null) {
int currentPoints = customer.getLoyaltyPoints() != null ? customer.getLoyaltyPoints() : 0;
int updatedPoints = currentPoints - pointsUsed + sale.getPointsEarned();
int updatedPoints = currentPoints - pointsDeducted + sale.getPointsEarned();
customer.setLoyaltyPoints(Math.max(updatedPoints, 0));
userRepository.save(customer);
}
@@ -226,6 +270,11 @@ public class SaleService {
sale.setItems(saleItems);
Sale savedSale = saleRepository.save(sale);
if (!Boolean.TRUE.equals(savedSale.getIsRefund()) && savedSale.getCustomer() != null) {
emailService.sendPurchaseReceipt(savedSale);
}
return mapToResponse(savedSale);
}

View File

@@ -20,14 +20,10 @@ public class ServiceService {
}
@Transactional(readOnly = true)
public Page<ServiceResponse> getAllServices(String query, Pageable pageable) {
Page<com.petshop.backend.entity.Service> services;
if (query != null && !query.trim().isEmpty()) {
services = serviceRepository.searchServices(query, pageable);
} else {
services = serviceRepository.findAll(pageable);
}
return services.map(this::mapToResponse);
public Page<ServiceResponse> getAllServices(String query, String species, Pageable pageable) {
String q = (query != null && !query.trim().isEmpty()) ? query.trim() : null;
String sp = (species != null && !species.trim().isEmpty()) ? species.trim() : null;
return serviceRepository.searchServices(q, sp, pageable).map(this::mapToResponse);
}
@Transactional(readOnly = true)

View File

@@ -3,21 +3,27 @@ package com.petshop.backend.service;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.user.UserRequest;
import com.petshop.backend.dto.user.UserResponse;
import com.petshop.backend.repository.ActivityLogRepository;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.ActivityLogRepository;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
@@ -54,13 +60,26 @@ public class UserService {
}
public UserResponse getUserById(Long id) {
return getUserById(id, null);
}
@Transactional(readOnly = true)
public UserResponse getUserById(Long id, User.Role requiredRole) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
requireRoleOrNotFound(user, requiredRole, id);
return mapToResponse(user);
}
@Transactional
public UserResponse createUser(UserRequest request) {
return createUser(request, null);
}
@Transactional
public UserResponse createUser(UserRequest request, User.Role requiredRole) {
requireRequestedRole(request.getRole(), requiredRole);
User user = new User();
user.setUsername(trimToNull(request.getUsername()));
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
@@ -88,9 +107,18 @@ public class UserService {
@Transactional
public UserResponse updateUser(Long id, UserRequest request) {
return updateUser(id, request, null);
}
@Transactional
public UserResponse updateUser(Long id, UserRequest request, User.Role requiredRole) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
requireRoleOrNotFound(user, requiredRole, id);
requireRequestedRole(request.getRole(), requiredRole);
requireAdminMutationAllowed(user, request.getRole());
boolean invalidateToken =
!Objects.equals(user.getUsername(), request.getUsername())
|| user.getRole() != request.getRole()
@@ -127,9 +155,17 @@ public class UserService {
@Transactional
public void deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("User not found with id: " + id);
}
deleteUser(id, null);
}
@Transactional
public void deleteUser(Long id, User.Role requiredRole) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
requireRoleOrNotFound(user, requiredRole, id);
requireAdminDeletionAllowed(user);
if (activityLogRepository.existsByUser_Id(id)) {
throw new ResponseStatusException(CONFLICT, "User cannot be deleted because activity logs exist");
}
@@ -138,7 +174,27 @@ public class UserService {
@Transactional
public void bulkDeleteUsers(BulkDeleteRequest request) {
if (request.getIds() != null && request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
bulkDeleteUsers(request, null);
}
@Transactional
public void bulkDeleteUsers(BulkDeleteRequest request, User.Role requiredRole) {
if (request.getIds() == null || request.getIds().isEmpty()) {
throw new ResponseStatusException(BAD_REQUEST, "IDs list cannot be empty");
}
Set<Long> requestedIds = new HashSet<>(request.getIds());
ArrayList<User> usersToDelete = new ArrayList<>();
userRepository.findAllById(requestedIds).forEach(usersToDelete::add);
if (usersToDelete.size() != requestedIds.size()) {
throw new ResourceNotFoundException("One or more users not found for bulk delete");
}
requireRoleOrNotFound(usersToDelete, requiredRole);
requireAdminDeletionAllowed(usersToDelete);
if (request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
throw new ResponseStatusException(CONFLICT, "One or more users cannot be deleted because activity logs exist");
}
userRepository.deleteAllById(request.getIds());
@@ -169,6 +225,51 @@ public class UserService {
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + storeId));
}
private void requireRoleOrNotFound(User user, User.Role requiredRole, Long id) {
if (requiredRole != null && user.getRole() != requiredRole) {
throw new ResourceNotFoundException("User not found with id: " + id);
}
}
private void requireRoleOrNotFound(Collection<User> users, User.Role requiredRole) {
if (requiredRole == null) {
return;
}
for (User user : users) {
if (user.getRole() != requiredRole) {
throw new ResourceNotFoundException("User not found with id: " + user.getId());
}
}
}
private void requireRequestedRole(User.Role requestedRole, User.Role requiredRole) {
if (requiredRole != null && requestedRole != requiredRole) {
throw new AccessDeniedException("Target user must have role " + requiredRole.name());
}
}
private void requireAdminMutationAllowed(User target, User.Role requestedRole) {
User actor = AuthenticationHelper.getAuthenticatedUser(userRepository);
if ((target.getRole() == User.Role.ADMIN && !actor.getId().equals(target.getId()))
|| (requestedRole == User.Role.ADMIN && !actor.getId().equals(target.getId()))) {
throw new AccessDeniedException("Admins cannot modify other admin accounts");
}
}
private void requireAdminDeletionAllowed(User target) {
if (target.getRole() == User.Role.ADMIN) {
throw new AccessDeniedException("Admins cannot delete admin accounts");
}
}
private void requireAdminDeletionAllowed(Collection<User> targets) {
for (User target : targets) {
if (target.getRole() == User.Role.ADMIN) {
throw new AccessDeniedException("Admins cannot delete admin accounts");
}
}
}
private void validateUniquePhone(String phone, Long currentUserId) {
if (phone == null || phone.isBlank()) {
return;

View File

@@ -0,0 +1,30 @@
package com.petshop.backend.util;
import com.petshop.backend.exception.BusinessException;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
public class ContentFilter {
private static final Pattern SCRIPT_PATTERN = Pattern.compile(
"<script|javascript:|on\\w+\\s*=", Pattern.CASE_INSENSITIVE);
private static final Set<String> PROFANITY = Set.of(
"profanityOne", "profanityTwo", "profanityThree"
);
public static void validate(String input) {
if (input == null || input.isBlank()) return;
if (SCRIPT_PATTERN.matcher(input).find()) {
throw new BusinessException("Message contains prohibited content");
}
String lower = input.toLowerCase(Locale.ROOT);
for (String word : PROFANITY) {
if (lower.contains(word)) {
throw new BusinessException("Message contains prohibited language");
}
}
}
}

View File

@@ -50,6 +50,11 @@ springdoc:
app:
upload:
base-dir: ${UPLOAD_BASE_DIR:uploads}
frontend-url: ${FRONTEND_URL:http://localhost:3000}
resend:
api-key: ${RESEND_API_KEY:}
from: ${RESEND_FROM:PetShop <onboarding@resend.dev>}
jwt:
secret: ${JWT_SECRET}
@@ -60,9 +65,11 @@ stripe:
openrouter:
api-key: ${OPENROUTER_API_KEY:}
model: ${OPENROUTER_MODEL:openai/gpt-oss-120b:free}
model: ${OPENROUTER_MODEL:openrouter/free}
logging:
file:
name: ${LOG_FILE:logs/app.log}
level:
com.petshop: ${LOG_LEVEL:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}

View File

@@ -0,0 +1,3 @@
ALTER TABLE appointment MODIFY petId BIGINT NULL;
ALTER TABLE appointment DROP FOREIGN KEY fk_appointment_pet;
ALTER TABLE appointment ADD CONSTRAINT fk_appointment_pet FOREIGN KEY (petId) REFERENCES pet(petId) ON DELETE SET NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE purchaseOrder DROP COLUMN status;

View File

@@ -0,0 +1,218 @@
package com.petshop.backend.service;
import com.petshop.backend.dto.common.BulkDeleteRequest;
import com.petshop.backend.dto.user.UserRequest;
import com.petshop.backend.dto.user.UserResponse;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.ActivityLogRepository;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.AppPrincipal;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import java.util.HashSet;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private ActivityLogRepository activityLogRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock private StoreRepository storeRepository;
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService(userRepository, activityLogRepository, passwordEncoder, storeRepository);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void updateUserDeniesEditingAnotherAdmin() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request));
verify(userRepository, never()).save(any(User.class));
}
@Test
void updateUserTreatsWrongScopedRoleAsNotFound() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
UserRequest request = request(User.Role.CUSTOMER);
assertThrows(ResourceNotFoundException.class, () -> userService.updateUser(2L, request, User.Role.CUSTOMER));
verify(userRepository, never()).save(any(User.class));
}
@Test
void deleteUserDeniesDeletingAnotherAdmin() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
assertThrows(AccessDeniedException.class, () -> userService.deleteUser(2L));
verify(userRepository, never()).deleteById(anyLong());
}
@Test
void deleteUserDeniesDeletingSelfAdminAccount() {
User actor = user(1L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
assertThrows(AccessDeniedException.class, () -> userService.deleteUser(1L));
verify(userRepository, never()).deleteById(anyLong());
}
@Test
void bulkDeleteUsersDeniesMixedAdminTargets() {
User targetAdmin = user(2L, User.Role.ADMIN);
User customer = user(3L, User.Role.CUSTOMER);
when(userRepository.findAllById(eq(new HashSet<>(List.of(2L, 3L))))).thenReturn(List.of(targetAdmin, customer));
BulkDeleteRequest request = new BulkDeleteRequest();
request.setIds(List.of(2L, 3L));
assertThrows(AccessDeniedException.class, () -> userService.bulkDeleteUsers(request));
verify(userRepository, never()).deleteAllById(any());
}
@Test
void bulkDeleteUsersThrowsWhenAnyIdMissing() {
User customer = user(2L, User.Role.CUSTOMER);
when(userRepository.findAllById(any())).thenReturn(List.of(customer));
BulkDeleteRequest request = new BulkDeleteRequest();
request.setIds(List.of(2L, 3L));
assertThrows(ResourceNotFoundException.class, () -> userService.bulkDeleteUsers(request, User.Role.CUSTOMER));
verify(userRepository, never()).deleteAllById(any());
}
@Test
void createUserDeniesWrongRoleForScopedRoute() {
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.createUser(request, User.Role.CUSTOMER));
verify(userRepository, never()).save(any(User.class));
}
@Test
void updateUserAllowsEditingOwnAdminAccount() {
User actor = user(1L, User.Role.ADMIN);
authenticate(actor);
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
UserRequest request = request(User.Role.ADMIN);
request.setFirstName("Updated");
UserResponse response = userService.updateUser(1L, request);
assertEquals("Updated", response.getFirstName());
}
@Test
void updateUserDeniesPromotingAnotherUserToAdmin() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.CUSTOMER);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request));
verify(userRepository, never()).save(any(User.class));
}
@Test
void scopedUpdateDeniesRoleEscalation() {
User actor = user(1L, User.Role.ADMIN);
User target = user(2L, User.Role.CUSTOMER);
authenticate(actor);
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
UserRequest request = request(User.Role.ADMIN);
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request, User.Role.CUSTOMER));
verify(userRepository, never()).save(any(User.class));
}
private void authenticate(User user) {
AppPrincipal principal = new AppPrincipal(user.getId(), user.getUsername(), user.getRole(), user.getTokenVersion());
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(new UsernamePasswordAuthenticationToken(principal, "n/a", principal.getAuthorities()));
SecurityContextHolder.setContext(context);
}
private User user(Long id, User.Role role) {
User user = new User();
user.setId(id);
user.setUsername("user" + id);
user.setFirstName("First");
user.setLastName("Last");
user.setEmail("user" + id + "@example.com");
user.setRole(role);
user.setActive(true);
user.setTokenVersion(0);
return user;
}
private UserRequest request(User.Role role) {
UserRequest request = new UserRequest();
request.setUsername("updated-user");
request.setFirstName("First");
request.setLastName("Last");
request.setEmail("updated@example.com");
request.setRole(role);
request.setActive(true);
return request;
}
}

View File

@@ -22,6 +22,7 @@ public class AppointmentDTO {
private SimpleStringProperty appointmentTime;
private SimpleStringProperty appointmentStatus;
private SimpleStringProperty storeName;
private Long storeId;
public AppointmentDTO(int appointmentId,
int customerId, String customerName,
@@ -32,7 +33,8 @@ public class AppointmentDTO {
String appointmentDate,
String appointmentTime,
String appointmentStatus,
String storeName) {
String storeName,
Long storeId) {
this.appointmentId = new SimpleIntegerProperty(appointmentId);
this.customerId = new SimpleIntegerProperty(customerId);
@@ -47,6 +49,7 @@ public class AppointmentDTO {
this.appointmentTime = new SimpleStringProperty(appointmentTime);
this.appointmentStatus = new SimpleStringProperty(appointmentStatus);
this.storeName = new SimpleStringProperty(storeName != null ? storeName : "");
this.storeId = storeId;
}
public int getAppointmentId() { return appointmentId.get(); }
@@ -66,4 +69,6 @@ public class AppointmentDTO {
public String getAppointmentTime() { return appointmentTime.get(); }
public String getAppointmentStatus() { return appointmentStatus.get(); }
public String getStoreName() { return storeName.get(); }
public Long getStoreId() { return storeId; }
}

View File

@@ -3,17 +3,33 @@ package org.example.petshopdesktop;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.stage.Stage;
import org.example.petshopdesktop.util.DesktopNotificationService;
import java.io.IOException;
import java.util.Objects;
public class PetShopApplication extends Application {
@Override
public void start(Stage stage) throws IOException {
FXMLLoader fxmlLoader = new FXMLLoader(PetShopApplication.class.getResource("login-view.fxml"));
Scene scene = new Scene(fxmlLoader.load());
stage.setTitle("Pet Shop Manager - Login");
stage.setTitle("Leon's Pet Store - Login");
try {
stage.getIcons().add(new Image(Objects.requireNonNull(
getClass().getResourceAsStream("/org/example/petshopdesktop/images/leons-pet-store-badge.png")
)));
} catch (Exception e) {
System.err.println("Could not load application icon: " + e.getMessage());
}
stage.setScene(scene);
stage.show();
DesktopNotificationService.getInstance().init(
stage.getIcons().isEmpty() ? null : stage.getIcons().get(0)
);
}
}

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.example.petshopdesktop.auth.UserSession;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -142,6 +143,38 @@ public class ApiClient {
return handleResponse(response, responseClass);
}
public <T> T postMultipartWithText(String path, String filePartName, Path filePath,
String textPartName, String textContent,
Class<T> responseClass) throws Exception {
String boundary = "----PetShopDesktop" + UUID.randomUUID();
String mimeType = Files.probeContentType(filePath);
if (mimeType == null || mimeType.isBlank()) mimeType = "application/octet-stream";
byte[] fileBytes = Files.readAllBytes(filePath);
String fileName = filePath.getFileName().toString();
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + filePartName
+ "\"; filename=\"" + fileName + "\"\r\nContent-Type: " + mimeType + "\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
out.write(fileBytes);
out.write("\r\n".getBytes(StandardCharsets.UTF_8));
if (textContent != null && !textContent.isBlank()) {
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + textPartName
+ "\"\r\n\r\n" + textContent + "\r\n").getBytes(StandardCharsets.UTF_8));
}
out.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()))
.timeout(Duration.ofSeconds(30));
addAuthHeader(builder);
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return handleResponse(response, responseClass);
}
public <T> T put(String path, Object requestBody, Class<T> responseClass) throws Exception {
String jsonBody = objectMapper.writeValueAsString(requestBody);

View File

@@ -4,6 +4,7 @@ import org.example.petshopdesktop.api.dto.chat.ConversationResponse;
import org.example.petshopdesktop.api.dto.chat.MessageRequest;
import org.example.petshopdesktop.api.dto.chat.MessageResponse;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.DesktopNotificationService;
import java.net.URI;
import java.net.http.HttpClient;
@@ -72,6 +73,16 @@ public class ChatRealtimeClient implements WebSocket.Listener {
updateNotificationState();
}
public void markConversationReplied(Long conversationId, Long senderId) {
synchronized (lock) {
ConversationResponse conv = globalConversations.get(conversationId);
if (conv != null) {
conv.setLastSenderId(senderId);
}
}
updateNotificationState();
}
public boolean hasActionableChats() {
synchronized (lock) {
UserSession session = UserSession.getInstance();
@@ -334,6 +345,12 @@ public class ChatRealtimeClient implements WebSocket.Listener {
messageListener.accept(message);
}
Long currentUserId = UserSession.getInstance().getUserId();
if (message.getSenderId() != null && !message.getSenderId().equals(currentUserId)) {
DesktopNotificationService.getInstance()
.notifyNewMessage(message.getSenderDisplayName(), message.getContent());
}
// Also update globalConversation last sender if this is the active conversation
synchronized (lock) {
ConversationResponse conv = globalConversations.get(message.getConversationId());

View File

@@ -1,5 +1,6 @@
package org.example.petshopdesktop.api.dto.adoption;
import java.math.BigDecimal;
import java.time.LocalDate;
public class AdoptionRequest {
@@ -9,7 +10,7 @@ public class AdoptionRequest {
private Long sourceStoreId;
private LocalDate adoptionDate;
private String adoptionStatus;
private String paymentMethod;
private BigDecimal adoptionFee;
public AdoptionRequest() {
}
@@ -62,11 +63,11 @@ public class AdoptionRequest {
this.adoptionStatus = adoptionStatus;
}
public String getPaymentMethod() {
return paymentMethod;
public BigDecimal getAdoptionFee() {
return adoptionFee;
}
public void setPaymentMethod(String paymentMethod) {
this.paymentMethod = paymentMethod;
public void setAdoptionFee(BigDecimal adoptionFee) {
this.adoptionFee = adoptionFee;
}
}

View File

@@ -13,6 +13,7 @@ public class AdoptionResponse {
private LocalDate adoptionDate;
private java.math.BigDecimal adoptionFee;
private String adoptionStatus;
private Long sourceStoreId;
private String sourceStoreName;
public AdoptionResponse() {
@@ -98,6 +99,14 @@ public class AdoptionResponse {
this.adoptionStatus = adoptionStatus;
}
public Long getSourceStoreId() {
return sourceStoreId;
}
public void setSourceStoreId(Long sourceStoreId) {
this.sourceStoreId = sourceStoreId;
}
public String getSourceStoreName() {
return sourceStoreName;
}

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

@@ -8,6 +8,9 @@ public class SaleRequest {
private List<SaleItemRequest> items;
private Boolean isRefund;
private Long originalSaleId;
private Long customerId;
private Long couponId;
private Integer pointsUsed;
public SaleRequest() {
}
@@ -51,4 +54,28 @@ public class SaleRequest {
public void setOriginalSaleId(Long originalSaleId) {
this.originalSaleId = originalSaleId;
}
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public Long getCouponId() {
return couponId;
}
public void setCouponId(Long couponId) {
this.couponId = couponId;
}
public Integer getPointsUsed() {
return pointsUsed;
}
public void setPointsUsed(Integer pointsUsed) {
this.pointsUsed = pointsUsed;
}
}

View File

@@ -14,6 +14,13 @@ public class SaleResponse {
private Boolean isRefund;
private Long originalSaleId;
private List<SaleItemResponse> items;
private Long customerId;
private String customerName;
private BigDecimal couponDiscountAmount;
private BigDecimal loyaltyDiscountAmount;
private Integer pointsUsed;
private Integer pointsEarned;
private BigDecimal subtotalAmount;
public SaleResponse() {
}
@@ -89,4 +96,60 @@ public class SaleResponse {
public void setItems(List<SaleItemResponse> items) {
this.items = items;
}
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public String getCustomerName() {
return customerName;
}
public void setCustomerName(String customerName) {
this.customerName = customerName;
}
public BigDecimal getCouponDiscountAmount() {
return couponDiscountAmount;
}
public void setCouponDiscountAmount(BigDecimal couponDiscountAmount) {
this.couponDiscountAmount = couponDiscountAmount;
}
public BigDecimal getLoyaltyDiscountAmount() {
return loyaltyDiscountAmount;
}
public void setLoyaltyDiscountAmount(BigDecimal loyaltyDiscountAmount) {
this.loyaltyDiscountAmount = loyaltyDiscountAmount;
}
public Integer getPointsUsed() {
return pointsUsed;
}
public void setPointsUsed(Integer pointsUsed) {
this.pointsUsed = pointsUsed;
}
public Integer getPointsEarned() {
return pointsEarned;
}
public void setPointsEarned(Integer pointsEarned) {
this.pointsEarned = pointsEarned;
}
public BigDecimal getSubtotalAmount() {
return subtotalAmount;
}
public void setSubtotalAmount(BigDecimal subtotalAmount) {
this.subtotalAmount = subtotalAmount;
}
}

View File

@@ -3,11 +3,14 @@ package org.example.petshopdesktop.api.dto.user;
public class UserRequest {
private String username;
private String password;
private String firstName;
private String lastName;
private String fullName;
private String email;
private String phone;
private String role;
private Boolean active;
private Integer loyaltyPoints;
public UserRequest() {
}
@@ -28,6 +31,22 @@ public class UserRequest {
this.password = password;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getFullName() {
return fullName;
}
@@ -67,4 +86,12 @@ public class UserRequest {
public void setActive(Boolean active) {
this.active = active;
}
public Integer getLoyaltyPoints() {
return loyaltyPoints;
}
public void setLoyaltyPoints(Integer loyaltyPoints) {
this.loyaltyPoints = loyaltyPoints;
}
}

View File

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

View File

@@ -23,7 +23,7 @@ public class AppointmentApi {
return INSTANCE;
}
public List<AppointmentResponse> listAppointments(String query, Long storeId) throws Exception {
public List<AppointmentResponse> listAppointments(String query, Long storeId, Long employeeId) throws Exception {
String path = "/api/v1/appointments?page=0&size=1000";
if (query != null && !query.isEmpty()) {
path += "&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8);
@@ -31,6 +31,9 @@ public class AppointmentApi {
if (storeId != null) {
path += "&storeId=" + storeId;
}
if (employeeId != null) {
path += "&employeeId=" + employeeId;
}
String response = apiClient.getRawResponse(path);
PageResponse<AppointmentResponse> pageResponse = apiClient.getObjectMapper().readValue(
response,

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

@@ -7,6 +7,7 @@ import org.example.petshopdesktop.api.dto.chat.ConversationResponse;
import org.example.petshopdesktop.api.dto.chat.MessageRequest;
import org.example.petshopdesktop.api.dto.chat.MessageResponse;
import org.example.petshopdesktop.api.dto.chat.UpdateConversationRequest;
import java.io.File;
import java.util.List;
public class ChatApi {
@@ -43,6 +44,11 @@ public class ChatApi {
return apiClient.post("/api/v1/chat/conversations/" + conversationId + "/messages", request, MessageResponse.class);
}
public MessageResponse sendMessageWithAttachment(Long conversationId, File file, String content) throws Exception {
String path = "/api/v1/chat/conversations/" + conversationId + "/attachments";
return apiClient.postMultipartWithText(path, "file", file.toPath(), "content", content, MessageResponse.class);
}
public ConversationResponse updateConversation(Long id, UpdateConversationRequest request) throws Exception {
return apiClient.put("/api/v1/chat/conversations/" + id, request, ConversationResponse.class);
}

View File

@@ -39,4 +39,8 @@ public class CouponApi {
public void deleteCoupon(Long id) throws Exception {
apiClient.delete("/api/v1/coupons/" + id);
}
public CouponResponse getCouponByCode(String code) throws Exception {
return apiClient.get("/api/v1/coupons/code/" + code, CouponResponse.class);
}
}

View File

@@ -45,4 +45,8 @@ public class CustomerApi {
public UserResponse createCustomer(UserRequest request) throws Exception {
return apiClient.post("/api/v1/customers", request, UserResponse.class);
}
public UserResponse getCustomerById(Long id) throws Exception {
return apiClient.get("/api/v1/customers/" + id, UserResponse.class);
}
}

View File

@@ -107,6 +107,14 @@ public class DropdownApi {
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {});
}
public List<DropdownOption> getAdoptionPets(Long storeId) throws Exception {
String response = apiClient.getRawResponse("/api/v1/dropdowns/adoption-pets?storeId=" + storeId);
if (response == null || response.isEmpty()) {
throw new IllegalStateException("Empty response from adoption pets endpoint");
}
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {});
}
public List<DropdownOption> getCustomerPets(Long customerId) throws Exception {
String response = apiClient.getRawResponse("/api/v1/dropdowns/customers/" + customerId + "/pets");
if (response == null || response.isEmpty()) {

View File

@@ -57,6 +57,10 @@ public class PetApi {
return listPets(query, null, null, null);
}
public PetResponse getPetById(Long id) throws Exception {
return apiClient.get("/api/v1/pets/" + id, PetResponse.class);
}
public PetResponse createPet(PetRequest request) throws Exception {
return apiClient.post("/api/v1/pets", request, PetResponse.class);
}

View File

@@ -3,9 +3,12 @@ package org.example.petshopdesktop.controllers;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
@@ -14,6 +17,7 @@ import org.example.petshopdesktop.api.endpoints.ActivityLogApi;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.TableViewSupport;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
@@ -40,6 +44,15 @@ public class ActivityLogController {
@FXML
private Button btnRefresh;
@FXML
private DatePicker dpStart;
@FXML
private DatePicker dpEnd;
@FXML
private CheckBox chkHideViewOnly;
@FXML
private Label lblStatus;
@@ -47,6 +60,7 @@ public class ActivityLogController {
private Label lblError;
private final ObservableList<ActivityLogResponse> activityLogs = FXCollections.observableArrayList();
private FilteredList<ActivityLogResponse> filteredLogs;
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final int DEFAULT_LIMIT = 2000;
@@ -71,7 +85,10 @@ public class ActivityLogController {
colStore.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayStore(data.getValue())));
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);
dpStart.setValue(LocalDate.now().minusDays(30));
dpEnd.setValue(LocalDate.now());
loadLogs();
}
@@ -80,18 +97,46 @@ public class ActivityLogController {
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() {
lblError.setText("");
tvActivityLogs.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(() -> {
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();
Platform.runLater(() -> {
activityLogs.setAll(safeContent);
applyViewOnlyFilter();
tvActivityLogs.setDisable(false);
btnRefresh.setDisable(false);
TableViewSupport.flashStatus(lblStatus, "Refreshed");

View File

@@ -4,6 +4,7 @@ import javafx.application.Platform;
import org.example.petshopdesktop.auth.UserSession;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
@@ -13,16 +14,21 @@ import javafx.scene.control.cell.PropertyValueFactory;
import javafx.stage.Modality;
import javafx.stage.Stage;
import org.example.petshopdesktop.api.dto.adoption.AdoptionResponse;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.endpoints.AdoptionApi;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.controllers.dialogcontrollers.AdoptionDialogController;
import org.example.petshopdesktop.models.Adoption;
import org.example.petshopdesktop.ui.CalendarPane;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.TableViewSupport;
import java.io.IOException;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
public class AdoptionController {
@@ -72,6 +78,19 @@ public class AdoptionController {
@FXML
private TextField txtSearch;
@FXML
private ComboBox<String> cbStatusFilter;
@FXML
private ComboBox<DropdownOption> cbStoreFilter;
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
@FXML
private CalendarPane calendarPane;
private LocalDate selectedCalendarDate = null;
private FilteredList<Adoption> filteredAdoptions;
private ObservableList<Adoption> data = FXCollections.observableArrayList();
private String mode = null;
@@ -92,6 +111,20 @@ public class AdoptionController {
colStoreName.setCellValueFactory(new PropertyValueFactory<>("storeName"));
TableViewSupport.applyCurrencyColumn(colAdoptionFee);
filteredAdoptions = new FilteredList<>(data, a -> true);
TableViewSupport.bindSortedItems(tvAdoptions, filteredAdoptions);
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Pending", "Completed", "Cancelled"));
cbStatusFilter.getSelectionModel().selectFirst();
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyFilterPredicate());
if (UserSession.getInstance().isAdmin()) {
cbStoreFilter.setVisible(true);
cbStoreFilter.setManaged(true);
loadStoreFilter();
}
cbStoreFilter.valueProperty().addListener((obs, o, n) -> displayAdoptions());
displayAdoptions();
TableViewSupport.installDoubleClickAction(tvAdoptions, selected -> openDialog(selected, "Edit"));
@@ -106,6 +139,11 @@ public class AdoptionController {
displayFilteredAdoptions(newValue);
});
calendarPane.setOnDateSelected(date -> {
selectedCalendarDate = date;
applyFilterPredicate();
});
tvAdoptions.setOnKeyPressed(event -> {
if (event.getCode() == javafx.scene.input.KeyCode.DELETE) {
if (tvAdoptions.getSelectionModel().getSelectedItem() != null) {
@@ -118,6 +156,10 @@ public class AdoptionController {
@FXML
void btnRefresh(ActionEvent event) {
txtSearch.clear();
cbStatusFilter.getSelectionModel().selectFirst();
cbStoreFilter.getSelectionModel().selectFirst();
selectedCalendarDate = null;
if (filteredAdoptions != null) filteredAdoptions.setPredicate(a -> true);
tvAdoptions.getSortOrder().clear();
displayAdoptions();
TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -189,7 +231,7 @@ public class AdoptionController {
} else {
new Thread(() -> {
try {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
Long storeId = selectedStoreId();
List<AdoptionResponse> adoptions = AdoptionApi.getInstance().listAdoptions(filter, storeId);
List<Adoption> adoptionList = adoptions.stream()
.map(this::mapToAdoption)
@@ -198,7 +240,13 @@ public class AdoptionController {
Platform.runLater(() -> {
data.setAll(adoptionList);
tvAdoptions.setItems(data);
Set<LocalDate> dates = adoptionList.stream()
.filter(a -> a.getAdoptionDate() != null && !a.getAdoptionDate().isEmpty())
.map(a -> { try { return LocalDate.parse(a.getAdoptionDate()); } catch (Exception ex) { return null; } })
.filter(d -> d != null)
.collect(java.util.stream.Collectors.toSet());
calendarPane.setEventDates(dates);
applyFilterPredicate();
});
} catch (Exception e) {
Platform.runLater(() -> {
@@ -225,7 +273,15 @@ public class AdoptionController {
Platform.runLater(() -> {
data.setAll(adoptionList);
tvAdoptions.setItems(data);
Set<LocalDate> dates = adoptionList.stream()
.filter(a -> a.getAdoptionDate() != null && !a.getAdoptionDate().isEmpty())
.map(a -> { try { return LocalDate.parse(a.getAdoptionDate()); } catch (Exception ex) { return null; } })
.filter(d -> d != null)
.collect(java.util.stream.Collectors.toSet());
calendarPane.setEventDates(dates);
if (selectedCalendarDate != null) {
filteredAdoptions.setPredicate(a -> a.getAdoptionDate().equals(selectedCalendarDate.toString()));
}
});
} catch (Exception e) {
Platform.runLater(() -> {
@@ -272,6 +328,44 @@ public class AdoptionController {
txtSearch.setText("");
}
private void loadStoreFilter() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
DropdownOption allStores = new DropdownOption();
allStores.setLabel("All Stores");
Platform.runLater(() -> {
storeOptions.clear();
storeOptions.addAll(stores);
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
items.add(allStores);
items.addAll(stores);
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
cbStoreFilter.getSelectionModel().selectFirst();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("AdoptionController.loadStoreFilter", e, "Loading store filter");
}
}).start();
}
private Long selectedStoreId() {
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
DropdownOption selected = cbStoreFilter.getValue();
return (selected != null && selected.getId() != null) ? selected.getId() : null;
}
private void applyFilterPredicate() {
String selectedStatus = cbStatusFilter.getValue();
filteredAdoptions.setPredicate(a -> {
boolean dateMatch = selectedCalendarDate == null
|| a.getAdoptionDate().equals(selectedCalendarDate.toString());
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|| a.getAdoptionStatus().equalsIgnoreCase(selectedStatus);
return dateMatch && statusMatch;
});
}
private Adoption mapToAdoption(AdoptionResponse response) {
return new Adoption(
response.getAdoptionId().intValue(),
@@ -284,7 +378,8 @@ public class AdoptionController {
response.getAdoptionDate() != null ? response.getAdoptionDate().toString() : "",
response.getAdoptionFee() != null ? response.getAdoptionFee().doubleValue() : 0.0,
response.getAdoptionStatus(),
response.getSourceStoreName()
response.getSourceStoreName(),
response.getSourceStoreId()
);
}
}

View File

@@ -1,19 +1,25 @@
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.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;
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 +80,46 @@ 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;
@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();
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 +134,36 @@ 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();
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();
}
@@ -142,64 +211,161 @@ public class AnalyticsController {
private void loadAnalyticsData() {
lblError.setVisible(false);
if (lblStatus != null) {
lblStatus.setVisible(false);
}
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(() -> {
try {
boolean isAdmin = UserSession.getInstance().isAdmin();
loadSummaryData(dashboard);
loadSalesOverTime(dashboard);
loadTopProductsByRevenue(dashboard);
loadTopProductsByQuantity(dashboard);
loadPaymentMethodDistribution(dashboard);
loadEmployeePerformance(dashboard, isAdmin);
applyRoleVisibility(isAdmin);
} catch (Exception e) {
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", e, "Loading analytics data");
lblError.setText("Error loading analytics data. Please try again.");
lblError.setVisible(true);
}
cachedSales = sales;
derivePaymentMethods();
deriveStores();
updateStoreFilterVisibility();
applyCurrentFilter();
btnRefresh.setDisable(false);
});
} 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");
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 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> 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;
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);
loadTopProductsByRevenue(dashboard);
loadTopProductsByQuantity(dashboard);
loadPaymentMethodDistribution(dashboard);
loadEmployeePerformance(dashboard, isAdmin);
applyRoleVisibility(isAdmin);
updateFilterSummary();
} catch (Exception e) {
ActivityLogger.getInstance().logException("AnalyticsController.applyCurrentFilter", e, "Computing analytics from filtered sales");
lblError.setText("Error computing analytics. 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;
}
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 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;
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 {
if (dashboard != null) {
BigDecimal totalRevenue = BigDecimal.ZERO;
@@ -318,34 +484,32 @@ 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);
}
}
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 +543,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);
DailySales row = new DailySales();
row.setDate(date.toString());
row.setRevenue(BigDecimal.ZERO);
row.setSalesCount(0L);
daily.put(date, row);
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);
}
} catch (Exception e) {
return new ArrayList<>();
}
for (SaleResponse sale : sales) {
if (Boolean.TRUE.equals(sale.getIsRefund()) || sale.getSaleDate() == null) {
continue;
}
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 +663,77 @@ 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();
cbStoreFilter.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;
String sf = cbStoreFilter.getValue();
currentFilter.storeFilter = (sf != null && !sf.isBlank()) ? sf : "All Stores";
applyCurrentFilter();
}
private static class FilterState {
String startDate = "";
String endDate = "";
String paymentMethod = "All";
int topN = 5;
String storeFilter = "All Stores";
}
}

View File

@@ -16,12 +16,17 @@ import javafx.stage.Stage;
import org.example.petshopdesktop.DTOs.AppointmentDTO;
import org.example.petshopdesktop.api.dto.appointment.AppointmentResponse;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.endpoints.AppointmentApi;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.controllers.dialogcontrollers.AppointmentDialogController;
import org.example.petshopdesktop.ui.CalendarPane;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.TableViewSupport;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.Comparator;
import java.util.stream.Collectors;
@@ -43,11 +48,20 @@ public class AppointmentController {
@FXML private Button btnEdit;
@FXML private Button btnDelete;
@FXML private Button btnRefresh;
@FXML private javafx.scene.control.ToggleButton btnMyAppointments;
@FXML private Label lblStatus;
@FXML private TextField txtSearch;
@FXML private ComboBox<String> cbStatusFilter;
@FXML private ComboBox<DropdownOption> cbStoreFilter;
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
@FXML private CalendarPane calendarPane;
private LocalDate selectedCalendarDate = null;
private final ObservableList<AppointmentDTO> appointments = FXCollections.observableArrayList();
private FilteredList<AppointmentDTO> filtered;
@@ -71,6 +85,22 @@ public class AppointmentController {
TableViewSupport.bindSortedItems(tvAppointments, filtered);
TableViewSupport.installDoubleClickAction(tvAppointments, selected -> openDialog(selected, "Edit"));
if (UserSession.getInstance().isStaff()) {
btnMyAppointments.setVisible(true);
btnMyAppointments.setManaged(true);
}
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Booked", "Completed", "Missed", "Cancelled"));
cbStatusFilter.getSelectionModel().selectFirst();
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyFilterPredicate());
if (UserSession.getInstance().isAdmin()) {
cbStoreFilter.setVisible(true);
cbStoreFilter.setManaged(true);
loadStoreFilter();
}
cbStoreFilter.valueProperty().addListener((obs, o, n) -> loadAppointments());
if (txtSearch != null) {
txtSearch.textProperty().addListener((obs, o, n) -> applyFilter(n));
}
@@ -90,13 +120,24 @@ public class AppointmentController {
});
loadAppointments();
calendarPane.setOnDateSelected(date -> {
selectedCalendarDate = date;
applyFilterPredicate();
});
}
@FXML
void btnMyAppointmentsToggled(javafx.event.ActionEvent event) {
loadAppointments();
}
private void loadAppointments(){
new Thread(() -> {
try{
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(null, storeId);
Long storeId = selectedStoreId();
Long employeeId = btnMyAppointments.isSelected() ? UserSession.getInstance().getEmployeeId() : null;
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(null, storeId, employeeId);
List<AppointmentDTO> appointmentDTOs = responses.stream()
.map(this::mapToAppointmentDTO)
.sorted(Comparator.comparing((AppointmentDTO a) -> a.getAppointmentDate() + "T" + a.getAppointmentTime()).reversed())
@@ -104,6 +145,13 @@ public class AppointmentController {
Platform.runLater(() -> {
appointments.setAll(appointmentDTOs);
Set<LocalDate> dates = appointmentDTOs.stream()
.filter(a -> a.getAppointmentDate() != null && !a.getAppointmentDate().isEmpty())
.map(a -> { try { return LocalDate.parse(a.getAppointmentDate()); } catch (Exception ex) { return null; } })
.filter(d -> d != null)
.collect(java.util.stream.Collectors.toSet());
calendarPane.setEventDates(dates);
applyFilterPredicate();
});
}catch(Exception e){
Platform.runLater(() -> {
@@ -121,8 +169,9 @@ public class AppointmentController {
String query = text == null || text.trim().isEmpty() ? null : text.trim();
new Thread(() -> {
try {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(query, storeId);
Long storeId = selectedStoreId();
Long employeeId = btnMyAppointments.isSelected() ? UserSession.getInstance().getEmployeeId() : null;
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(query, storeId, employeeId);
List<AppointmentDTO> appointmentDTOs = responses.stream()
.map(this::mapToAppointmentDTO)
.sorted(Comparator.comparing((AppointmentDTO a) -> a.getAppointmentDate() + "T" + a.getAppointmentTime()).reversed())
@@ -130,6 +179,13 @@ public class AppointmentController {
Platform.runLater(() -> {
appointments.setAll(appointmentDTOs);
Set<LocalDate> dates = appointmentDTOs.stream()
.filter(a -> a.getAppointmentDate() != null && !a.getAppointmentDate().isEmpty())
.map(a -> { try { return LocalDate.parse(a.getAppointmentDate()); } catch (Exception ex) { return null; } })
.filter(d -> d != null)
.collect(java.util.stream.Collectors.toSet());
calendarPane.setEventDates(dates);
applyFilterPredicate();
});
} catch (Exception e) {
Platform.runLater(() -> {
@@ -146,6 +202,10 @@ public class AppointmentController {
@FXML
void btnRefresh(ActionEvent event) {
txtSearch.clear();
cbStatusFilter.getSelectionModel().selectFirst();
cbStoreFilter.getSelectionModel().selectFirst();
selectedCalendarDate = null;
filtered.setPredicate(a -> true);
tvAppointments.getSortOrder().clear();
loadAppointments();
TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -247,6 +307,44 @@ public class AppointmentController {
}
}
private void loadStoreFilter() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
DropdownOption allStores = new DropdownOption();
allStores.setLabel("All Stores");
Platform.runLater(() -> {
storeOptions.clear();
storeOptions.addAll(stores);
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
items.add(allStores);
items.addAll(stores);
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
cbStoreFilter.getSelectionModel().selectFirst();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("AppointmentController.loadStoreFilter", e, "Loading store filter");
}
}).start();
}
private Long selectedStoreId() {
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
DropdownOption selected = cbStoreFilter.getValue();
return (selected != null && selected.getId() != null) ? selected.getId() : null;
}
private void applyFilterPredicate() {
String selectedStatus = cbStatusFilter.getValue();
filtered.setPredicate(apt -> {
boolean dateMatch = selectedCalendarDate == null
|| apt.getAppointmentDate().equals(selectedCalendarDate.toString());
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|| apt.getAppointmentStatus().equalsIgnoreCase(selectedStatus);
return dateMatch && statusMatch;
});
}
private void showAlert(String title, String msg){
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle(title);
@@ -269,7 +367,8 @@ public class AppointmentController {
response.getAppointmentDate() != null ? response.getAppointmentDate().toString() : "",
response.getAppointmentTime() != null ? response.getAppointmentTime().toString() : "",
normalizeAppointmentStatus(response.getAppointmentStatus()),
response.getStoreName()
response.getStoreName(),
response.getStoreId()
);
}

View File

@@ -27,6 +27,7 @@ import org.example.petshopdesktop.api.dto.chat.UpdateConversationRequest;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.endpoints.ChatApi;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.api.ApiClient;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.ActivityLogger;
@@ -176,33 +177,33 @@ public class ChatController {
}
String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim();
if (content.isEmpty()) {
if (selectedAttachmentFile != null) {
lblChatStatus.setText("Attachments are not available yet");
}
if (content.isEmpty() && selectedAttachmentFile == null) {
return;
}
Long convId = selectedConversation.getId();
File attachmentFile = selectedAttachmentFile;
txtMessage.clear();
btnSend.setDisable(true);
lblChatStatus.setText("Sending message...");
new Thread(() -> {
try {
MessageRequest request = new MessageRequest(content);
MessageResponse response = ChatApi.getInstance().sendMessage(convId, request);
MessageResponse response;
if (attachmentFile != null) {
response = ChatApi.getInstance().sendMessageWithAttachment(convId, attachmentFile, content);
} else {
response = ChatApi.getInstance().sendMessage(convId, new MessageRequest(content));
}
Platform.runLater(() -> {
btnSend.setDisable(false);
realtimeClient.markConversationReplied(convId, UserSession.getInstance().getUserId());
appendMessageIfSelected(response);
if (selectedAttachmentFile != null) {
if (attachmentFile != null) {
clearLocalAttachment();
lblChatStatus.setText("Message sent without attachment");
} else {
lblChatStatus.setText("Message sent");
}
lblChatStatus.setText("Message sent");
});
} catch (Exception e) {
Platform.runLater(() -> {
@@ -380,6 +381,7 @@ public class ChatController {
List<ConversationResponse> response = ChatApi.getInstance().listConversations();
response.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
Platform.runLater(() -> {
realtimeClient.initializeState(response);
conversations.setAll(response);
refreshSections();
restoreSelection();
@@ -471,6 +473,7 @@ public class ChatController {
.findFirst()
.ifPresent(conversation -> {
conversation.setLastMessage(message.getContent());
conversation.setLastSenderId(message.getSenderId());
conversation.setUpdatedAt(message.getTimestamp());
conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
refreshSections();
@@ -504,14 +507,16 @@ public class ChatController {
Circle avatar = new Circle(16);
avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1"));
if (message.getSenderAvatarUrl() != null && !message.getSenderAvatarUrl().isBlank()) {
try {
String fullUrl = org.example.petshopdesktop.api.ApiConfig.getInstance().getBaseUrl() + message.getSenderAvatarUrl();
Image img = new Image(fullUrl, true);
img.errorProperty().addListener((obs, old, err) -> {
if (err) avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1"));
});
avatar.setFill(new ImagePattern(img));
} catch (Exception ignored) {}
String relativeUrl = message.getSenderAvatarUrl();
new Thread(() -> {
try {
byte[] bytes = ApiClient.getInstance().getBytes(relativeUrl);
Image img = new Image(new java.io.ByteArrayInputStream(bytes));
if (!img.isError()) {
Platform.runLater(() -> avatar.setFill(new ImagePattern(img)));
}
} catch (Exception ignored) {}
}).start();
}
Label author = new Label(resolveAuthorLabel(message));
@@ -529,6 +534,12 @@ public class ChatController {
content.setStyle("-fx-text-fill: " + (mine ? "#ffffff" : "#1f2937") + ";");
bubble.getChildren().add(content);
}
if (message.getAttachmentName() != null && !message.getAttachmentName().isBlank()) {
Label attachmentLabel = new Label("\uD83D\uDCCE " + message.getAttachmentName());
attachmentLabel.setStyle("-fx-text-fill: " + (mine ? "#dbeafe" : "#475569") + "; -fx-font-size: 12px;");
attachmentLabel.setWrapText(true);
bubble.getChildren().add(attachmentLabel);
}
bubble.getChildren().add(timestamp);
bubble.setMaxWidth(420);

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;
@@ -49,6 +56,9 @@ public class CustomerAccountsController {
@FXML
private TextField txtSearchCustomer;
@FXML
private ComboBox<String> cbStatusFilter;
@FXML
private Button btnEditCustomer;
@@ -66,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()));
@@ -82,14 +99,40 @@ public class CustomerAccountsController {
btnEditCustomer.setDisable(newVal == null));
btnEditCustomer.setDisable(true);
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Active", "Inactive"));
cbStatusFilter.getSelectionModel().selectFirst();
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyCustomerFilter(txtSearchCustomer.getText()));
txtSearchCustomer.textProperty().addListener((obs, o, n) -> applyCustomerFilter(n));
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();
cbStatusFilter.getSelectionModel().selectFirst();
TableViewSupport.clearSort(tvCustomers);
refresh();
TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -108,15 +151,15 @@ public class CustomerAccountsController {
}
try {
FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/example/petshopdesktop/dialogviews/staff-edit-dialog-view.fxml"));
FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/example/petshopdesktop/dialogviews/customer-edit-dialog-view.fxml"));
Stage dialog = new Stage();
dialog.initOwner(tvCustomers.getScene().getWindow());
dialog.initModality(Modality.APPLICATION_MODAL);
dialog.setTitle("Edit Customer Account");
dialog.setScene(new Scene(loader.load()));
dialog.setResizable(false);
var controller = (org.example.petshopdesktop.controllers.dialogcontrollers.StaffEditDialogController) loader.getController();
controller.setUser(selected);
var controller = (org.example.petshopdesktop.controllers.dialogcontrollers.CustomerEditDialogController) loader.getController();
controller.setCustomer(selected);
dialog.showAndWait();
refresh();
} catch (Exception e) {
@@ -154,16 +197,19 @@ public class CustomerAccountsController {
private void applyCustomerFilter(String text) {
String q = text == null ? "" : text.trim().toLowerCase();
if (q.isEmpty()) {
filteredCustomers.setPredicate(a -> true);
return;
}
filteredCustomers.setPredicate(a ->
safe(a.getUsername()).contains(q)
|| safe(a.getFullName()).contains(q)
|| safe(a.getEmail()).contains(q)
|| safe(a.getPhone()).contains(q)
);
String selectedStatus = cbStatusFilter.getValue();
filteredCustomers.setPredicate(a -> {
boolean textMatch = q.isEmpty()
|| safe(a.getUsername()).contains(q)
|| safe(a.getFullName()).contains(q)
|| safe(a.getEmail()).contains(q)
|| safe(a.getPhone()).contains(q);
boolean active = Boolean.TRUE.equals(a.getActive());
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|| (selectedStatus.equals("Active") && active)
|| (selectedStatus.equals("Inactive") && !active);
return textMatch && statusMatch;
});
}
private static String safe(String v) {

View File

@@ -12,7 +12,9 @@ import javafx.scene.control.cell.PropertyValueFactory;
import javafx.stage.Modality;
import org.example.petshopdesktop.auth.UserSession;
import javafx.stage.Stage;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.dto.inventory.InventoryResponse;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.api.endpoints.InventoryApi;
import org.example.petshopdesktop.controllers.dialogcontrollers.InventoryDialogController;
import org.example.petshopdesktop.models.Inventory;
@@ -63,6 +65,11 @@ public class InventoryController {
@FXML
private TextField txtSearch;
@FXML
private ComboBox<DropdownOption> cbStoreFilter;
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
private ObservableList<Inventory> data = FXCollections.observableArrayList();
//Determines if in add/edit mode
@@ -81,6 +88,13 @@ public class InventoryController {
colQuantity.setCellValueFactory(new PropertyValueFactory<>("quantity"));
colStoreName.setCellValueFactory(new PropertyValueFactory<>("storeName"));
if (UserSession.getInstance().isAdmin()) {
cbStoreFilter.setVisible(true);
cbStoreFilter.setManaged(true);
loadStoreFilter();
}
cbStoreFilter.valueProperty().addListener((obs, o, n) -> displayInventory());
displayInventory();
TableViewSupport.installDoubleClickAction(tvInventory, selected -> openDialog(selected, "Edit"));
@@ -108,6 +122,7 @@ public class InventoryController {
@FXML
void btnRefresh(ActionEvent event) {
txtSearch.clear();
cbStoreFilter.getSelectionModel().selectFirst();
tvInventory.getSortOrder().clear();
displayInventory();
TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -172,7 +187,7 @@ public class InventoryController {
} else {
new Thread(() -> {
try {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
Long storeId = selectedStoreId();
List<InventoryResponse> inventories = InventoryApi.getInstance().listInventory(filter, storeId);
List<Inventory> inventoryList = inventories.stream()
.map(this::mapToInventory)
@@ -198,7 +213,7 @@ public class InventoryController {
private void displayInventory() {
new Thread(() -> {
try {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
Long storeId = selectedStoreId();
List<InventoryResponse> inventories = InventoryApi.getInstance().listInventory(null, storeId);
List<Inventory> inventoryList = inventories.stream()
.map(this::mapToInventory)
@@ -253,6 +268,33 @@ public class InventoryController {
txtSearch.setText("");
}
private void loadStoreFilter() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
DropdownOption allStores = new DropdownOption();
allStores.setLabel("All Stores");
Platform.runLater(() -> {
storeOptions.clear();
storeOptions.addAll(stores);
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
items.add(allStores);
items.addAll(stores);
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
cbStoreFilter.getSelectionModel().selectFirst();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("InventoryController.loadStoreFilter", e, "Loading store filter");
}
}).start();
}
private Long selectedStoreId() {
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
DropdownOption selected = cbStoreFilter.getValue();
return (selected != null && selected.getId() != null) ? selected.getId() : null;
}
private Inventory mapToInventory(InventoryResponse response) {
return new Inventory(
response.getInventoryId().intValue(),

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

@@ -113,6 +113,9 @@ public class MainLayoutController {
@FXML
private Button btnRemoveAvatar;
@FXML
private Button btnRefreshAvatar;
@FXML
private Label lblUsername;
@@ -253,6 +256,29 @@ public class MainLayoutController {
}
}
@FXML
void btnRefreshAvatarClicked(ActionEvent event) {
btnRefreshAvatar.setDisable(true);
new Thread(() -> {
try {
UserInfoResponse userInfo = AuthApi.getInstance().getCurrentUser();
String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank()
? UserSession.getInstance().getUsername()
: userInfo.getFullName();
Image avatarImage = loadAvatarImage(userInfo.getAvatarUrl());
Platform.runLater(() -> {
UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl());
renderAvatar(displayName, avatarImage);
btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank());
btnRefreshAvatar.setDisable(false);
});
} catch (Exception e) {
Platform.runLater(() -> btnRefreshAvatar.setDisable(false));
ActivityLogger.getInstance().logException("MainLayoutController.btnRefreshAvatarClicked", e, "Refreshing avatar");
}
}).start();
}
@FXML
void btnRemoveAvatarClicked(ActionEvent event) {
try {

View File

@@ -86,18 +86,20 @@ public class PetController {
@FXML
private ComboBox<String> cbStatusFilter;
@FXML
private ComboBox<DropdownOption> cbStoreFilter;
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
@FXML
private TextField txtSearch;
@FXML
void btnRefresh(ActionEvent event) {
txtSearch.clear();
if (cbSpeciesFilter != null) {
cbSpeciesFilter.getSelectionModel().selectFirst();
}
if (cbStatusFilter != null) {
cbStatusFilter.getSelectionModel().selectFirst();
}
if (cbSpeciesFilter != null) cbSpeciesFilter.getSelectionModel().selectFirst();
if (cbStatusFilter != null) cbStatusFilter.getSelectionModel().selectFirst();
cbStoreFilter.getSelectionModel().selectFirst();
tvPets.getSortOrder().clear();
displayPets();
TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -194,6 +196,13 @@ public class PetController {
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Available", "Adopted", "Owned", "Pending"));
cbStatusFilter.getSelectionModel().selectFirst();
if (UserSession.getInstance().isAdmin()) {
cbStoreFilter.setVisible(true);
cbStoreFilter.setManaged(true);
loadStoreFilter();
}
cbStoreFilter.valueProperty().addListener((obs, o, n) -> applyFilters());
displayPets();
TableViewSupport.installDoubleClickAction(tvPets, selected -> openDialog(selected, "Edit"));
@@ -229,7 +238,7 @@ public class PetController {
} else {
new Thread(() -> {
try {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
Long storeId = selectedStoreId();
List<PetResponse> pets = PetApi.getInstance().listPets(filter, species, status, storeId);
List<Pet> petList = pets.stream()
.map(this::mapToPet)
@@ -255,7 +264,7 @@ public class PetController {
private void displayPets() {
new Thread(() -> {
try {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
Long storeId = selectedStoreId();
List<PetResponse> pets = PetApi.getInstance().listPets(null, selectedSpecies(), selectedStatus(), storeId);
List<Pet> petList = pets.stream()
.map(this::mapToPet)
@@ -281,6 +290,33 @@ public class PetController {
displayFilteredPet(txtSearch.getText());
}
private void loadStoreFilter() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
DropdownOption allStores = new DropdownOption();
allStores.setLabel("All Stores");
Platform.runLater(() -> {
storeOptions.clear();
storeOptions.addAll(stores);
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
items.add(allStores);
items.addAll(stores);
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
cbStoreFilter.getSelectionModel().selectFirst();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("PetController.loadStoreFilter", e, "Loading store filter");
}
}).start();
}
private Long selectedStoreId() {
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
DropdownOption selected = cbStoreFilter.getValue();
return (selected != null && selected.getId() != null) ? selected.getId() : null;
}
private void loadSpeciesFilter() {
new Thread(() -> {
try {

View File

@@ -10,6 +10,7 @@ import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.SelectionMode;
@@ -19,18 +20,25 @@ import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import org.example.petshopdesktop.auth.UserSession;
import javafx.concurrent.Task;
import org.example.petshopdesktop.api.endpoints.CustomerApi;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.api.endpoints.ProductApi;
import org.example.petshopdesktop.api.endpoints.SaleApi;
import org.example.petshopdesktop.api.endpoints.CouponApi;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.dto.coupon.CouponResponse;
import org.example.petshopdesktop.api.dto.product.ProductResponse;
import org.example.petshopdesktop.api.dto.sale.SaleItemRequest;
import org.example.petshopdesktop.api.dto.sale.SaleItemResponse;
import org.example.petshopdesktop.api.dto.sale.SaleRequest;
import org.example.petshopdesktop.api.dto.sale.SaleResponse;
import org.example.petshopdesktop.api.dto.user.UserResponse;
import org.example.petshopdesktop.models.Product;
import org.example.petshopdesktop.models.SaleCartItem;
import org.example.petshopdesktop.models.SaleDetail;
@@ -111,6 +119,9 @@ public class SaleController {
@FXML
private TableColumn<SaleLineItem, String> colEmployeeName;
@FXML
private TableColumn<SaleLineItem, String> colCustomerName;
@FXML
private TableColumn<SaleLineItem, String> colServiceProduct;
@@ -135,11 +146,73 @@ public class SaleController {
@FXML
private TextField txtSearch;
@FXML
private ComboBox<String> cbFilterPayment;
@FXML
private ComboBox<String> cbFilterRefundStatus;
@FXML
private ComboBox<DropdownOption> cbFilterCustomer;
@FXML
private ComboBox<DropdownOption> cbStoreFilter;
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
@FXML
private ComboBox<DropdownOption> cbCustomer;
@FXML
private CheckBox chkUseLoyaltyPoints;
@FXML
private Label lblLoyaltyPoints;
@FXML
private TextField txtCouponCode;
@FXML
private Button btnApplyCoupon;
@FXML
private Button btnClearCoupon;
@FXML
private Label lblCouponStatus;
@FXML
private Label lblSubtotal;
@FXML
private HBox hbCouponDiscount;
@FXML
private Label lblCouponDiscount;
@FXML
private HBox hbLoyaltyDiscount;
@FXML
private Label lblLoyaltyDiscountLabel;
@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;
private boolean saleSaveInProgress;
private CouponResponse appliedCoupon = null;
private UserResponse selectedCustomerData = null;
private final NumberFormat currency = NumberFormat.getCurrencyInstance(Locale.CANADA);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
@@ -173,6 +246,8 @@ public class SaleController {
colSaleId.setCellValueFactory(new PropertyValueFactory<>("saleId"));
colSaleDate.setCellValueFactory(new PropertyValueFactory<>("saleDate"));
colEmployeeName.setCellValueFactory(new PropertyValueFactory<>("employeeName"));
colCustomerName.setCellValueFactory(new PropertyValueFactory<>("customerName"));
colCustomerName.setMinWidth(120);
colServiceProduct.setCellValueFactory(new PropertyValueFactory<>("itemName"));
colSaleQuantity.setCellValueFactory(new PropertyValueFactory<>("quantity"));
colSaleUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
@@ -194,6 +269,24 @@ public class SaleController {
TableViewSupport.bindSortedItems(tvSales, filteredSales);
TableViewSupport.installDoubleClickAction(tvSales, selected -> openSaleDetailDialog(selected.getSaleId()));
if (UserSession.getInstance().isAdmin()) {
cbStoreFilter.setVisible(true);
cbStoreFilter.setManaged(true);
loadStoreFilter();
}
cbStoreFilter.valueProperty().addListener((obs, o, n) -> refreshSales(false));
cbFilterPayment.setItems(FXCollections.observableArrayList("All Payments", "Cash", "Card"));
cbFilterPayment.getSelectionModel().selectFirst();
cbFilterPayment.valueProperty().addListener((obs, o, n) -> applySalesFilter(txtSearch.getText()));
cbFilterRefundStatus.setItems(FXCollections.observableArrayList("All Status", "Sales Only", "Refunds Only"));
cbFilterRefundStatus.getSelectionModel().selectFirst();
cbFilterRefundStatus.valueProperty().addListener((obs, o, n) -> applySalesFilter(txtSearch.getText()));
loadCustomerFilter();
cbFilterCustomer.valueProperty().addListener((obs, o, n) -> applySalesFilter(txtSearch.getText()));
txtSearch.textProperty().addListener((obs, oldVal, newVal) -> applySalesFilter(newVal));
tvSales.widthProperty().addListener((obs, oldWidth, newWidth) -> updateSalesColumnWidths(newWidth.doubleValue()));
tvCart.widthProperty().addListener((obs, oldWidth, newWidth) -> updateCartColumnWidths(newWidth.doubleValue()));
@@ -214,7 +307,23 @@ public class SaleController {
setCreateSaleControlsDisabled(true);
Task<ObservableList<Product>> task = new Task<>() {
cbCustomer.valueProperty().addListener((obs, oldVal, newVal) -> {
if (newVal != null) {
loadCustomerDetails(newVal.getId());
} else {
selectedCustomerData = null;
lblLoyaltyPoints.setVisible(false);
lblLoyaltyPoints.setManaged(false);
chkUseLoyaltyPoints.setVisible(false);
chkUseLoyaltyPoints.setManaged(false);
chkUseLoyaltyPoints.setSelected(false);
updateCartTotal();
}
});
chkUseLoyaltyPoints.selectedProperty().addListener((obs, oldVal, newVal) -> updateCartTotal());
Task<ObservableList<Product>> productsTask = new Task<>() {
@Override
protected ObservableList<Product> call() throws Exception {
List<ProductResponse> productResponses = ProductApi.getInstance().listProducts(null);
@@ -232,13 +341,13 @@ public class SaleController {
}
};
task.setOnSucceeded(event -> {
cbProduct.setItems(task.getValue());
productsTask.setOnSucceeded(event -> {
cbProduct.setItems(productsTask.getValue());
setCreateSaleControlsDisabled(false);
});
task.setOnFailed(event -> {
Throwable e = task.getException();
productsTask.setOnFailed(event -> {
Throwable e = productsTask.getException();
ActivityLogger.getInstance().logException(
"SaleController.setupCreateSale",
e instanceof Exception ? (Exception) e : new RuntimeException(e),
@@ -248,6 +357,73 @@ public class SaleController {
showError("Sales", "Could not load products. Check the backend connection and refresh the view.");
});
new Thread(productsTask).start();
Task<List<DropdownOption>> customersTask = new Task<>() {
@Override
protected List<DropdownOption> call() throws Exception {
return DropdownApi.getInstance().getCustomers();
}
};
customersTask.setOnSucceeded(event -> {
List<DropdownOption> customers = customersTask.getValue();
ObservableList<DropdownOption> customerOptions = FXCollections.observableArrayList();
customerOptions.addAll(customers);
cbCustomer.setItems(customerOptions);
});
customersTask.setOnFailed(event -> {
Throwable e = customersTask.getException();
ActivityLogger.getInstance().logException(
"SaleController.setupCreateSale",
e instanceof Exception ? (Exception) e : new RuntimeException(e),
"Loading customers"
);
});
new Thread(customersTask).start();
}
private void loadCustomerDetails(Long customerId) {
Task<UserResponse> task = new Task<>() {
@Override
protected UserResponse call() throws Exception {
return CustomerApi.getInstance().getCustomerById(customerId);
}
};
task.setOnSucceeded(event -> {
selectedCustomerData = task.getValue();
if (selectedCustomerData != null) {
int pts = selectedCustomerData.getLoyaltyPoints() != null ? selectedCustomerData.getLoyaltyPoints() : 0;
lblLoyaltyPoints.setText(pts + " pts available");
lblLoyaltyPoints.setVisible(true);
lblLoyaltyPoints.setManaged(true);
boolean canRedeem = pts >= 20;
chkUseLoyaltyPoints.setVisible(canRedeem);
chkUseLoyaltyPoints.setManaged(canRedeem);
if (!canRedeem) chkUseLoyaltyPoints.setSelected(false);
} else {
lblLoyaltyPoints.setVisible(false);
lblLoyaltyPoints.setManaged(false);
chkUseLoyaltyPoints.setVisible(false);
chkUseLoyaltyPoints.setManaged(false);
chkUseLoyaltyPoints.setSelected(false);
}
updateCartTotal();
});
task.setOnFailed(event -> {
selectedCustomerData = null;
Throwable e = task.getException();
ActivityLogger.getInstance().logException(
"SaleController.loadCustomerDetails",
e instanceof Exception ? (Exception) e : new RuntimeException(e),
"Loading customer details"
);
});
new Thread(task).start();
}
@@ -269,8 +445,7 @@ public class SaleController {
Task<List<SaleLineItem>> task = new Task<List<SaleLineItem>>() {
@Override
protected List<SaleLineItem> call() throws Exception {
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
List<SaleResponse> sales = SaleApi.getInstance().listAllSales(null, storeId);
List<SaleResponse> sales = SaleApi.getInstance().listAllSales(null, selectedStoreId());
sales.sort(Comparator.comparing(SaleResponse::getSaleDate, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(SaleResponse::getSaleId, Comparator.nullsLast(Comparator.reverseOrder())));
List<SaleLineItem> lineItems = new ArrayList<>();
@@ -281,6 +456,9 @@ public class SaleController {
: "";
if (sale.getItems() != null && !sale.getItems().isEmpty()) {
double saleSubtotal = sale.getSubtotalAmount() != null ? Math.abs(sale.getSubtotalAmount().doubleValue()) : 0;
double saleActualTotal = sale.getTotalAmount() != null ? Math.abs(sale.getTotalAmount().doubleValue()) : 0;
double discountRatio = saleSubtotal > 0 ? saleActualTotal / saleSubtotal : 1.0;
for (SaleItemResponse item : sale.getItems()) {
boolean isRefund = sale.getIsRefund() != null && sale.getIsRefund();
double unitPrice = item.getUnitPrice() != null ? item.getUnitPrice().doubleValue() : 0.0;
@@ -288,7 +466,7 @@ public class SaleController {
if (isRefund && quantity > 0) {
quantity = -quantity;
}
double lineTotal = unitPrice * quantity;
double lineTotal = unitPrice * quantity * discountRatio;
lineItems.add(new SaleLineItem(
sale.getSaleId().intValue(),
saleDate,
@@ -299,7 +477,8 @@ public class SaleController {
lineTotal,
sale.getPaymentMethod(),
isRefund,
sale.getStoreName()
sale.getStoreName(),
sale.getCustomerName()
));
}
}
@@ -326,6 +505,10 @@ public class SaleController {
@FXML
void btnRefresh(ActionEvent event) {
txtSearch.clear();
cbFilterPayment.getSelectionModel().selectFirst();
cbFilterRefundStatus.getSelectionModel().selectFirst();
cbFilterCustomer.getSelectionModel().selectFirst();
cbStoreFilter.getSelectionModel().selectFirst();
TableViewSupport.clearSort(tvSales);
refreshSales(false);
TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -379,6 +562,73 @@ public class SaleController {
updateCartTotal();
}
@FXML
void btnApplyCoupon(ActionEvent event) {
String code = txtCouponCode.getText().trim();
if (code.isEmpty()) {
lblCouponStatus.setText("Enter a coupon code.");
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
return;
}
BigDecimal subtotal = BigDecimal.valueOf(cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum());
Task<CouponResponse> task = new Task<>() {
@Override
protected CouponResponse call() throws Exception {
return CouponApi.getInstance().getCouponByCode(code);
}
};
task.setOnSucceeded(evt -> {
CouponResponse coupon = task.getValue();
if (coupon == null) {
lblCouponStatus.setText("Coupon not found.");
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
return;
}
if (!Boolean.TRUE.equals(coupon.getActive())) {
lblCouponStatus.setText("Coupon is not active.");
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
return;
}
if (coupon.getMinOrderAmount() != null && subtotal.compareTo(coupon.getMinOrderAmount()) < 0) {
lblCouponStatus.setText("Order minimum not met ($" + coupon.getMinOrderAmount() + ").");
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
return;
}
appliedCoupon = coupon;
txtCouponCode.setDisable(true);
lblCouponStatus.setText("Coupon applied: " + coupon.getCouponCode());
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#27ae60"));
updateCartTotal();
});
task.setOnFailed(evt -> {
Throwable e = task.getException();
lblCouponStatus.setText("Invalid or not found.");
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
ActivityLogger.getInstance().logException(
"SaleController.btnApplyCoupon",
e instanceof Exception ? (Exception) e : new RuntimeException(e),
"Applying coupon"
);
});
new Thread(task).start();
}
@FXML
void btnClearCoupon(ActionEvent event) {
appliedCoupon = null;
txtCouponCode.clear();
txtCouponCode.setDisable(false);
lblCouponStatus.setText("");
hbCouponDiscount.setVisible(false);
hbCouponDiscount.setManaged(false);
updateCartTotal();
}
@FXML
void btnSaveSale(ActionEvent event) {
if (saleSaveInProgress) {
@@ -409,6 +659,7 @@ public class SaleController {
SaleRequest request = new SaleRequest();
request.setStoreId(storeId);
request.setPaymentMethod(payment);
request.setIsRefund(false);
List<SaleItemRequest> itemRequests = new ArrayList<>();
for (SaleCartItem cartItem : cartItems) {
@@ -419,6 +670,20 @@ public class SaleController {
}
request.setItems(itemRequests);
DropdownOption customerOption = cbCustomer.getValue();
if (customerOption != null) {
request.setCustomerId(customerOption.getId());
}
if (appliedCoupon != null) {
request.setCouponId(appliedCoupon.getCouponId());
}
if (chkUseLoyaltyPoints.isSelected() && selectedCustomerData != null) {
BigDecimal subtotal = BigDecimal.valueOf(cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum());
BigDecimal couponDiscount = calculateCouponDiscount(subtotal);
BigDecimal subtotalAfterCoupon = subtotal.subtract(couponDiscount);
request.setPointsUsed(calculatePointsToUse(subtotalAfterCoupon));
}
saleSaveInProgress = true;
setCreateSaleControlsDisabled(true);
btnRefund.setDisable(true);
@@ -437,6 +702,23 @@ public class SaleController {
SaleResponse response = task.getValue();
showInfo("Sale saved", "Sale ID " + response.getSaleId() + " was created.");
cartItems.clear();
appliedCoupon = null;
txtCouponCode.clear();
txtCouponCode.setDisable(false);
lblCouponStatus.setText("");
hbCouponDiscount.setVisible(false);
hbCouponDiscount.setManaged(false);
hbLoyaltyDiscount.setVisible(false);
hbLoyaltyDiscount.setManaged(false);
hbPointsToEarn.setVisible(false);
hbPointsToEarn.setManaged(false);
cbCustomer.setValue(null);
selectedCustomerData = null;
lblLoyaltyPoints.setVisible(false);
lblLoyaltyPoints.setManaged(false);
chkUseLoyaltyPoints.setVisible(false);
chkUseLoyaltyPoints.setManaged(false);
chkUseLoyaltyPoints.setSelected(false);
updateCartTotal();
refreshSales(true);
});
@@ -536,6 +818,9 @@ public class SaleController {
private SaleDetail mapToSaleDetail(SaleResponse sale) {
ObservableList<SaleDetail.SaleDetailItem> items = FXCollections.observableArrayList();
double saleSubtotal = sale.getSubtotalAmount() != null ? Math.abs(sale.getSubtotalAmount().doubleValue()) : 0;
double saleActualTotal = sale.getTotalAmount() != null ? Math.abs(sale.getTotalAmount().doubleValue()) : 0;
double discountRatio = saleSubtotal > 0 ? saleActualTotal / saleSubtotal : 1.0;
if (sale.getItems() != null) {
boolean isRefund = sale.getIsRefund() != null && sale.getIsRefund();
for (SaleItemResponse item : sale.getItems()) {
@@ -549,10 +834,13 @@ public class SaleController {
item.getProductName(),
quantity,
unitPrice,
unitPrice * quantity
unitPrice * quantity * discountRatio
));
}
}
double subtotal = sale.getSubtotalAmount() != null ? sale.getSubtotalAmount().doubleValue() : 0.0;
double couponDiscount = sale.getCouponDiscountAmount() != null ? sale.getCouponDiscountAmount().doubleValue() : 0.0;
double loyaltyDiscount = sale.getLoyaltyDiscountAmount() != null ? sale.getLoyaltyDiscountAmount().doubleValue() : 0.0;
return new SaleDetail(
sale.getSaleId().intValue(),
sale.getSaleDate(),
@@ -560,18 +848,86 @@ public class SaleController {
sale.getPaymentMethod(),
sale.getEmployeeName(),
Boolean.TRUE.equals(sale.getIsRefund()),
items
items,
sale.getCustomerName(),
subtotal,
couponDiscount,
loyaltyDiscount
);
}
private void updateCartTotal() {
double total = cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum();
lblCartTotal.setText(currency.format(total));
BigDecimal subtotal = BigDecimal.valueOf(cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum());
BigDecimal couponDiscount = calculateCouponDiscount(subtotal);
BigDecimal subtotalAfterCoupon = subtotal.subtract(couponDiscount);
BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(subtotalAfterCoupon);
BigDecimal total = subtotalAfterCoupon.subtract(loyaltyDiscount);
lblSubtotal.setText(currency.format(subtotal.doubleValue()));
if (couponDiscount.compareTo(BigDecimal.ZERO) > 0) {
lblCouponDiscount.setText("-" + currency.format(couponDiscount.doubleValue()));
hbCouponDiscount.setVisible(true);
hbCouponDiscount.setManaged(true);
} else {
hbCouponDiscount.setVisible(false);
hbCouponDiscount.setManaged(false);
}
if (loyaltyDiscount.compareTo(BigDecimal.ZERO) > 0) {
int pointsToUse = calculatePointsToUse(subtotalAfterCoupon);
lblLoyaltyDiscountLabel.setText("Loyalty Discount (" + pointsToUse + " pts):");
lblLoyaltyDiscount.setText("-" + currency.format(loyaltyDiscount.doubleValue()));
hbLoyaltyDiscount.setVisible(true);
hbLoyaltyDiscount.setManaged(true);
} else {
hbLoyaltyDiscount.setVisible(false);
hbLoyaltyDiscount.setManaged(false);
}
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) {
if (appliedCoupon == null) {
return BigDecimal.ZERO;
}
if ("PERCENTAGE".equalsIgnoreCase(appliedCoupon.getDiscountType())) {
return subtotal.multiply(appliedCoupon.getDiscountValue()).divide(BigDecimal.valueOf(100));
} else {
return appliedCoupon.getDiscountValue().min(subtotal);
}
}
private BigDecimal calculateLoyaltyDiscount(BigDecimal subtotalAfterCoupon) {
if (!chkUseLoyaltyPoints.isSelected() || selectedCustomerData == null) {
return BigDecimal.ZERO;
}
int pointsToUse = calculatePointsToUse(subtotalAfterCoupon);
return BigDecimal.valueOf(pointsToUse).multiply(BigDecimal.valueOf(0.05));
}
private int calculatePointsToUse(BigDecimal subtotalAfterCoupon) {
if (selectedCustomerData == null || selectedCustomerData.getLoyaltyPoints() == null) {
return 0;
}
int maxPointsNeeded = subtotalAfterCoupon.multiply(BigDecimal.valueOf(20)).intValue();
return Math.min(selectedCustomerData.getLoyaltyPoints(), maxPointsNeeded);
}
private void updateSalesColumnWidths(double tableWidth) {
double available = Math.max(tableWidth - 28.0, 0.0);
double baseWidth = 1255.0;
double baseWidth = 1395.0;
if (available <= 0) {
return;
}
@@ -580,6 +936,7 @@ public class SaleController {
colSaleId.setPrefWidth(60.0);
colSaleDate.setPrefWidth(170.0);
colEmployeeName.setPrefWidth(160.0);
colCustomerName.setPrefWidth(140.0);
colServiceProduct.setPrefWidth(320.0);
colSaleQuantity.setPrefWidth(70.0);
colSaleUnitPrice.setPrefWidth(115.0);
@@ -591,14 +948,15 @@ public class SaleController {
double extra = available - baseWidth;
colSaleId.setPrefWidth(60.0);
colSaleDate.setPrefWidth(170.0 + extra * 0.16);
colEmployeeName.setPrefWidth(160.0 + extra * 0.16);
colServiceProduct.setPrefWidth(320.0 + extra * 0.38);
colSaleDate.setPrefWidth(170.0 + extra * 0.14);
colEmployeeName.setPrefWidth(160.0 + extra * 0.14);
colCustomerName.setPrefWidth(140.0 + extra * 0.12);
colServiceProduct.setPrefWidth(320.0 + extra * 0.34);
colSaleQuantity.setPrefWidth(70.0);
colSaleUnitPrice.setPrefWidth(115.0 + extra * 0.08);
colSaleTotal.setPrefWidth(120.0 + extra * 0.08);
colSalePaymentType.setPrefWidth(110.0 + extra * 0.06);
colStoreName.setPrefWidth(130.0 + extra * 0.08);
colStoreName.setPrefWidth(130.0 + extra * 0.04);
}
private void updateCartColumnWidths(double tableWidth) {
@@ -631,28 +989,92 @@ public class SaleController {
cbPaymentMethod.setDisable(disabled);
btnClearCart.setDisable(disabled);
btnSaveSale.setDisable(disabled);
cbCustomer.setDisable(disabled);
txtCouponCode.setDisable(disabled);
btnApplyCoupon.setDisable(disabled);
btnClearCoupon.setDisable(disabled);
chkUseLoyaltyPoints.setDisable(disabled);
}
private void applySalesFilter(String filter) {
String f = filter == null ? "" : filter.trim().toLowerCase();
if (f.isEmpty()) {
filteredSales.setPredicate(s -> true);
return;
}
String selectedPayment = cbFilterPayment.getValue();
String selectedStatus = cbFilterRefundStatus.getValue();
DropdownOption selectedCustomer = cbFilterCustomer.getValue();
filteredSales.setPredicate(s ->
String.valueOf(s.getSaleId()).contains(f)
filteredSales.setPredicate(s -> {
boolean textMatch = f.isEmpty()
|| String.valueOf(s.getSaleId()).contains(f)
|| safe(s.getSaleDate()).contains(f)
|| safe(s.getEmployeeName()).contains(f)
|| safe(s.getCustomerName()).contains(f)
|| safe(s.getItemName()).contains(f)
|| safe(s.getPaymentMethod()).contains(f)
);
|| safe(s.getPaymentMethod()).contains(f);
boolean paymentMatch = selectedPayment == null || selectedPayment.equals("All Payments")
|| safe(s.getPaymentMethod()).equals(selectedPayment.toLowerCase());
boolean refundMatch = selectedStatus == null || selectedStatus.equals("All Status")
|| (selectedStatus.equals("Sales Only") && !s.isRefund())
|| (selectedStatus.equals("Refunds Only") && s.isRefund());
boolean customerMatch = selectedCustomer == null || selectedCustomer.getId() == null
|| safe(s.getCustomerName()).equals(safe(selectedCustomer.getLabel()));
return textMatch && paymentMatch && refundMatch && customerMatch;
});
}
private static String safe(String v) {
return v == null ? "" : v.toLowerCase();
}
private void loadCustomerFilter() {
new Thread(() -> {
try {
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
DropdownOption allCustomers = new DropdownOption();
allCustomers.setLabel("All Customers");
Platform.runLater(() -> {
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
items.add(allCustomers);
items.addAll(customers);
cbFilterCustomer.setItems(FXCollections.observableArrayList(items));
cbFilterCustomer.getSelectionModel().selectFirst();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("SaleController.loadCustomerFilter", e, "Loading customer filter");
}
}).start();
}
private void loadStoreFilter() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
DropdownOption allStores = new DropdownOption();
allStores.setLabel("All Stores");
Platform.runLater(() -> {
storeOptions.clear();
storeOptions.addAll(stores);
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
items.add(allStores);
items.addAll(stores);
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
cbStoreFilter.getSelectionModel().selectFirst();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("SaleController.loadStoreFilter", e, "Loading store filter");
}
}).start();
}
private Long selectedStoreId() {
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
DropdownOption selected = cbStoreFilter.getValue();
return (selected != null && selected.getId() != null) ? selected.getId() : null;
}
private void showError(String title, String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle(title);

View File

@@ -7,15 +7,19 @@ 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;
import org.example.petshopdesktop.api.dto.user.UserResponse;
import org.example.petshopdesktop.api.endpoints.UserApi;
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;
@@ -24,62 +28,48 @@ import java.util.stream.Collectors;
public class StaffAccountsController {
@FXML
private VBox staffSection;
@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;
@FXML private TableColumn<EmployeeResponse, String> colPhone;
@FXML private TableColumn<EmployeeResponse, String> colRole;
@FXML private TableColumn<EmployeeResponse, String> colStatus;
@FXML private TableColumn<EmployeeResponse, Object> colCreated;
@FXML private TextField txtSearch;
@FXML private ComboBox<String> cbRoleFilter;
@FXML private ComboBox<String> cbStatusFilter;
@FXML private Button btnRefresh;
@FXML private Button btnCreateAccount;
@FXML private Button btnEditAccount;
@FXML private Label lblError;
@FXML private Label lblStatus;
@FXML
private TableView<UserResponse> tvStaff;
@FXML
private TableColumn<UserResponse, String> colUsername;
@FXML
private TableColumn<UserResponse, String> colName;
@FXML
private TableColumn<UserResponse, String> colEmail;
@FXML
private TableColumn<UserResponse, String> colPhone;
@FXML
private TableColumn<UserResponse, String> colRole;
@FXML
private TableColumn<UserResponse, String> colStatus;
@FXML
private TableColumn<UserResponse, Object> colCreated;
@FXML
private TextField txtSearch;
@FXML
private Button btnRefresh;
@FXML
private Button btnCreateAccount;
@FXML
private Button btnEditAccount;
@FXML
private Label lblError;
@FXML
private Label lblStatus;
private final ObservableList<UserResponse> staffAccounts = FXCollections.observableArrayList();
private FilteredList<UserResponse> filteredStaff;
private final ObservableList<EmployeeResponse> staffAccounts = FXCollections.observableArrayList();
private FilteredList<EmployeeResponse> filteredStaff;
@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()));
colPhone.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getPhone()));
colRole.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getRole()));
colStatus.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getActive() != null && data.getValue().getActive() ? "Active" : "Inactive"));
colRole.setCellValueFactory(data -> {
String role = data.getValue().getRole() != null ? data.getValue().getRole() : "";
String staffRole = data.getValue().getStaffRole() != null ? " (" + data.getValue().getStaffRole() + ")" : "";
return new javafx.beans.property.SimpleStringProperty(role + staffRole);
});
colStatus.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(
Boolean.TRUE.equals(data.getValue().getActive()) ? "Active" : "Inactive"));
colCreated.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getCreatedAt()));
filteredStaff = new FilteredList<>(staffAccounts, a -> true);
@@ -90,14 +80,45 @@ public class StaffAccountsController {
btnEditAccount.setDisable(newVal == null));
btnEditAccount.setDisable(true);
cbRoleFilter.setItems(FXCollections.observableArrayList("All Roles", "Admin", "Staff"));
cbRoleFilter.getSelectionModel().selectFirst();
cbRoleFilter.valueProperty().addListener((obs, o, n) -> applyStaffFilter(txtSearch.getText()));
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Active", "Inactive"));
cbStatusFilter.getSelectionModel().selectFirst();
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyStaffFilter(txtSearch.getText()));
txtSearch.textProperty().addListener((obs, o, n) -> applyStaffFilter(n));
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();
cbRoleFilter.getSelectionModel().selectFirst();
cbStatusFilter.getSelectionModel().selectFirst();
TableViewSupport.clearSort(tvStaff);
refresh();
TableViewSupport.flashStatus(lblStatus, "Refreshed");
@@ -128,7 +149,7 @@ public class StaffAccountsController {
openEditDialog(tvStaff.getSelectionModel().getSelectedItem());
}
private void openEditDialog(UserResponse selected) {
private void openEditDialog(EmployeeResponse selected) {
if (selected == null) {
lblError.setText("Select a user account to edit.");
return;
@@ -152,12 +173,12 @@ public class StaffAccountsController {
dialog.setScene(new Scene(loader.load()));
dialog.setResizable(false);
var controller = (org.example.petshopdesktop.controllers.dialogcontrollers.StaffEditDialogController) loader.getController();
controller.setUser(selected);
controller.setEmployee(selected);
dialog.showAndWait();
refresh();
} catch (Exception e) {
ActivityLogger.getInstance().logException("StaffAccountsController.openEditDialog", e, "Opening user edit dialog");
lblError.setText("Could not open user account editor.");
ActivityLogger.getInstance().logException("StaffAccountsController.openEditDialog", e, "Opening employee edit dialog");
lblError.setText("Could not open employee account editor.");
}
}
@@ -167,11 +188,10 @@ public class StaffAccountsController {
new Thread(() -> {
try {
Comparator<UserResponse> byCreated = Comparator.comparing(
UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
Comparator<EmployeeResponse> byCreated = Comparator.comparing(
EmployeeResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
List<UserResponse> staff = UserApi.getInstance().listUsers(null).stream()
.filter(u -> !"CUSTOMER".equalsIgnoreCase(u.getRole()))
List<EmployeeResponse> staff = EmployeeApi.getInstance().listEmployees(null).stream()
.sorted(byCreated)
.collect(Collectors.toList());
@@ -180,7 +200,7 @@ public class StaffAccountsController {
tvStaff.setDisable(false);
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("StaffAccountsController.refresh", e, "Loading staff accounts");
ActivityLogger.getInstance().logException("StaffAccountsController.refresh", e, "Loading employee accounts");
Platform.runLater(() -> {
lblError.setText("Could not load staff accounts.");
tvStaff.setDisable(false);
@@ -191,17 +211,24 @@ public class StaffAccountsController {
private void applyStaffFilter(String text) {
String q = text == null ? "" : text.trim().toLowerCase();
if (q.isEmpty()) {
filteredStaff.setPredicate(a -> true);
return;
}
filteredStaff.setPredicate(a ->
safe(a.getUsername()).contains(q)
|| safe(a.getFullName()).contains(q)
|| safe(a.getEmail()).contains(q)
|| safe(a.getPhone()).contains(q)
|| safe(a.getRole()).contains(q)
);
String selectedRole = cbRoleFilter.getValue();
String selectedStatus = cbStatusFilter.getValue();
filteredStaff.setPredicate(a -> {
boolean textMatch = q.isEmpty()
|| safe(a.getUsername()).contains(q)
|| safe(a.getFullName()).contains(q)
|| safe(a.getEmail()).contains(q)
|| safe(a.getPhone()).contains(q)
|| safe(a.getRole()).contains(q)
|| safe(a.getStaffRole()).contains(q);
boolean active = Boolean.TRUE.equals(a.getActive());
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|| (selectedStatus.equals("Active") && active)
|| (selectedStatus.equals("Inactive") && !active);
boolean roleMatch = selectedRole == null || selectedRole.equals("All Roles")
|| safe(a.getRole()).equals(selectedRole.toLowerCase());
return textMatch && statusMatch && roleMatch;
});
}
private static String safe(String v) {

View File

@@ -8,164 +8,98 @@ import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ChoiceDialog;
import javafx.scene.control.ComboBox;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.TextField;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import org.example.petshopdesktop.api.dto.adoption.AdoptionRequest;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.endpoints.AdoptionApi;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.api.endpoints.PetApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.models.Adoption;
import org.example.petshopdesktop.util.ActivityLogger;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
public class AdoptionDialogController {
@FXML
private Button btnCancel;
@FXML
private Button btnSave;
@FXML
private ComboBox<String> cbAdoptionStatus;
@FXML
private ComboBox<DropdownOption> cbCustomer;
@FXML
private ComboBox<DropdownOption> cbEmployee;
@FXML
private ComboBox<DropdownOption> cbPet;
@FXML
private DatePicker dpAdoptionDate;
@FXML
private Label lblAdoptionId;
@FXML
private Label lblMode;
@FXML private Button btnCancel;
@FXML private Button btnSave;
@FXML private ComboBox<String> cbAdoptionStatus;
@FXML private ComboBox<DropdownOption> cbCustomer;
@FXML private ComboBox<DropdownOption> cbEmployee;
@FXML private ComboBox<DropdownOption> cbPet;
@FXML private ComboBox<DropdownOption> cbStore;
@FXML private VBox vbStore;
@FXML private DatePicker dpAdoptionDate;
@FXML private TextField txtAdoptionFee;
@FXML private Label lblAdoptionId;
@FXML private Label lblMode;
private String mode = null;
private Adoption selectedAdoption = null;
private String selectedPaymentMethod = null;
private boolean suppressPaymentDialog = false;
private boolean suppressStatusListener = false;
private Long pendingStoreId = null;
private boolean isEditing = false;
private ObservableList<String> statusList = FXCollections.observableArrayList(
private final ObservableList<String> statusList = FXCollections.observableArrayList(
"Pending", "Completed", "Missed", "Cancelled"
);
@FXML
void initialize() {
cbAdoptionStatus.setItems(statusList);
cbAdoptionStatus.valueProperty().addListener((obs, oldVal, newVal) -> {
if ("Completed".equals(newVal) && !"Completed".equals(oldVal) && !suppressPaymentDialog) {
ChoiceDialog<String> dialog = new ChoiceDialog<>("Cash", "Cash", "Credit Card", "Debit Card", "E-Transfer");
dialog.setTitle("Payment Method");
dialog.setHeaderText("Confirm payment received");
dialog.setContentText("Select payment method:");
dialog.showAndWait().ifPresentOrElse(
method -> selectedPaymentMethod = method,
() -> {
selectedPaymentMethod = null;
cbAdoptionStatus.setValue(oldVal);
}
);
} else if (!"Completed".equals(newVal)) {
selectedPaymentMethod = null;
if (!suppressStatusListener && newVal != null) {
applyStatusFieldRules(newVal);
}
});
cbEmployee.setPromptText("Select an employee");
new Thread(() -> {
try {
List<DropdownOption> pets = DropdownApi.getInstance().getAdoptionPets();
Platform.runLater(() -> {
if (pets != null) {
ObservableList<DropdownOption> petsObs = FXCollections.observableArrayList(pets);
ensureSelectedPetOption(petsObs);
cbPet.setItems(petsObs);
applySelectedPet();
}
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.initialize",
e,
"Loading pets for combo box");
System.out.println("Error loading pets: " + e.getMessage());
});
}
}).start();
txtAdoptionFee.setDisable(true);
new Thread(() -> {
try {
List<DropdownOption> employees = DropdownApi.getInstance().getEmployees();
Platform.runLater(() -> {
ObservableList<DropdownOption> employeesObs = FXCollections.observableArrayList(employees);
ensureSelectedEmployeeOption(employeesObs);
cbEmployee.setItems(employeesObs);
applySelectedEmployee();
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.initialize",
e,
"Loading employees for combo box");
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Unable to load employees");
});
}
}).start();
cbEmployee.setCellFactory(param -> new ListCell<>() {
LocalDate today = LocalDate.now();
dpAdoptionDate.setDayCellFactory(picker -> new javafx.scene.control.DateCell() {
@Override
protected void updateItem(DropdownOption option, boolean empty) {
super.updateItem(option, empty);
setText(empty || option == null ? null : option.getLabel());
}
});
cbEmployee.setButtonCell(new ListCell<>() {
@Override
protected void updateItem(DropdownOption option, boolean empty) {
super.updateItem(option, empty);
setText(empty || option == null ? null : option.getLabel());
public void updateItem(LocalDate item, boolean empty) {
super.updateItem(item, empty);
setDisable(empty || item.isBefore(today));
}
});
new Thread(() -> {
try {
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
Platform.runLater(() -> {
if (customers != null) {
ObservableList<DropdownOption> customersObs = FXCollections.observableArrayList(customers);
cbCustomer.setItems(customersObs);
applySelectedCustomer();
}
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.initialize",
e,
"Loading customers for combo box");
System.out.println("Error loading customers: " + e.getMessage());
});
setupDropdownCellFactory(cbEmployee);
setupDropdownCellFactory(cbPet);
setupDropdownCellFactory(cbCustomer);
setupDropdownCellFactory(cbStore);
cbPet.valueProperty().addListener((obs, oldVal, newVal) -> {
if (newVal != null) {
loadPetPrice(newVal.getId());
} else {
txtAdoptionFee.setText("0");
}
}).start();
});
if (UserSession.getInstance().isAdmin()) {
vbStore.setVisible(true);
vbStore.setManaged(true);
cbPet.setDisable(true);
cbPet.setPromptText("Select a store first");
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Select a store first");
} else {
cbEmployee.setPromptText("Select an employee");
}
loadDropdownsAsync();
btnSave.setOnMouseClicked(new EventHandler<MouseEvent>() {
@Override
@@ -182,32 +116,236 @@ public class AdoptionDialogController {
});
}
private void setupDropdownCellFactory(ComboBox<DropdownOption> cb) {
cb.setCellFactory(param -> new ListCell<>() {
@Override protected void updateItem(DropdownOption o, boolean empty) {
super.updateItem(o, empty);
setText(empty || o == null ? null : o.getLabel());
}
});
cb.setButtonCell(new ListCell<>() {
@Override protected void updateItem(DropdownOption o, boolean empty) {
super.updateItem(o, empty);
setText(empty || o == null ? null : o.getLabel());
}
});
}
private void loadDropdownsAsync() {
new Thread(() -> {
try {
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
Platform.runLater(() -> {
if (customers != null) {
cbCustomer.setItems(FXCollections.observableArrayList(customers));
applySelectedCustomer();
}
});
} catch (Exception e) {
Platform.runLater(() -> ActivityLogger.getInstance().logException(
"AdoptionDialogController.loadDropdownsAsync", e, "Loading customers"));
}
}).start();
if (UserSession.getInstance().isAdmin()) {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
Platform.runLater(() -> {
if (stores != null) {
cbStore.setItems(FXCollections.observableArrayList(stores));
cbStore.valueProperty().addListener((obs, oldVal, newVal) -> {
Long sid = newVal != null ? newVal.getId() : null;
cbEmployee.setValue(null);
cbEmployee.setItems(FXCollections.observableArrayList());
if (sid != null) {
String status = cbAdoptionStatus.getValue();
boolean locked = "Cancelled".equalsIgnoreCase(status)
|| "Completed".equalsIgnoreCase(status)
|| "Missed".equalsIgnoreCase(status);
if (!locked) {
cbEmployee.setDisable(false);
cbEmployee.setPromptText("Select an employee");
}
loadEmployeesForStore(sid);
if (!isEditing) {
cbPet.setValue(null);
cbPet.setItems(FXCollections.observableArrayList());
cbPet.setDisable(true);
cbPet.setPromptText("Select a pet");
loadAdoptionPetsForStore(sid);
}
} else {
String status = cbAdoptionStatus.getValue();
boolean locked = "Cancelled".equalsIgnoreCase(status)
|| "Completed".equalsIgnoreCase(status)
|| "Missed".equalsIgnoreCase(status);
if (!locked) {
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Select a store first");
}
if (!isEditing) {
cbPet.setValue(null);
cbPet.setItems(FXCollections.observableArrayList());
cbPet.setDisable(true);
cbPet.setPromptText("Select a store first");
}
}
});
if (pendingStoreId != null) {
for (DropdownOption store : cbStore.getItems()) {
if (pendingStoreId.equals(store.getId())) {
cbStore.setValue(store);
break;
}
}
pendingStoreId = null;
}
}
});
} catch (Exception e) {
Platform.runLater(() -> ActivityLogger.getInstance().logException(
"AdoptionDialogController.loadDropdownsAsync", e, "Loading stores"));
}
}).start();
} else {
Long storeId = UserSession.getInstance().getStoreId();
if (storeId != null && storeId > 0) {
new Thread(() -> {
try {
List<DropdownOption> employees = DropdownApi.getInstance().getStoreEmployees(storeId);
Platform.runLater(() -> {
ObservableList<DropdownOption> employeesObs = FXCollections.observableArrayList(employees != null ? employees : List.of());
ensureSelectedEmployeeOption(employeesObs);
cbEmployee.setItems(employeesObs);
applySelectedEmployee();
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.loadDropdownsAsync", e, "Loading employees");
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Unable to load employees");
});
}
}).start();
new Thread(() -> {
try {
List<DropdownOption> pets = DropdownApi.getInstance().getAdoptionPets(storeId);
Platform.runLater(() -> {
if (!isEditing && pets != null) {
ObservableList<DropdownOption> petsObs = FXCollections.observableArrayList(pets);
cbPet.setItems(petsObs);
if (petsObs.isEmpty()) {
cbPet.setDisable(true);
cbPet.setPromptText("No available pets for this store");
}
}
});
} catch (Exception e) {
Platform.runLater(() -> ActivityLogger.getInstance().logException(
"AdoptionDialogController.loadDropdownsAsync", e, "Loading pets"));
}
}).start();
}
}
}
private void loadEmployeesForStore(Long storeId) {
new Thread(() -> {
try {
List<DropdownOption> employees = DropdownApi.getInstance().getStoreEmployees(storeId);
Platform.runLater(() -> {
ObservableList<DropdownOption> employeesObs = FXCollections.observableArrayList(employees != null ? employees : List.of());
ensureSelectedEmployeeOption(employeesObs);
cbEmployee.setItems(employeesObs);
applySelectedEmployee();
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.loadEmployeesForStore", e, "Loading employees for store");
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Unable to load employees");
});
}
}).start();
}
private void loadAdoptionPetsForStore(Long storeId) {
new Thread(() -> {
try {
List<DropdownOption> pets = DropdownApi.getInstance().getAdoptionPets(storeId);
Platform.runLater(() -> {
if (pets != null) {
ObservableList<DropdownOption> petsObs = FXCollections.observableArrayList(pets);
cbPet.setItems(petsObs);
if (petsObs.isEmpty()) {
cbPet.setDisable(true);
cbPet.setPromptText("No available pets for this store");
} else {
cbPet.setDisable(false);
}
}
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.loadAdoptionPetsForStore", e, "Loading adoption pets for store");
cbPet.setDisable(true);
cbPet.setPromptText("Unable to load pets");
});
}
}).start();
}
private void applyStatusFieldRules(String status) {
if ("Cancelled".equalsIgnoreCase(status) || "Completed".equalsIgnoreCase(status) || "Missed".equalsIgnoreCase(status)) {
cbEmployee.setDisable(true);
dpAdoptionDate.setDisable(true);
cbStore.setDisable(true);
} else {
cbEmployee.setDisable(false);
dpAdoptionDate.setDisable(false);
if (UserSession.getInstance().isAdmin() && !isEditing) cbStore.setDisable(false);
}
}
private void buttonSaveClicked(MouseEvent mouseEvent) {
String errorMsg = "";
if (cbPet.getSelectionModel().getSelectedItem() == null) {
errorMsg += "Pet is required.\n";
}
if (cbPet.getSelectionModel().getSelectedItem() == null) errorMsg += "Pet is required.\n";
if (cbCustomer.getSelectionModel().getSelectedItem() == null) errorMsg += "Customer is required.\n";
if (cbEmployee.getSelectionModel().getSelectedItem() == null) errorMsg += "Employee is required.\n";
if (dpAdoptionDate.getValue() == null) errorMsg += "Adoption Date is required.\n";
if (cbAdoptionStatus.getSelectionModel().getSelectedItem() == null) errorMsg += "Status is required.\n";
if (cbCustomer.getSelectionModel().getSelectedItem() == null) {
errorMsg += "Customer is required.\n";
}
if (cbEmployee.getSelectionModel().getSelectedItem() == null) {
errorMsg += "Employee is required.\n";
}
if (dpAdoptionDate.getValue() == null) {
errorMsg += "Adoption Date is required.\n";
}
if (cbAdoptionStatus.getSelectionModel().getSelectedItem() == null) {
errorMsg += "Status is required.\n";
BigDecimal adoptionFee = BigDecimal.ZERO;
String feeText = txtAdoptionFee.getText() == null ? "" : txtAdoptionFee.getText().trim();
if (!feeText.isEmpty()) {
try {
adoptionFee = new BigDecimal(feeText);
} catch (NumberFormatException e) {
adoptionFee = BigDecimal.ZERO;
}
}
if (errorMsg.isEmpty()) {
try {
Long storeId = UserSession.getInstance().getStoreId();
Long storeId;
if (UserSession.getInstance().isAdmin()) {
if (cbStore.getSelectionModel().getSelectedItem() == null) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setHeaderText("Input Error");
alert.setContentText("Store is required.");
alert.showAndWait();
return;
}
storeId = cbStore.getSelectionModel().getSelectedItem().getId();
} else {
storeId = UserSession.getInstance().getStoreId();
}
if (storeId == null || storeId <= 0) {
throw new IllegalStateException("Store is not set for this account");
}
@@ -219,15 +357,13 @@ public class AdoptionDialogController {
request.setSourceStoreId(storeId);
request.setAdoptionDate(dpAdoptionDate.getValue());
request.setAdoptionStatus(cbAdoptionStatus.getValue());
request.setPaymentMethod(selectedPaymentMethod);
request.setAdoptionFee(adoptionFee);
if (mode.equals("Add")) {
AdoptionApi.getInstance().createAdoption(request);
} else {
String[] parts = lblAdoptionId.getText().split(": ");
if (parts.length < 2) {
throw new IllegalStateException("Invalid adoption ID format");
}
if (parts.length < 2) throw new IllegalStateException("Invalid adoption ID format");
Long adoptionId = Long.parseLong(parts[1]);
AdoptionApi.getInstance().updateAdoption(adoptionId, request);
}
@@ -239,9 +375,7 @@ public class AdoptionDialogController {
closeStage(mouseEvent);
} catch (Exception e) {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.buttonSaveClicked",
e,
mode + " adoption");
"AdoptionDialogController.buttonSaveClicked", e, mode + " adoption");
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setHeaderText("Database Operation Error");
alert.setContentText(e.getMessage());
@@ -262,36 +396,46 @@ public class AdoptionDialogController {
}
public void displayAdoptionDetails(Adoption adoption) {
if (adoption != null) {
selectedAdoption = adoption;
lblAdoptionId.setText("ID: " + adoption.getAdoptionId());
ensureSelectedEmployeeOption(cbEmployee.getItems());
applySelectedPet();
applySelectedCustomer();
applySelectedEmployee();
if (adoption == null) return;
selectedAdoption = adoption;
isEditing = true;
lblAdoptionId.setText("ID: " + adoption.getAdoptionId());
pendingStoreId = adoption.getStoreId();
if (adoption.getAdoptionDate() != null && !adoption.getAdoptionDate().isEmpty()) {
try {
dpAdoptionDate.setValue(LocalDate.parse(adoption.getAdoptionDate()));
} catch (Exception e) {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.displayAdoptionDetails",
e,
"Parsing adoption date");
}
}
suppressPaymentDialog = true;
cbAdoptionStatus.setItems(statusList);
for (String status : cbAdoptionStatus.getItems()) {
if (status.equals(adoption.getAdoptionStatus())) {
cbAdoptionStatus.getSelectionModel().select(status);
break;
}
}
suppressPaymentDialog = false;
applyEditModeLock();
if (adoption.getPetId() > 0) {
DropdownOption petOption = new DropdownOption();
petOption.setId((long) adoption.getPetId());
petOption.setLabel(adoption.getPetName() != null && !adoption.getPetName().isBlank()
? adoption.getPetName() : "Pet #" + adoption.getPetId());
cbPet.setItems(FXCollections.observableArrayList(petOption));
cbPet.setValue(petOption);
}
applySelectedCustomer();
applySelectedEmployee();
if (adoption.getAdoptionDate() != null && !adoption.getAdoptionDate().isEmpty()) {
try {
dpAdoptionDate.setValue(LocalDate.parse(adoption.getAdoptionDate()));
} catch (Exception e) {
ActivityLogger.getInstance().logException(
"AdoptionDialogController.displayAdoptionDetails", e, "Parsing adoption date");
}
}
txtAdoptionFee.setText(adoption.getAdoptionFee() > 0
? String.format("%.2f", adoption.getAdoptionFee()) : "0.00");
suppressStatusListener = true;
cbAdoptionStatus.setItems(statusList);
for (String status : cbAdoptionStatus.getItems()) {
if (status.equals(adoption.getAdoptionStatus())) {
cbAdoptionStatus.getSelectionModel().select(status);
break;
}
}
suppressStatusListener = false;
applyEditModeLock();
}
private void applyEditModeLock() {
@@ -304,6 +448,7 @@ public class AdoptionDialogController {
dpAdoptionDate.setDisable(true);
cbAdoptionStatus.setDisable(true);
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Cancelled"));
cbStore.setDisable(true);
btnSave.setDisable(true);
return;
}
@@ -311,23 +456,33 @@ public class AdoptionDialogController {
LocalDate adoptionDate = dpAdoptionDate.getValue();
boolean isPast = adoptionDate != null && adoptionDate.isBefore(LocalDate.now());
cbPet.setDisable(true);
cbCustomer.setDisable(true);
dpAdoptionDate.setDisable(false);
cbEmployee.setDisable(false);
cbAdoptionStatus.setDisable(false);
suppressPaymentDialog = true;
if (isPast) {
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Completed", "Missed"));
cbPet.setDisable(true);
cbCustomer.setDisable(true);
cbEmployee.setDisable(true);
dpAdoptionDate.setDisable(true);
cbStore.setDisable(true);
cbAdoptionStatus.setDisable(false);
suppressStatusListener = true;
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Completed", "Missed"));
if (!cbAdoptionStatus.getItems().contains(cbAdoptionStatus.getValue())) {
cbAdoptionStatus.getSelectionModel().selectFirst();
}
suppressStatusListener = false;
} else {
cbPet.setDisable(true);
cbCustomer.setDisable(true);
dpAdoptionDate.setDisable(false);
cbEmployee.setDisable(false);
cbAdoptionStatus.setDisable(false);
cbStore.setDisable(true);
suppressStatusListener = true;
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Pending", "Cancelled"));
if (!cbAdoptionStatus.getItems().contains(cbAdoptionStatus.getValue())) {
cbAdoptionStatus.getSelectionModel().selectFirst();
}
suppressStatusListener = false;
}
if (!cbAdoptionStatus.getItems().contains(cbAdoptionStatus.getValue())) {
cbAdoptionStatus.getSelectionModel().selectFirst();
}
suppressPaymentDialog = false;
}
public void setMode(String mode) {
@@ -335,60 +490,64 @@ public class AdoptionDialogController {
lblMode.setText(mode + " Adoption");
lblAdoptionId.setVisible(mode.equals("Edit"));
if (mode.equals("Add")) {
suppressPaymentDialog = true;
suppressStatusListener = true;
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Pending"));
cbAdoptionStatus.setValue("Pending");
cbAdoptionStatus.setDisable(true);
suppressPaymentDialog = false;
suppressStatusListener = false;
txtAdoptionFee.setText("");
}
}
private void applySelectedPet() {
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0) {
return;
}
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0) return;
DropdownOption selected = findOptionById(cbPet.getItems(), (long) selectedAdoption.getPetId());
if (selected != null && !Objects.equals(cbPet.getValue(), selected)) {
cbPet.setValue(selected);
}
if (selected != null && !Objects.equals(cbPet.getValue(), selected)) cbPet.setValue(selected);
}
private void applySelectedCustomer() {
if (selectedAdoption == null || selectedAdoption.getCustomerId() <= 0) {
return;
}
if (selectedAdoption == null || selectedAdoption.getCustomerId() <= 0) return;
DropdownOption selected = findOptionById(cbCustomer.getItems(), (long) selectedAdoption.getCustomerId());
if (selected != null && !Objects.equals(cbCustomer.getValue(), selected)) {
cbCustomer.setValue(selected);
}
if (selected != null && !Objects.equals(cbCustomer.getValue(), selected)) cbCustomer.setValue(selected);
}
private void applySelectedEmployee() {
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0) {
return;
}
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0) return;
DropdownOption selected = findOptionById(cbEmployee.getItems(), (long) selectedAdoption.getEmployeeId());
if (selected != null && !Objects.equals(cbEmployee.getValue(), selected)) {
cbEmployee.setValue(selected);
}
if (selected != null && !Objects.equals(cbEmployee.getValue(), selected)) cbEmployee.setValue(selected);
}
private DropdownOption findOptionById(List<DropdownOption> options, Long id) {
if (id == null || options == null) {
return null;
}
if (id == null || options == null) return null;
for (DropdownOption option : options) {
if (option.getId() != null && option.getId().equals(id)) {
return option;
}
if (option.getId() != null && option.getId().equals(id)) return option;
}
return null;
}
private void loadPetPrice(Long petId) {
new Thread(() -> {
try {
var pet = PetApi.getInstance().getPetById(petId);
Platform.runLater(() -> {
if (pet != null && pet.getPetPrice() != null) {
txtAdoptionFee.setText(String.format("%.2f", pet.getPetPrice()));
} else {
txtAdoptionFee.setText("0.00");
}
});
} catch (Exception e) {
Platform.runLater(() -> {
txtAdoptionFee.setText("0.00");
ActivityLogger.getInstance().logException(
"AdoptionDialogController.loadPetPrice", e, "Loading pet price");
});
}
}).start();
}
private void ensureSelectedEmployeeOption(ObservableList<DropdownOption> options) {
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0 || options == null) {
return;
}
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0 || options == null) return;
DropdownOption existing = findOptionById(options, (long) selectedAdoption.getEmployeeId());
if (existing == null) {
DropdownOption option = new DropdownOption();
@@ -399,9 +558,7 @@ public class AdoptionDialogController {
}
private void ensureSelectedPetOption(ObservableList<DropdownOption> options) {
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0 || options == null) {
return;
}
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0 || options == null) return;
DropdownOption existing = findOptionById(options, (long) selectedAdoption.getPetId());
if (existing == null) {
DropdownOption option = new DropdownOption();

View File

@@ -7,6 +7,7 @@ import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import javafx.scene.control.ListCell;
@@ -18,7 +19,6 @@ import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.ActivityLogger;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.time.LocalDate;
import java.util.List;
@@ -33,6 +33,8 @@ public class AppointmentDialogController {
@FXML private ComboBox<DropdownOption> cbCustomer;
@FXML private ComboBox<DropdownOption> cbPet;
@FXML private ComboBox<DropdownOption> cbEmployee;
@FXML private ComboBox<DropdownOption> cbStore;
@FXML private VBox vbStore;
@FXML private ComboBox<Integer> cbHour;
@FXML private ComboBox<Integer> cbMinute;
@@ -46,8 +48,10 @@ public class AppointmentDialogController {
private String mode = null;
private AppointmentDTO selectedAppointment = null;
private Long pendingPetSelectionId = null;
private Long pendingStoreId = null;
private boolean isOriginallyCancel = false;
private boolean isOriginallyCompletedOrMissed = false;
private boolean isEditing = false;
public void setMode(String mode) {
this.mode = mode;
@@ -63,18 +67,39 @@ public class AppointmentDialogController {
@FXML
public void initialize() {
cbAppointmentStatus.setItems(FXCollections.observableArrayList("Booked", "Completed", "Missed", "Cancelled"));
cbAppointmentStatus.valueProperty().addListener((obs, oldVal, newVal) -> {
if (newVal == null) return;
boolean lockAll = "Cancelled".equalsIgnoreCase(newVal)
|| "Completed".equalsIgnoreCase(newVal)
|| "Missed".equalsIgnoreCase(newVal);
if (lockAll) {
cbEmployee.setDisable(true);
cbHour.setDisable(true);
cbMinute.setDisable(true);
dpAppointmentDate.setDisable(true);
cbStore.setDisable(true);
} else {
if (!isOriginallyCancel && !isOriginallyCompletedOrMissed) {
cbEmployee.setDisable(false);
cbHour.setDisable(false);
cbMinute.setDisable(false);
dpAppointmentDate.setDisable(false);
if (UserSession.getInstance().isAdmin() && !isEditing) cbStore.setDisable(false);
}
}
});
cbPet.setDisable(true);
cbEmployee.setPromptText("Select an employee");
cbPet.setPromptText("Select a customer first");
cbCustomer.setPromptText("Select a customer");
cbService.setPromptText("Select a service");
LocalDate minDate = minAppointmentDate();
dpAppointmentDate.setValue(minDate);
LocalDate today = LocalDate.now();
dpAppointmentDate.setValue(today);
dpAppointmentDate.setDayCellFactory(picker -> new javafx.scene.control.DateCell() {
@Override
public void updateItem(LocalDate item, boolean empty) {
super.updateItem(item, empty);
setDisable(empty || item.isBefore(minDate));
setDisable(empty || item.isBefore(today));
}
});
cbAppointmentStatus.setValue("Booked");
@@ -179,7 +204,7 @@ public class AppointmentDialogController {
Long customerId = newValue != null ? newValue.getId() : null;
cbPet.setValue(null);
cbPet.setItems(FXCollections.observableArrayList());
cbPet.setDisable(customerId == null);
cbPet.setDisable(customerId == null || isOriginallyCancel || isOriginallyCompletedOrMissed || isEditing);
if (customerId != null) {
cbPet.setPromptText("Loading customer pets...");
loadCustomerPets(customerId);
@@ -189,19 +214,47 @@ public class AppointmentDialogController {
}
});
cbStore.setCellFactory(param -> new ListCell<>() {
@Override
protected void updateItem(DropdownOption option, boolean empty) {
super.updateItem(option, empty);
setText(empty || option == null ? null : option.getLabel());
}
});
cbStore.setButtonCell(new ListCell<>() {
@Override
protected void updateItem(DropdownOption option, boolean empty) {
super.updateItem(option, empty);
setText(empty || option == null ? null : option.getLabel());
}
});
if (UserSession.getInstance().isAdmin()) {
vbStore.setVisible(true);
vbStore.setManaged(true);
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Select a store first");
}
btnSave.setOnMouseClicked(this::buttonSaveClicked);
btnCancel.setOnMouseClicked(this::closeStage);
loadServices();
loadAppointmentCustomers();
loadEmployees();
if (UserSession.getInstance().isAdmin()) {
loadStores();
} else {
loadEmployees();
}
}
public void displayAppointmentDetails(AppointmentDTO appt) {
selectedAppointment = appt;
isEditing = true;
lblAppointmentId.setText("ID: " + appt.getAppointmentId());
pendingPetSelectionId = appt.getPetId() > 0 ? (long) appt.getPetId() : null;
pendingStoreId = appt.getStoreId();
try {
dpAppointmentDate.setValue(
@@ -248,6 +301,7 @@ public class AppointmentDialogController {
dpAppointmentDate.setDisable(true);
cbAppointmentStatus.setDisable(true);
cbAppointmentStatus.setItems(FXCollections.observableArrayList("Cancelled"));
cbStore.setDisable(true);
btnSave.setDisable(true);
} else if (isOriginallyCompletedOrMissed) {
cbService.setDisable(true);
@@ -257,12 +311,14 @@ public class AppointmentDialogController {
cbHour.setDisable(true);
cbMinute.setDisable(true);
dpAppointmentDate.setDisable(true);
cbStore.setDisable(true);
cbAppointmentStatus.setDisable(false);
cbAppointmentStatus.setItems(FXCollections.observableArrayList("Completed", "Missed"));
} else {
cbService.setDisable(true);
cbCustomer.setDisable(true);
cbPet.setDisable(true);
cbStore.setDisable(true);
cbEmployee.setDisable(false);
cbHour.setDisable(false);
cbMinute.setDisable(false);
@@ -288,7 +344,16 @@ public class AppointmentDialogController {
}
LocalTime appointmentTime = LocalTime.of(cbHour.getValue(), cbMinute.getValue());
Long storeId = UserSession.getInstance().getStoreId();
Long storeId;
if (UserSession.getInstance().isAdmin()) {
if (cbStore.getSelectionModel().getSelectedItem() == null) {
showError("Store is required.");
return;
}
storeId = cbStore.getSelectionModel().getSelectedItem().getId();
} else {
storeId = UserSession.getInstance().getStoreId();
}
if (storeId == null || storeId <= 0) {
showError("Store is not set for this account");
return;
@@ -408,7 +473,7 @@ public class AppointmentDialogController {
}
}
cbPet.setItems(petOptions);
cbPet.setDisable(petOptions.isEmpty());
cbPet.setDisable(petOptions.isEmpty() || isOriginallyCancel || isOriginallyCompletedOrMissed || isEditing);
cbPet.setPromptText(petOptions.isEmpty() ? "No pets for selected customer" : "Select a pet");
if (pendingPetSelectionId != null) {
for (DropdownOption pet : cbPet.getItems()) {
@@ -462,7 +527,7 @@ public class AppointmentDialogController {
Platform.runLater(() -> {
cbCustomer.setItems(FXCollections.observableArrayList(customers));
boolean hasCustomers = customers != null && !customers.isEmpty();
cbCustomer.setDisable(!hasCustomers);
cbCustomer.setDisable(!hasCustomers || isOriginallyCancel || isOriginallyCompletedOrMissed || isEditing);
cbPet.setDisable(true);
cbPet.setItems(FXCollections.observableArrayList());
cbCustomer.setPromptText(hasCustomers ? "Select a customer" : "No customers with pets yet");
@@ -484,17 +549,70 @@ public class AppointmentDialogController {
}).start();
}
private LocalDate minAppointmentDate() {
LocalDate date = LocalDate.now();
int businessDaysAdded = 0;
while (businessDaysAdded < 2) {
date = date.plusDays(1);
DayOfWeek dow = date.getDayOfWeek();
if (dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY) {
businessDaysAdded++;
private void loadStores() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
Platform.runLater(() -> {
if (stores != null) {
cbStore.setItems(FXCollections.observableArrayList(stores));
cbStore.valueProperty().addListener((obs, oldVal, newVal) -> {
Long sid = newVal != null ? newVal.getId() : null;
cbEmployee.setValue(null);
cbEmployee.setItems(FXCollections.observableArrayList());
if (sid != null) {
if (!isOriginallyCancel && !isOriginallyCompletedOrMissed) {
cbEmployee.setDisable(false);
cbEmployee.setPromptText("Select an employee");
}
loadEmployeesForStore(sid);
} else {
if (!isOriginallyCancel && !isOriginallyCompletedOrMissed) {
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Select a store first");
}
}
});
if (pendingStoreId != null) {
for (DropdownOption store : cbStore.getItems()) {
if (pendingStoreId.equals(store.getId())) {
cbStore.setValue(store);
break;
}
}
pendingStoreId = null;
}
}
});
} catch (Exception e) {
Platform.runLater(() -> ActivityLogger.getInstance().logException(
"AppointmentDialogController.loadStores",
e,
"Loading stores for appointment dialog"));
}
}
return date;
}).start();
}
private void loadEmployeesForStore(Long storeId) {
new Thread(() -> {
try {
List<DropdownOption> employees = DropdownApi.getInstance().getStoreEmployees(storeId);
Platform.runLater(() -> {
cbEmployee.setItems(FXCollections.observableArrayList(employees));
applySelectedEmployee();
});
} catch (Exception e) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AppointmentDialogController.loadEmployeesForStore",
e,
"Loading employees for store");
cbEmployee.setDisable(true);
cbEmployee.setPromptText("Unable to load employees");
});
}
}).start();
}
private void loadEmployees() {

View File

@@ -0,0 +1,177 @@
package org.example.petshopdesktop.controllers.dialogcontrollers;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.stage.Stage;
import org.example.petshopdesktop.Validator;
import org.example.petshopdesktop.api.dto.user.UserRequest;
import org.example.petshopdesktop.api.dto.user.UserResponse;
import org.example.petshopdesktop.api.endpoints.CustomerApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.TextFieldFormatSupport;
public class CustomerEditDialogController {
@FXML private TextField txtFirstName;
@FXML private TextField txtLastName;
@FXML private TextField txtEmail;
@FXML private TextField txtPhone;
@FXML private TextField txtUsername;
@FXML private PasswordField txtPassword;
@FXML private PasswordField txtPasswordConfirm;
@FXML private ComboBox<String> cbActive;
@FXML private TextField txtLoyaltyPoints;
@FXML private Label lblError;
@FXML private Button btnSave;
private UserResponse customer;
@FXML
void initialize() {
TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone);
cbActive.setItems(FXCollections.observableArrayList("Active", "Inactive"));
boolean isAdmin = UserSession.getInstance().isAdmin();
txtLoyaltyPoints.setDisable(!isAdmin);
}
public void setCustomer(UserResponse user) {
this.customer = user;
String fullName = user.getFullName() == null ? "" : user.getFullName();
String[] names = splitFullName(fullName);
txtFirstName.setText(names[0]);
txtLastName.setText(names[1]);
txtEmail.setText(user.getEmail());
txtPhone.setText(user.getPhone());
txtUsername.setText(user.getUsername());
cbActive.setValue(Boolean.TRUE.equals(user.getActive()) ? "Active" : "Inactive");
int pts = user.getLoyaltyPoints() != null ? user.getLoyaltyPoints() : 0;
txtLoyaltyPoints.setText(String.valueOf(pts));
}
private String[] splitFullName(String fullName) {
if (fullName == null || fullName.trim().isEmpty()) return new String[]{"", ""};
String[] parts = fullName.trim().split("\\s+", 2);
return new String[]{parts.length > 0 ? parts[0] : "", parts.length > 1 ? parts[1] : ""};
}
@FXML
void btnSaveClicked(ActionEvent event) {
lblError.setText("");
if (customer == null) {
lblError.setText("No customer selected.");
return;
}
String firstName = value(txtFirstName);
String lastName = value(txtLastName);
String email = value(txtEmail);
String phone = value(txtPhone);
String username = value(txtUsername);
String password = txtPassword.getText() == null ? "" : txtPassword.getText().trim();
String confirm = txtPasswordConfirm.getText() == null ? "" : txtPasswordConfirm.getText().trim();
String loyaltyPointsText = value(txtLoyaltyPoints);
if (firstName.isBlank() || lastName.isBlank()) {
lblError.setText("First name and last name are required.");
return;
}
if (email.isBlank()) {
lblError.setText("Email is required.");
return;
}
if (phone.isBlank()) {
lblError.setText("Phone is required.");
return;
}
String phoneError = Validator.isValidPhoneNumber(phone, "Phone");
if (!phoneError.isEmpty()) {
lblError.setText(phoneError.trim());
return;
}
if (username.isBlank()) {
lblError.setText("Username is required.");
return;
}
if (!password.isEmpty() && password.length() < 6) {
lblError.setText("Password must be at least 6 characters.");
return;
}
if (!password.equals(confirm)) {
lblError.setText("Passwords do not match.");
return;
}
Integer loyaltyPoints = null;
if (UserSession.getInstance().isAdmin()) {
if (loyaltyPointsText.isBlank()) {
lblError.setText("Loyalty points is required.");
return;
}
try {
loyaltyPoints = Integer.parseInt(loyaltyPointsText);
if (loyaltyPoints < 0) {
lblError.setText("Loyalty points cannot be negative.");
return;
}
} catch (NumberFormatException e) {
lblError.setText("Loyalty points must be a valid integer.");
return;
}
}
btnSave.setDisable(true);
boolean active = "Active".equals(cbActive.getValue());
Integer finalLoyaltyPoints = loyaltyPoints;
new Thread(() -> {
try {
UserRequest request = new UserRequest();
request.setUsername(username);
request.setPassword(password.isEmpty() ? null : password);
request.setFirstName(firstName);
request.setLastName(lastName);
request.setFullName(firstName + " " + lastName);
request.setEmail(email);
request.setPhone(phone);
request.setRole(customer.getRole());
request.setActive(active);
if (finalLoyaltyPoints != null) request.setLoyaltyPoints(finalLoyaltyPoints);
CustomerApi.getInstance().updateCustomer(customer.getId(), request);
Platform.runLater(this::close);
} catch (Exception e) {
ActivityLogger.getInstance().logException("CustomerEditDialogController.btnSaveClicked", e, "Updating customer");
String msg = e.getMessage() == null ? "Could not update customer." : e.getMessage();
Platform.runLater(() -> {
lblError.setText(msg);
btnSave.setDisable(false);
});
}
}).start();
}
@FXML
void btnCancelClicked(ActionEvent event) {
close();
}
private void close() {
Stage stage = (Stage) btnSave.getScene().getWindow();
stage.close();
}
private static String value(TextField tf) {
return tf.getText() == null ? "" : tf.getText().trim();
}
}

View File

@@ -1,5 +1,6 @@
package org.example.petshopdesktop.controllers.dialogcontrollers;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
@@ -9,66 +10,65 @@ import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.TextField;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import javafx.util.StringConverter;
import org.example.petshopdesktop.models.Inventory;
import org.example.petshopdesktop.Validator;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.dto.inventory.InventoryRequest;
import org.example.petshopdesktop.api.dto.inventory.InventoryResponse;
import org.example.petshopdesktop.api.dto.product.ProductResponse;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.api.endpoints.InventoryApi;
import org.example.petshopdesktop.api.endpoints.ProductApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.models.Product;
import org.example.petshopdesktop.util.ActivityLogger;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
public class InventoryDialogController {
//FXML elements
@FXML
private Button btnCancel;
@FXML private Button btnCancel;
@FXML private Button btnSave;
@FXML private ComboBox<Product> cbProduct;
@FXML private ComboBox<DropdownOption> cbStore;
@FXML private VBox vbStoreField;
@FXML private Label lblInventoryId;
@FXML private Label lblMode;
@FXML private TextField txtQuantity;
@FXML
private Button btnSave;
@FXML
private ComboBox<Product> cbProduct;
@FXML
private Label lblInventoryId;
@FXML
private Label lblMode;
@FXML
private TextField txtQuantity;
//Determines if the mode is add or edit
private String mode = null;
private Long pendingStoreId = null;
//Loads upon .FXML boot
@FXML
void initialize() {
cbProduct.setConverter(new StringConverter<Product>() {
//Displays product in combobox (prodID + name)
@Override
public String toString(Product product) {
return product == null ? "" : product.getProdId() + ": " + product.getProdName();
}
//Not needed
@Override
public Product fromString(String string) { return null; }
});
//Load product list from API into combobox
cbStore.setCellFactory(param -> new ListCell<>() {
@Override protected void updateItem(DropdownOption o, boolean empty) {
super.updateItem(o, empty);
setText(empty || o == null ? null : o.getLabel());
}
});
cbStore.setButtonCell(new ListCell<>() {
@Override protected void updateItem(DropdownOption o, boolean empty) {
super.updateItem(o, empty);
setText(empty || o == null ? null : o.getLabel());
}
});
try {
List<ProductResponse> productResponses = ProductApi.getInstance().listProducts(null);
if (productResponses != null) {
@@ -89,10 +89,16 @@ public class InventoryDialogController {
"InventoryDialogController.initialize",
e,
"Loading products for combo box");
System.out.println("Error loading products: " + e.getMessage());
}
//Save button handler
boolean isAdmin = UserSession.getInstance().isAdmin();
if (isAdmin) {
loadStores();
} else {
vbStoreField.setManaged(false);
vbStoreField.setVisible(false);
}
btnSave.setOnMouseClicked(new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent mouseEvent) {
@@ -100,7 +106,6 @@ public class InventoryDialogController {
}
});
//Cancel button handler
btnCancel.setOnMouseClicked(new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent mouseEvent) {
@@ -109,29 +114,66 @@ public class InventoryDialogController {
});
}
//Handles save button click event
private void loadStores() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
Platform.runLater(() -> {
cbStore.setItems(FXCollections.observableArrayList(stores));
applyPendingStore();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("InventoryDialogController.loadStores", e, "Loading stores");
Platform.runLater(() -> {
cbStore.setDisable(true);
cbStore.setPromptText("Unable to load stores");
});
}
}).start();
}
private void applyPendingStore() {
if (pendingStoreId == null) return;
for (DropdownOption opt : cbStore.getItems()) {
if (opt.getId() != null && opt.getId().equals(pendingStoreId)) {
cbStore.setValue(opt);
pendingStoreId = null;
return;
}
}
}
private void buttonSaveClicked(MouseEvent mouseEvent) {
int numRow = 0;
String errorMsg = "";
if (cbProduct.getSelectionModel().getSelectedItem() == null) {
errorMsg += "Product is required.\n";
}
//Validate inputs
errorMsg += Validator.isPresent(txtQuantity.getText(), "Quantity");
errorMsg += Validator.isLessThanVarChars(txtQuantity.getText(), "Quantity", 11);
errorMsg += Validator.isPositiveInteger(txtQuantity.getText(), "Quantity");
//Operation only occurs if there are no errors
boolean isAdmin = UserSession.getInstance().isAdmin();
Long storeId;
if (isAdmin) {
if (cbStore.getValue() == null) {
errorMsg += "Store is required.\n";
storeId = null;
} else {
storeId = cbStore.getValue().getId();
}
} else {
storeId = UserSession.getInstance().getStoreId();
if (storeId == null || storeId <= 0) {
errorMsg += "Store is not set for this account.\n";
}
}
if (errorMsg.isEmpty()) {
try {
InventoryRequest request = new InventoryRequest();
Product selectedProduct = cbProduct.getSelectionModel().getSelectedItem();
Long storeId = UserSession.getInstance().getStoreId();
if (storeId == null || storeId <= 0) {
throw new IllegalStateException("Store is not set for this account");
}
request.setProdId((long) selectedProduct.getProdId());
int quantity;
try {
@@ -168,10 +210,7 @@ public class InventoryDialogController {
alert.setContentText(e.getMessage());
alert.showAndWait();
}
}
//Displays validation errors
else {
} else {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setHeaderText("Input Error");
alert.setContentText(errorMsg);
@@ -179,22 +218,16 @@ public class InventoryDialogController {
}
}
//Close dialog view
private void closeStage(MouseEvent mouseEvent) {
Node node = (Node) mouseEvent.getSource();
Stage stage = (Stage) node.getScene().getWindow();
stage.close();
}
//Editing
//Displays fields with existing inventory data
public void displayInventoryDetails(Inventory inventory) {
if (inventory != null) {
//Displays inventory ID
lblInventoryId.setText("ID: " + inventory.getInventoryId());
//Selecting matching product in combobox
for (Product product : cbProduct.getItems()) {
if (product.getProdId() == inventory.getProdId()) {
cbProduct.getSelectionModel().select(product);
@@ -203,10 +236,15 @@ public class InventoryDialogController {
}
txtQuantity.setText(String.valueOf(inventory.getQuantity()));
boolean isAdmin = UserSession.getInstance().isAdmin();
if (isAdmin && inventory.getStoreId() > 0) {
pendingStoreId = (long) inventory.getStoreId();
applyPendingStore();
}
}
}
//Sets dialog view to add/edit. Updates UI labels
public void setMode(String mode) {
this.mode = mode;
lblMode.setText(mode + " Inventory");

View File

@@ -9,6 +9,7 @@ import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.scene.control.*;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.HBox;
import javafx.application.Platform;
import javafx.stage.Stage;
import org.example.petshopdesktop.api.dto.sale.SaleItemRequest;
@@ -80,6 +81,21 @@ public class RefundDialogController {
@FXML
private ComboBox<String> cbPaymentMethod;
@FXML
private Label lblRefundSubtotal;
@FXML
private HBox hbRefundCouponDiscount;
@FXML
private Label lblRefundCouponDiscount;
@FXML
private HBox hbRefundLoyaltyDiscount;
@FXML
private Label lblRefundLoyaltyDiscount;
@FXML
private Label lblRefundTotal;
@@ -448,8 +464,68 @@ public class RefundDialogController {
}
private void updateRefundTotal() {
double total = refundItems.stream().mapToDouble(RefundItem::getTotal).sum();
lblRefundTotal.setText(currency.format(total));
if (currentSale == null || refundItems.isEmpty()) {
lblRefundSubtotal.setText(currency.format(0.0));
lblRefundTotal.setText(currency.format(0.0));
hbRefundCouponDiscount.setVisible(false);
hbRefundCouponDiscount.setManaged(false);
hbRefundLoyaltyDiscount.setVisible(false);
hbRefundLoyaltyDiscount.setManaged(false);
return;
}
double originalSubtotal = currentSale.getSubtotalAmount() != null
? currentSale.getSubtotalAmount().doubleValue() : 0;
if (originalSubtotal == 0 && currentSale.getItems() != null) {
for (var item : currentSale.getItems()) {
if (item.getUnitPrice() != null && item.getQuantity() != null) {
originalSubtotal += item.getUnitPrice().doubleValue() * Math.abs(item.getQuantity());
}
}
}
double cartSubtotal = refundItems.stream().mapToDouble(RefundItem::getTotal).sum();
double originalTotal = currentSale.getTotalAmount() != null
? Math.abs(currentSale.getTotalAmount().doubleValue()) : 0;
double refundTotal;
double couponDiscountRefunded = 0;
double loyaltyDiscountRefunded = 0;
if (originalSubtotal > 0) {
double ratio = cartSubtotal / originalSubtotal;
refundTotal = originalTotal * ratio;
if (currentSale.getCouponDiscountAmount() != null) {
couponDiscountRefunded = currentSale.getCouponDiscountAmount().doubleValue() * ratio;
}
if (currentSale.getLoyaltyDiscountAmount() != null) {
loyaltyDiscountRefunded = currentSale.getLoyaltyDiscountAmount().doubleValue() * ratio;
}
} else {
refundTotal = cartSubtotal;
}
lblRefundSubtotal.setText(currency.format(cartSubtotal));
if (couponDiscountRefunded > 0.001) {
lblRefundCouponDiscount.setText("-" + currency.format(couponDiscountRefunded));
hbRefundCouponDiscount.setVisible(true);
hbRefundCouponDiscount.setManaged(true);
} else {
hbRefundCouponDiscount.setVisible(false);
hbRefundCouponDiscount.setManaged(false);
}
if (loyaltyDiscountRefunded > 0.001) {
lblRefundLoyaltyDiscount.setText("-" + currency.format(loyaltyDiscountRefunded));
hbRefundLoyaltyDiscount.setVisible(true);
hbRefundLoyaltyDiscount.setManaged(true);
} else {
hbRefundLoyaltyDiscount.setVisible(false);
hbRefundLoyaltyDiscount.setManaged(false);
}
lblRefundTotal.setText(currency.format(refundTotal));
}
private void closeDialog() {

View File

@@ -27,6 +27,13 @@ public class SaleDetailDialogController {
@FXML private Label lblSaleDate;
@FXML private Label lblEmployee;
@FXML private Label lblPayment;
@FXML private Label lblCustomer;
@FXML private javafx.scene.layout.HBox hbDetailSubtotal;
@FXML private Label lblDetailSubtotal;
@FXML private javafx.scene.layout.HBox hbDetailCouponDiscount;
@FXML private Label lblDetailCouponDiscount;
@FXML private javafx.scene.layout.HBox hbDetailLoyaltyDiscount;
@FXML private Label lblDetailLoyaltyDiscount;
@FXML private Label lblTotal;
@FXML private Button btnRefund;
@FXML private TableView<SaleDetail.SaleDetailItem> tvItems;
@@ -61,6 +68,39 @@ public class SaleDetailDialogController {
lblSaleDate.setText(sale.getSaleDate() != null ? sale.getSaleDate().format(DATE_FORMATTER) : "");
lblEmployee.setText(sale.getEmployeeName() != null ? sale.getEmployeeName() : "");
lblPayment.setText(sale.getPaymentMethod() != null ? sale.getPaymentMethod() : "");
lblCustomer.setText(sale.getCustomerName() != null && !sale.getCustomerName().isEmpty() ? sale.getCustomerName() : "-");
if (!sale.isRefund() && sale.getSubtotalAmount() > 0) {
hbDetailSubtotal.setVisible(true);
hbDetailSubtotal.setManaged(true);
lblDetailSubtotal.setText(currency.format(sale.getSubtotalAmount()));
if (sale.getCouponDiscountAmount() > 0.001) {
lblDetailCouponDiscount.setText("-" + currency.format(sale.getCouponDiscountAmount()));
hbDetailCouponDiscount.setVisible(true);
hbDetailCouponDiscount.setManaged(true);
} else {
hbDetailCouponDiscount.setVisible(false);
hbDetailCouponDiscount.setManaged(false);
}
if (sale.getLoyaltyDiscountAmount() > 0.001) {
lblDetailLoyaltyDiscount.setText("-" + currency.format(sale.getLoyaltyDiscountAmount()));
hbDetailLoyaltyDiscount.setVisible(true);
hbDetailLoyaltyDiscount.setManaged(true);
} else {
hbDetailLoyaltyDiscount.setVisible(false);
hbDetailLoyaltyDiscount.setManaged(false);
}
} else {
hbDetailSubtotal.setVisible(false);
hbDetailSubtotal.setManaged(false);
hbDetailCouponDiscount.setVisible(false);
hbDetailCouponDiscount.setManaged(false);
hbDetailLoyaltyDiscount.setVisible(false);
hbDetailLoyaltyDiscount.setManaged(false);
}
lblTotal.setText(currency.format(sale.getTotalAmount()));
tvItems.setItems(sale.getItems());
if (btnRefund != null) {

View File

@@ -1,84 +1,128 @@
package org.example.petshopdesktop.controllers.dialogcontrollers;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.stage.Stage;
import org.example.petshopdesktop.Validator;
import org.example.petshopdesktop.api.dto.user.UserRequest;
import org.example.petshopdesktop.api.dto.user.UserResponse;
import org.example.petshopdesktop.api.endpoints.UserApi;
import org.example.petshopdesktop.api.endpoints.CustomerApi;
import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
import org.example.petshopdesktop.api.dto.employee.EmployeeRequest;
import org.example.petshopdesktop.api.dto.employee.EmployeeResponse;
import org.example.petshopdesktop.api.endpoints.DropdownApi;
import org.example.petshopdesktop.api.endpoints.EmployeeApi;
import org.example.petshopdesktop.util.ActivityLogger;
import org.example.petshopdesktop.util.TextFieldFormatSupport;
import java.util.List;
public class StaffEditDialogController {
@FXML
private TextField txtFirstName;
@FXML private TextField txtFirstName;
@FXML private TextField txtLastName;
@FXML private TextField txtEmail;
@FXML private TextField txtPhone;
@FXML private TextField txtUsername;
@FXML private PasswordField txtPassword;
@FXML private PasswordField txtPasswordConfirm;
@FXML private ComboBox<String> cbRole;
@FXML private ComboBox<String> cbActive;
@FXML private ComboBox<DropdownOption> cbStore;
@FXML private Label lblError;
@FXML private Button btnSave;
@FXML
private TextField txtLastName;
@FXML
private TextField txtEmail;
@FXML
private TextField txtPhone;
@FXML
private TextField txtUsername;
@FXML
private PasswordField txtPassword;
@FXML
private PasswordField txtPasswordConfirm;
@FXML
private Label lblError;
@FXML
private Button btnSave;
private UserResponse user;
private EmployeeResponse employee;
private Long pendingStoreId = null;
@FXML
void initialize() {
TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone);
cbRole.setItems(FXCollections.observableArrayList("STAFF", "ADMIN"));
cbActive.setItems(FXCollections.observableArrayList("Active", "Inactive"));
cbStore.setCellFactory(param -> new ListCell<>() {
@Override protected void updateItem(DropdownOption o, boolean empty) {
super.updateItem(o, empty);
setText(empty || o == null ? null : o.getLabel());
}
});
cbStore.setButtonCell(new ListCell<>() {
@Override protected void updateItem(DropdownOption o, boolean empty) {
super.updateItem(o, empty);
setText(empty || o == null ? null : o.getLabel());
}
});
loadStores();
}
public void setUser(UserResponse user) {
this.user = user;
String fullName = user.getFullName() == null ? "" : user.getFullName();
private void loadStores() {
new Thread(() -> {
try {
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
Platform.runLater(() -> {
cbStore.setItems(FXCollections.observableArrayList(stores));
applyPendingStore();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("StaffEditDialogController.loadStores", e, "Loading stores");
Platform.runLater(() -> {
cbStore.setDisable(true);
cbStore.setPromptText("Unable to load stores");
});
}
}).start();
}
private void applyPendingStore() {
if (pendingStoreId == null) return;
for (DropdownOption opt : cbStore.getItems()) {
if (opt.getId() != null && opt.getId().equals(pendingStoreId)) {
cbStore.setValue(opt);
pendingStoreId = null;
return;
}
}
}
public void setEmployee(EmployeeResponse emp) {
this.employee = emp;
String fullName = emp.getFullName() != null ? emp.getFullName() : "";
if (fullName.isEmpty() && emp.getFirstName() != null) {
fullName = emp.getFirstName() + (emp.getLastName() != null ? " " + emp.getLastName() : "");
}
String[] names = splitFullName(fullName);
txtFirstName.setText(names[0]);
txtLastName.setText(names[1]);
txtEmail.setText(user.getEmail());
txtPhone.setText(user.getPhone());
txtUsername.setText(user.getUsername());
txtEmail.setText(emp.getEmail());
txtPhone.setText(emp.getPhone());
txtUsername.setText(emp.getUsername());
if (emp.getRole() != null) cbRole.setValue(emp.getRole());
cbActive.setValue(Boolean.TRUE.equals(emp.getActive()) ? "Active" : "Inactive");
pendingStoreId = emp.getPrimaryStoreId();
applyPendingStore();
}
private String[] splitFullName(String fullName) {
if (fullName == null || fullName.trim().isEmpty()) {
return new String[]{"", ""};
}
if (fullName == null || fullName.trim().isEmpty()) return new String[]{"", ""};
String[] parts = fullName.trim().split("\\s+", 2);
String firstName = parts.length > 0 ? parts[0] : "";
String lastName = parts.length > 1 ? parts[1] : "";
return new String[]{firstName, lastName};
return new String[]{parts.length > 0 ? parts[0] : "", parts.length > 1 ? parts[1] : ""};
}
@FXML
void btnSaveClicked(ActionEvent event) {
lblError.setText("");
if (user == null) {
lblError.setText("No user selected.");
if (employee == null) {
lblError.setText("No employee selected.");
return;
}
@@ -119,31 +163,41 @@ public class StaffEditDialogController {
lblError.setText("Passwords do not match.");
return;
}
if (cbRole.getValue() == null) {
lblError.setText("Role is required.");
return;
}
if (cbStore.getValue() == null) {
lblError.setText("Primary store is required.");
return;
}
btnSave.setDisable(true);
String role = cbRole.getValue();
boolean active = "Active".equals(cbActive.getValue());
Long storeId = cbStore.getValue().getId();
new Thread(() -> {
try {
UserRequest request = new UserRequest();
EmployeeRequest request = new EmployeeRequest();
request.setUsername(username);
request.setPassword(password.isEmpty() ? null : password);
request.setFirstName(firstName);
request.setLastName(lastName);
request.setFullName(firstName + " " + lastName);
request.setEmail(email);
request.setPhone(phone);
request.setRole(user.getRole());
request.setActive(user.getActive());
request.setRole(role);
request.setActive(active);
request.setPrimaryStoreId(storeId);
UserSession session = UserSession.getInstance();
if (session.isAdmin()) {
UserApi.getInstance().updateUser(user.getId(), request);
} else {
CustomerApi.getInstance().updateCustomer(user.getId(), request);
}
EmployeeApi.getInstance().updateEmployee(employee.getId(), request);
Platform.runLater(this::close);
} catch (Exception e) {
ActivityLogger.getInstance().logException("StaffEditDialogController.btnSaveClicked", e, "Updating user");
String msg = e.getMessage() == null ? "Could not update user." : e.getMessage();
ActivityLogger.getInstance().logException("StaffEditDialogController.btnSaveClicked", e, "Updating employee");
String msg = e.getMessage() == null ? "Could not update employee." : e.getMessage();
Platform.runLater(() -> {
lblError.setText(msg);
btnSave.setDisable(false);

Some files were not shown because too many files have changed in this diff Show More