diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/PetAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/PetAdapter.java index 7ec19b8a..463295bc 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/PetAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/PetAdapter.java @@ -103,11 +103,21 @@ public class PetAdapter extends RecyclerView.Adapter i binding.tvPetStatus.setText(pet.getPetStatus()); - //Set the status color depending on availability. If available, green, otherwise red - if (pet.getPetStatus() != null && pet.getPetStatus().equals("Available")) { - binding.tvPetStatus.setBackgroundColor(Color.parseColor("#4CAF50")); + //Set the status color depending on availability. If available, green, If Pending, yellow, otherwise red + if (pet.getPetStatus() != null) { + switch (pet.getPetStatus()) { + case "Available": + binding.tvPetStatus.setBackgroundColor(Color.parseColor("#4CAF50")); + break; + case "Pending": + binding.tvPetStatus.setBackgroundColor(Color.parseColor("#FF9800")); + break; + default: + binding.tvPetStatus.setBackgroundColor(Color.parseColor("#F44336")); + break; + } } else { - binding.tvPetStatus.setBackgroundColor(Color.parseColor("#F44336")); + binding.tvPetStatus.setBackgroundColor(Color.parseColor("#9E9E9E")); } // Load pet image using Glide diff --git a/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java b/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java index b03ab44d..b554166b 100644 --- a/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java +++ b/android/app/src/main/java/com/example/petstoremobile/api/PetApi.java @@ -50,6 +50,9 @@ public interface PetApi { @GET("api/v1/dropdowns/pet-species") Call> getPetSpeciesDropdowns(); + @GET("api/v1/dropdowns/pet-breeds") + Call> getPetBreedsDropdowns(@Query("species") String species); + // Get pet by id @GET("api/v1/pets/{id}") Call getPetById(@Path("id") Long id); diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java index 52d96a1e..c190342e 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/CustomerDTO.java @@ -17,6 +17,7 @@ public class CustomerDTO { private String createdAt; private String updatedAt; private String password; + private String role; public CustomerDTO() {} @@ -73,4 +74,7 @@ public class CustomerDTO { public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } + + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } } diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/SaleDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/SaleDTO.java index f309978f..9dc76029 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/SaleDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/SaleDTO.java @@ -17,6 +17,9 @@ public class SaleDTO { private BigDecimal subtotalAmount; private BigDecimal couponDiscountAmount; private BigDecimal employeeDiscountAmount; + private BigDecimal loyaltyDiscountAmount; + private BigDecimal pointsDiscountAmount; + private Integer pointsUsed; private String paymentMethod; private String channel; private Boolean isRefund; @@ -78,6 +81,22 @@ public class SaleDTO { return employeeDiscountAmount; } + 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 String getPaymentMethod() { return paymentMethod; } @@ -126,6 +145,14 @@ public class SaleDTO { return customerName; } + public BigDecimal getPointsDiscountAmount() { + return pointsDiscountAmount; + } + + public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) { + this.pointsDiscountAmount = pointsDiscountAmount; + } + // Nested SaleItemDTO public static class SaleItemDTO { private Long saleItemId; diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java index a9d153e4..cd00726a 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/PetFragment.java @@ -136,7 +136,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen } private void setupStatusFilter() { - String[] statuses = {"All Statuses", "Available", "Adopted", "Owned"}; + String[] statuses = {"All Statuses", "Available", "Adopted", "Owned", "Pending"}; SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, this::loadPetData); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java index 787e99a7..5c6589f3 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/SaleFragment.java @@ -65,6 +65,11 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis UIUtils.setupHamburgerMenu(binding.btnHamburger, this); + if (isAdmin()) { + binding.fabAddSale.setVisibility(View.GONE); + binding.btnOpenRefund.setVisibility(View.GONE); + } + binding.fabAddSale.setOnClickListener(v -> NavHostFragment.findNavController(this).navigate(R.id.nav_sale_detail)); @@ -110,6 +115,10 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis return "STAFF".equalsIgnoreCase(tokenManager.getRole()); } + private boolean isAdmin() { + return "ADMIN".equalsIgnoreCase(tokenManager.getRole()); + } + private void setupStoreFilter() { SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadSales(true)); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/CustomerDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/CustomerDetailFragment.java index f7a8c5a3..455450cc 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/CustomerDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/CustomerDetailFragment.java @@ -14,8 +14,11 @@ import com.example.petstoremobile.utils.InputValidator; import com.example.petstoremobile.utils.SpinnerUtils; import com.example.petstoremobile.utils.UIUtils; import com.example.petstoremobile.viewmodels.CustomerDetailViewModel; +import com.example.petstoremobile.api.auth.TokenManager; import com.example.petstoremobile.utils.Resource; +import javax.inject.Inject; + import dagger.hilt.android.AndroidEntryPoint; @AndroidEntryPoint @@ -24,6 +27,8 @@ public class CustomerDetailFragment extends Fragment { private FragmentCustomerDetailBinding binding; private CustomerDetailViewModel viewModel; + @Inject TokenManager tokenManager; + private final String[] STATUSES = {"Active", "Inactive"}; @Override @@ -65,7 +70,10 @@ public class CustomerDetailFragment extends Fragment { // Show loyalty points binding.tvLoyaltyPointsLabel.setVisibility(View.VISIBLE); - binding.tvCustomerLoyaltyPoints.setVisibility(View.VISIBLE); + binding.etCustomerLoyaltyPoints.setVisibility(View.VISIBLE); + + boolean isAdmin = "ADMIN".equalsIgnoreCase(tokenManager.getRole()); + binding.etCustomerLoyaltyPoints.setEnabled(isAdmin); loadCustomerData(customerId); } else { @@ -74,7 +82,7 @@ public class CustomerDetailFragment extends Fragment { binding.btnDeleteCustomer.setVisibility(View.GONE); binding.tvCustomerId.setVisibility(View.GONE); binding.tvLoyaltyPointsLabel.setVisibility(View.GONE); - binding.tvCustomerLoyaltyPoints.setVisibility(View.GONE); + binding.etCustomerLoyaltyPoints.setVisibility(View.GONE); } } @@ -91,7 +99,7 @@ public class CustomerDetailFragment extends Fragment { binding.etCustomerPhone.setText(c.getPhone() != null ? c.getPhone() : ""); binding.spinnerCustomerStatus.setSelection(Boolean.TRUE.equals(c.getActive()) ? 0 : 1); int pts = c.getLoyaltyPoints() != null ? c.getLoyaltyPoints() : 0; - binding.tvCustomerLoyaltyPoints.setText(String.valueOf(pts)); + binding.etCustomerLoyaltyPoints.setText(String.valueOf(pts)); } } }); @@ -121,6 +129,12 @@ public class CustomerDetailFragment extends Fragment { if (!InputValidator.isValidEmail(binding.etCustomerEmail)) return; if (!InputValidator.isValidPhone(binding.etCustomerPhone)) return; + Integer loyaltyPoints = null; + if (viewModel.isEditing()) { + if (!InputValidator.isPositiveInteger(binding.etCustomerLoyaltyPoints, "Loyalty Points")) return; + loyaltyPoints = Integer.parseInt(binding.etCustomerLoyaltyPoints.getText().toString().trim()); + } + String username = binding.etCustomerUsername.getText().toString().trim(); String password = viewModel.isEditing() ? null : binding.etCustomerPassword.getText().toString().trim(); String firstName = binding.etCustomerFirstName.getText().toString().trim(); @@ -129,9 +143,7 @@ public class CustomerDetailFragment extends Fragment { String phone = binding.etCustomerPhone.getText().toString().trim(); boolean active = binding.spinnerCustomerStatus.getSelectedItemPosition() == 0; - CustomerDTO dto = new CustomerDTO(username, password, firstName, lastName, email, phone); - dto.setFullName(firstName + " " + lastName); - dto.setActive(active); + CustomerDTO dto = viewModel.createCustomerDto(username, password, firstName, lastName, email, phone, active, loyaltyPoints); viewModel.saveCustomer(dto).observe(getViewLifecycleOwner(), resource -> { if (resource != null) { diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java index 2d5f1575..a22e2c0d 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/PetDetailFragment.java @@ -101,6 +101,14 @@ public class PetDetailFragment extends Fragment { DropdownDTO::getLabel, "-- Select Species --", null, DropdownDTO::getId); SpinnerUtils.setSelectionByValue(binding.spinnerPetSpecies, selectedSpecies); }); + + viewModel.getBreedList().observe(getViewLifecycleOwner(), list -> { + PetDetailViewModel.ViewState state = viewModel.getViewState().getValue(); + String selectedBreed = state != null ? state.selectedBreed : null; + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerPetBreed, list, + DropdownDTO::getLabel, "-- Select Breed --", null, DropdownDTO::getId); + SpinnerUtils.setSelectionByValue(binding.spinnerPetBreed, selectedBreed); + }); } private void setLoading(boolean loading) { @@ -118,7 +126,7 @@ public class PetDetailFragment extends Fragment { private void savePet() { if (!InputValidator.isNotEmpty(binding.etPetName, "Pet Name")) return; if (!InputValidator.isSpinnerSelected(binding.spinnerPetSpecies, "Species")) return; - if (!InputValidator.isNotEmpty(binding.etPetBreed, "Breed")) return; + if (!InputValidator.isSpinnerSelected(binding.spinnerPetBreed, "Breed")) return; if (!InputValidator.isPositiveInteger(binding.etPetAge, "Age")) return; if (!InputValidator.isPositiveDecimal(binding.etPetPrice, "Price")) return; @@ -127,7 +135,11 @@ public class PetDetailFragment extends Fragment { String species = (speciesOptions != null && binding.spinnerPetSpecies.getSelectedItemPosition() > 0) ? speciesOptions.get(binding.spinnerPetSpecies.getSelectedItemPosition() - 1).getLabel() : ""; - String breed = binding.etPetBreed.getText().toString().trim(); + + List breedOptions = viewModel.getBreedList().getValue(); + String breed = (breedOptions != null && binding.spinnerPetBreed.getSelectedItemPosition() > 0) + ? breedOptions.get(binding.spinnerPetBreed.getSelectedItemPosition() - 1).getLabel() + : ""; int age = Integer.parseInt(binding.etPetAge.getText().toString().trim()); double price = Double.parseDouble(binding.etPetPrice.getText().toString().trim()); String status = binding.spinnerPetStatus.getSelectedItem().toString(); @@ -152,6 +164,10 @@ public class PetDetailFragment extends Fragment { if (!InputValidator.isSpinnerSelected(binding.spinnerCustomer, "Owner")) return; if (!InputValidator.isSpinnerSelected(binding.spinnerStore, "Store")) return; } + if ("Pending".equalsIgnoreCase(status)) { + if (!InputValidator.isSpinnerSelected(binding.spinnerCustomer, "Owner")) return; + if (!InputValidator.isSpinnerSelected(binding.spinnerStore, "Store")) return; + } PetDTO petDTO = new PetDTO(); petDTO.setPetName(name); @@ -236,7 +252,6 @@ public class PetDetailFragment extends Fragment { if (resource.status == Resource.Status.SUCCESS && resource.data != null) { PetDTO p = resource.data; binding.etPetName.setText(p.getPetName()); - binding.etPetBreed.setText(p.getPetBreed()); binding.etPetAge.setText(String.valueOf(p.getPetAge())); if (p.getPetPrice() != null) { binding.etPetPrice.setText(String.format(Locale.getDefault(), "%.2f", p.getPetPrice())); @@ -279,6 +294,11 @@ public class PetDetailFragment extends Fragment { viewModel.onSpeciesSelected(p); }); + SpinnerUtils.setOnIndexSelectedListener(binding.spinnerPetBreed, p -> { + if (isUpdatingUI) return; + viewModel.onBreedSelected(p); + }); + SpinnerUtils.setOnIndexSelectedListener(binding.spinnerCustomer, p -> { if (isUpdatingUI) return; viewModel.onCustomerSelected(p); @@ -306,7 +326,7 @@ public class PetDetailFragment extends Fragment { binding.btnSavePet.setText(state.saveButtonText); UIUtils.setViewsEnabled(state.isSpeciesEnabled, binding.spinnerPetSpecies); - UIUtils.setViewsEnabled(state.isBreedEnabled, binding.etPetBreed); + UIUtils.setViewsEnabled(state.isBreedEnabled, binding.spinnerPetBreed); UIUtils.setViewsEnabled(state.isCustomerEnabled, binding.spinnerCustomer); UIUtils.setViewsEnabled(state.isStoreEnabled, binding.spinnerStore); @@ -323,6 +343,13 @@ public class PetDetailFragment extends Fragment { SpinnerUtils.setSelectionByValue(binding.spinnerPetSpecies, state.selectedSpecies); } + List breeds = viewModel.getBreedList().getValue(); + if (breeds != null) { + SpinnerUtils.populateSpinner(requireContext(), binding.spinnerPetBreed, breeds, + DropdownDTO::getLabel, "-- Select Breed --", null, DropdownDTO::getId); + SpinnerUtils.setSelectionByValue(binding.spinnerPetBreed, state.selectedBreed); + } + if (!state.isCustomerEnabled && binding.spinnerCustomer.getSelectedItemPosition() != 0) { binding.spinnerCustomer.setSelection(0); } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/RefundFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/RefundFragment.java index f4e58fb5..0dbd985f 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/RefundFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/RefundFragment.java @@ -302,11 +302,7 @@ public class RefundFragment extends Fragment { } private void updateRefundTotal() { - BigDecimal total = BigDecimal.ZERO; - List cart = viewModel.getRefundCart().getValue(); - if (cart != null) { - for (RefundViewModel.RefundItem item : cart) total = total.add(item.getTotal()); - } + BigDecimal total = viewModel.calculateRefundTotal(); binding.tvRefundTotal.setText("Refund Total: $" + total.setScale(2, RoundingMode.HALF_UP)); } @@ -321,9 +317,7 @@ public class RefundFragment extends Fragment { } String payment = PAYMENT_METHODS[binding.spinnerRefundPayment.getSelectedItemPosition()]; - BigDecimal total = BigDecimal.ZERO; - for (RefundViewModel.RefundItem item : viewModel.getRefundCart().getValue()) total = total.add(item.getTotal()); - final BigDecimal finalTotal = total; + final BigDecimal finalTotal = viewModel.calculateRefundTotal(); DialogUtils.showConfirmDialog(requireContext(), "Confirm Refund", "Process refund for Sale #" + viewModel.getCurrentSale().getSaleId() diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java index ec111675..8a4dff16 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java @@ -14,6 +14,7 @@ import com.example.petstoremobile.dtos.*; import com.example.petstoremobile.viewmodels.SaleDetailViewModel; import com.example.petstoremobile.utils.SpinnerUtils; import com.example.petstoremobile.utils.DialogUtils; +import com.example.petstoremobile.utils.DateTimeUtils; import com.example.petstoremobile.utils.InputValidator; import com.example.petstoremobile.utils.Resource; import com.example.petstoremobile.utils.UIUtils; @@ -68,6 +69,10 @@ public class SaleDetailFragment extends Fragment { return "STAFF".equalsIgnoreCase(tokenManager.getRole()); } + private boolean isAdmin() { + return "ADMIN".equalsIgnoreCase(tokenManager.getRole()); + } + private void observeViewModel() { viewModel.getStoreList().observe(getViewLifecycleOwner(), list -> { Long primaryStoreId = tokenManager.getPrimaryStoreId(); @@ -77,7 +82,7 @@ public class SaleDetailFragment extends Fragment { if (isStaff()) { UIUtils.setViewsEnabled(false, binding.spinnerSaleStore); if (primaryStoreId == null) { - Toast.makeText(requireContext(), "No store assigned to your account. Contact an admin.", Toast.LENGTH_LONG).show(); + UIUtils.showToast(requireContext(), "No store assigned to your account. Contact an admin."); } } }); @@ -96,6 +101,17 @@ public class SaleDetailFragment extends Fragment { renderCartItems(); updateTotal(); }); + + viewModel.getSelectedCustomerData().observe(getViewLifecycleOwner(), customer -> { + if (customer != null && !viewModel.isViewOnly()) { + binding.llLoyaltyPoints.setVisibility(View.VISIBLE); + binding.tvAvailablePoints.setText("(Available: " + customer.getLoyaltyPoints() + ")"); + binding.cbUseLoyaltyPoints.setEnabled(customer.getLoyaltyPoints() >= 20); + } else { + binding.llLoyaltyPoints.setVisibility(View.GONE); + binding.cbUseLoyaltyPoints.setChecked(false); + } + }); } private void handleArguments() { @@ -105,11 +121,11 @@ public class SaleDetailFragment extends Fragment { boolean viewOnly = a.getBoolean("viewOnly", false); viewModel.setSaleId(saleId, viewOnly); - binding.tvSaleMode.setText("Sale #" + saleId); - binding.tvSaleDetailId.setText("ID: " + saleId); + binding.tvSaleMode.setText("Sale #" + DateTimeUtils.formatId(saleId)); + binding.tvSaleDetailId.setText("ID: " + DateTimeUtils.formatId(saleId)); boolean isRefund = a.getBoolean("isRefund", false); - if (isRefund) { + if (isRefund || isAdmin()) { binding.btnRefundSale.setVisibility(View.GONE); } @@ -128,9 +144,8 @@ public class SaleDetailFragment extends Fragment { binding.spinnerPaymentMethod.setVisibility(View.GONE); binding.tvSaleStore.setVisibility(View.VISIBLE); binding.tvSalePaymentMethod.setVisibility(View.VISIBLE); - - // Show refund button only if it's not already a refund - binding.btnRefundSale.setVisibility(isRefund ? View.GONE : View.VISIBLE); + + binding.btnRefundSale.setVisibility((isRefund || isAdmin()) ? View.GONE : View.VISIBLE); } loadSaleDetails(); @@ -173,23 +188,36 @@ public class SaleDetailFragment extends Fragment { setLoading(resource.status == Resource.Status.LOADING); if (resource.status == Resource.Status.SUCCESS && resource.data != null) { SaleDTO sale = resource.data; - binding.tvSaleDetailTotal.setText("Total: $" + sale.getTotalAmount()); - binding.tvSaleSubtotal.setText("$" + (sale.getSubtotalAmount() != null ? sale.getSubtotalAmount() : sale.getTotalAmount())); + binding.tvSaleDetailTotal.setText("Total: $" + String.format(Locale.getDefault(), "%.2f", sale.getTotalAmount())); + binding.tvSaleSubtotal.setText("$" + String.format(Locale.getDefault(), "%.2f", (sale.getSubtotalAmount() != null ? sale.getSubtotalAmount() : sale.getTotalAmount()))); if (sale.getCouponDiscountAmount() != null && sale.getCouponDiscountAmount().compareTo(BigDecimal.ZERO) > 0) { binding.llCouponDiscount.setVisibility(View.VISIBLE); - binding.tvSaleCouponDiscount.setText("-$" + sale.getCouponDiscountAmount()); + binding.tvSaleCouponDiscount.setText("-$" + String.format(Locale.getDefault(), "%.2f", sale.getCouponDiscountAmount())); } else { binding.llCouponDiscount.setVisibility(View.GONE); } if (sale.getEmployeeDiscountAmount() != null && sale.getEmployeeDiscountAmount().compareTo(BigDecimal.ZERO) > 0) { binding.llEmployeeDiscount.setVisibility(View.VISIBLE); - binding.tvSaleEmployeeDiscount.setText("-$" + sale.getEmployeeDiscountAmount()); + binding.tvSaleEmployeeDiscount.setText("-$" + String.format(Locale.getDefault(), "%.2f", sale.getEmployeeDiscountAmount())); } else { binding.llEmployeeDiscount.setVisibility(View.GONE); } + if (sale.getPointsDiscountAmount() != null && sale.getPointsDiscountAmount().compareTo(BigDecimal.ZERO) > 0) { + binding.llLoyaltyDiscount.setVisibility(View.VISIBLE); + binding.tvSaleLoyaltyDiscount.setText("-$" + String.format(Locale.getDefault(), "%.2f", sale.getPointsDiscountAmount())); + if (sale.getPointsUsed() != null) { + binding.tvLoyaltyDiscountLabel.setText("Loyalty Discount (" + sale.getPointsUsed() + " pts):"); + } + } else if (sale.getLoyaltyDiscountAmount() != null && sale.getLoyaltyDiscountAmount().compareTo(BigDecimal.ZERO) > 0) { + binding.llLoyaltyDiscount.setVisibility(View.VISIBLE); + binding.tvSaleLoyaltyDiscount.setText("-$" + String.format(Locale.getDefault(), "%.2f", sale.getLoyaltyDiscountAmount())); + } else { + binding.llLoyaltyDiscount.setVisibility(View.GONE); + } + binding.tvSaleChannel.setText(sale.getChannel() != null ? sale.getChannel() : "—"); binding.tvSalePoints.setText(String.valueOf(sale.getPointsEarned() != null ? sale.getPointsEarned() : 0)); binding.tvSaleStore.setText(sale.getStoreName() != null ? sale.getStoreName() : "—"); @@ -216,7 +244,7 @@ public class SaleDetailFragment extends Fragment { binding.btnApplyCoupon.setOnClickListener(v -> { String code = binding.etCouponCode.getText().toString().trim(); if (code.isEmpty()) { - Toast.makeText(getContext(), "Enter a coupon code", Toast.LENGTH_SHORT).show(); + UIUtils.showToast(getContext(), "Enter a coupon code"); return; } setLoading(true); @@ -286,7 +314,7 @@ public class SaleDetailFragment extends Fragment { for (SaleDTO.SaleItemDTO existing : viewModel.getCartItems().getValue()) { if (existing.getProdId().equals(product.getProdId())) { - Toast.makeText(getContext(), "Product already added", Toast.LENGTH_SHORT).show(); + UIUtils.showToast(getContext(), "Product already added"); return; } } @@ -294,6 +322,19 @@ public class SaleDetailFragment extends Fragment { viewModel.addToCart(new SaleDTO.SaleItemDTO(product.getProdId(), qty)); binding.etSaleQuantity.setText(""); }); + SpinnerUtils.setOnIndexSelectedListener(binding.spinnerSaleCustomer, p -> { + if (p > 0) { + Long id = viewModel.getCustomerList().getValue().get(p - 1).getId(); + viewModel.selectCustomer(id); + } else { + viewModel.selectCustomer(null); + } + }); + + binding.cbUseLoyaltyPoints.setOnCheckedChangeListener((v, checked) -> { + viewModel.setUseLoyaltyPoints(checked); + updateTotal(); + }); } private void renderCartItems() { @@ -333,7 +374,7 @@ public class SaleDetailFragment extends Fragment { TextView tvPrice = new TextView(getContext()); tvPrice.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); - tvPrice.setText(price != null ? "$" + price : ""); + tvPrice.setText(price != null ? "$" + String.format(Locale.getDefault(), "%.2f", price) : ""); row.addView(tvName); row.addView(tvQty); @@ -357,23 +398,35 @@ public class SaleDetailFragment extends Fragment { private void updateTotal() { BigDecimal subtotal = viewModel.calculateSubtotal(); - BigDecimal discount = viewModel.calculateDiscount(); - BigDecimal total = subtotal.subtract(discount); - binding.tvSaleSubtotal.setText("$" + subtotal); - if (discount.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal couponDiscount = viewModel.calculateCouponDiscount(); + BigDecimal loyaltyDiscount = viewModel.calculateLoyaltyDiscount(); + BigDecimal total = subtotal.subtract(couponDiscount).subtract(loyaltyDiscount); + + binding.tvSaleSubtotal.setText("$" + String.format(Locale.getDefault(), "%.2f", subtotal)); + + if (couponDiscount.compareTo(BigDecimal.ZERO) > 0) { binding.llCouponDiscount.setVisibility(View.VISIBLE); - binding.tvSaleCouponDiscount.setText("-$" + discount); + binding.tvSaleCouponDiscount.setText("-$" + String.format(Locale.getDefault(), "%.2f", couponDiscount)); } else { binding.llCouponDiscount.setVisibility(View.GONE); } - binding.tvSaleDetailTotal.setText("Total: $" + total); + + if (loyaltyDiscount.compareTo(BigDecimal.ZERO) > 0) { + binding.llLoyaltyDiscount.setVisibility(View.VISIBLE); + binding.tvLoyaltyDiscountLabel.setText("Loyalty Discount (" + viewModel.calculatePointsToUse() + " pts):"); + binding.tvSaleLoyaltyDiscount.setText("-$" + String.format(Locale.getDefault(), "%.2f", loyaltyDiscount)); + } else { + binding.llLoyaltyDiscount.setVisibility(View.GONE); + } + + binding.tvSaleDetailTotal.setText("Total: $" + String.format(Locale.getDefault(), "%.2f", total)); } private void saveSale() { if (!InputValidator.isSpinnerSelected(binding.spinnerSaleStore, "Store")) return; if (viewModel.getCartItems().getValue() == null || viewModel.getCartItems().getValue().isEmpty()) { - Toast.makeText(getContext(), "Add at least one item", Toast.LENGTH_SHORT).show(); + UIUtils.showToast(getContext(), "Add at least one item"); return; } @@ -387,12 +440,16 @@ public class SaleDetailFragment extends Fragment { SaleDTO dto = new SaleDTO(store.getId(), payment, viewModel.getCartItems().getValue(), false, null, customerId); dto.setCouponId(viewModel.getAppliedCouponId()); + + if (Boolean.TRUE.equals(viewModel.getUseLoyaltyPoints().getValue())) { + dto.setPointsUsed(viewModel.calculatePointsToUse()); + } viewModel.createSale(dto).observe(getViewLifecycleOwner(), resource -> { if (resource != null) { setLoading(resource.status == Resource.Status.LOADING); if (resource.status == Resource.Status.SUCCESS) { - Toast.makeText(getContext(), "Sale saved!", Toast.LENGTH_SHORT).show(); + UIUtils.showToast(getContext(), "Sale saved!"); navigateBack(); } else if (resource.status == Resource.Status.ERROR) { DialogUtils.showInfoDialog(requireContext(), "Save Error", resource.message); diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java index acf171ac..94b2b307 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java @@ -161,7 +161,7 @@ public class StaffDetailFragment extends Fragment { List stores = viewModel.getStoreList().getValue(); Long storeId = stores.get(binding.spinnerStaffStore.getSelectedItemPosition() - 1).getId(); - EmployeeDTO dto = new EmployeeDTO( + EmployeeDTO dto = viewModel.createEmployeeDto( username, password.isEmpty() ? null : password, firstName, diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java index 73da4613..496304a3 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/listprofilefragments/PetProfileFragment.java @@ -126,7 +126,7 @@ public class PetProfileFragment extends Fragment { String status = pet.getPetStatus(); - if ("Adopted".equalsIgnoreCase(status) || "Owned".equalsIgnoreCase(status)) { + if ("Adopted".equalsIgnoreCase(status) || "Owned".equalsIgnoreCase(status) || "Pending".equalsIgnoreCase(status)) { binding.layoutPetOwner.setVisibility(View.VISIBLE); if (pet.getCustomerName() != null && !pet.getCustomerName().isEmpty()) { binding.tvPetOwner.setText(pet.getCustomerName()); @@ -137,7 +137,7 @@ public class PetProfileFragment extends Fragment { binding.layoutPetOwner.setVisibility(View.GONE); } - if ("Available".equalsIgnoreCase(status) || "Adopted".equalsIgnoreCase(status)) { + if ("Available".equalsIgnoreCase(status) || "Adopted".equalsIgnoreCase(status) || "Pending".equalsIgnoreCase(status)) { binding.layoutPetStore.setVisibility(View.VISIBLE); if (pet.getStoreName() != null && !pet.getStoreName().isEmpty()) { binding.tvPetStore.setText(pet.getStoreName()); diff --git a/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java b/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java index f3114beb..11bb9ff1 100644 --- a/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java +++ b/android/app/src/main/java/com/example/petstoremobile/repositories/PetRepository.java @@ -58,6 +58,10 @@ public class PetRepository extends BaseRepository { return executeCall(petApi.getPetSpeciesDropdowns()); } + public LiveData>> getPetBreedsDropdowns(String species) { + return executeCall(petApi.getPetBreedsDropdowns(species)); + } + /** * Retrieves available pets for a specific store. */ diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java b/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java index ee79ec9f..0d45ad12 100644 --- a/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java +++ b/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java @@ -100,12 +100,13 @@ public class InputValidator { return true; } - // Checks if the phone number is valid + // Checks if the phone number is valid in (XXX) XXX-XXXX format public static boolean isValidPhone(EditText field) { String phone = field.getText().toString().trim(); - // Android built in phone validation pattern - if (phone.isEmpty() || !android.util.Patterns.PHONE.matcher(phone).matches()) { - field.setError("Enter a valid phone number"); + // Matches (XXX) XXX-XXXX format + String pattern = "^\\(\\d{3}\\) \\d{3}-\\d{4}$"; + if (phone.isEmpty() || !phone.matches(pattern)) { + field.setError("Enter a valid phone number: (XXX) XXX-XXXX"); field.requestFocus(); return false; } diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/CustomerDetailViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/CustomerDetailViewModel.java index e7864b66..0d0ebfaf 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/CustomerDetailViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/CustomerDetailViewModel.java @@ -31,6 +31,19 @@ public class CustomerDetailViewModel extends ViewModel { public long getCustomerId() { return customerId; } public boolean isEditing() { return isEditing; } + public CustomerDTO createCustomerDto(String username, String password, String firstName, + String lastName, String email, String phone, + boolean active, Integer loyaltyPoints) { + CustomerDTO dto = new CustomerDTO(username, password, firstName, lastName, email, phone); + dto.setFullName(firstName + " " + lastName); + dto.setActive(active); + dto.setRole("CUSTOMER"); + if (isEditing && loyaltyPoints != null) { + dto.setLoyaltyPoints(loyaltyPoints); + } + return dto; + } + public LiveData> loadCustomer(long id) { return repository.getCustomerById(id); } diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetDetailViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetDetailViewModel.java index 2f302b42..ddb868a7 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetDetailViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/PetDetailViewModel.java @@ -25,6 +25,7 @@ public class PetDetailViewModel extends ViewModel { private static final String STATUS_AVAILABLE = "Available"; private static final String STATUS_ADOPTED = "Adopted"; private static final String STATUS_OWNED = "Owned"; + private static final String STATUS_PENDING = "Pending"; private final PetRepository petRepository; private final CustomerRepository customerRepository; @@ -33,6 +34,7 @@ public class PetDetailViewModel extends ViewModel { private final MutableLiveData> customerList = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData> storeList = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData> speciesList = new MutableLiveData<>(new ArrayList<>()); + private final MutableLiveData> breedList = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData isLoading = new MutableLiveData<>(false); private final MutableLiveData viewState = new MutableLiveData<>(new ViewState()); @@ -40,6 +42,7 @@ public class PetDetailViewModel extends ViewModel { private Long selectedCustomerId = null; private Long selectedStoreId = null; private String selectedSpecies = null; + private String selectedBreed = null; private boolean isOriginallyOwnedOrAdopted = false; private Long originalCustomerId = null; @@ -111,10 +114,33 @@ public class PetDetailViewModel extends ViewModel { List list = speciesList.getValue(); if (position > 0 && list != null && position <= list.size()) { selectedSpecies = list.get(position - 1).getLabel(); + loadBreeds(selectedSpecies); } else { selectedSpecies = null; + breedList.setValue(new ArrayList<>()); } - updateViewState(state -> state.selectedSpecies = selectedSpecies); + updateViewState(state -> { + state.selectedSpecies = selectedSpecies; + state.isBreedEnabled = !state.isEditing && (selectedSpecies != null); + }); + } + + public void onBreedSelected(int position) { + List list = breedList.getValue(); + if (position > 0 && list != null && position <= list.size()) { + selectedBreed = list.get(position - 1).getLabel(); + } else { + selectedBreed = null; + } + updateViewState(state -> state.selectedBreed = selectedBreed); + } + + private void loadBreeds(String species) { + observeOnce(petRepository.getPetBreedsDropdowns(species), resource -> { + if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) { + breedList.setValue(resource.data); + } + }); } public void onStoreSelected(int position) { @@ -154,12 +180,15 @@ public class PetDetailViewModel extends ViewModel { selectedCustomerId = null; selectedStoreId = null; selectedSpecies = null; + selectedBreed = null; state.selectedCustomerId = null; state.selectedStoreId = null; state.selectedSpecies = null; + state.selectedBreed = null; state.selectedStatus = STATUS_AVAILABLE; state.isCustomerEnabled = false; state.isStoreEnabled = true; + state.isBreedEnabled = false; } }); } @@ -172,15 +201,22 @@ public class PetDetailViewModel extends ViewModel { selectedCustomerId = pet.getCustomerId(); selectedStoreId = pet.getStoreId(); selectedSpecies = pet.getPetSpecies(); + selectedBreed = pet.getPetBreed(); isOriginallyOwnedOrAdopted = STATUS_OWNED.equalsIgnoreCase(pet.getPetStatus()) || STATUS_ADOPTED.equalsIgnoreCase(pet.getPetStatus()); originalCustomerId = pet.getCustomerId(); + if (selectedSpecies != null) { + loadBreeds(selectedSpecies); + } + updateViewState(state -> { state.selectedCustomerId = selectedCustomerId; state.selectedStoreId = selectedStoreId; state.selectedSpecies = selectedSpecies; + state.selectedBreed = selectedBreed; state.selectedStatus = normalizeStatus(pet.getPetStatus()); + state.isBreedEnabled = !state.isEditing && (selectedSpecies != null); applyStatusRules(state, false); }); } @@ -215,6 +251,10 @@ public class PetDetailViewModel extends ViewModel { return speciesList; } + public LiveData> getBreedList() { + return breedList; + } + public LiveData getIsLoading() { return isLoading; } @@ -253,6 +293,7 @@ public class PetDetailViewModel extends ViewModel { String normalized = status.trim(); if (STATUS_ADOPTED.equalsIgnoreCase(normalized)) return STATUS_ADOPTED; if (STATUS_OWNED.equalsIgnoreCase(normalized)) return STATUS_OWNED; + if (STATUS_PENDING.equalsIgnoreCase(normalized)) return STATUS_PENDING; return STATUS_AVAILABLE; } @@ -290,9 +331,10 @@ public class PetDetailViewModel extends ViewModel { public boolean isStoreEnabled = true; public String modeTitle = "Add Pet"; public String saveButtonText = "Add"; - public String[] availableStatuses = new String[]{STATUS_AVAILABLE, STATUS_ADOPTED, STATUS_OWNED}; + public String[] availableStatuses = new String[]{STATUS_AVAILABLE, STATUS_ADOPTED, STATUS_OWNED, STATUS_PENDING}; public String selectedStatus = STATUS_AVAILABLE; public String selectedSpecies = null; + public String selectedBreed = null; public Long selectedCustomerId = null; public Long selectedStoreId = null; } diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/RefundViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/RefundViewModel.java index 222a1fa4..fab83dd9 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/RefundViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/RefundViewModel.java @@ -10,6 +10,7 @@ import com.example.petstoremobile.repositories.SaleRepository; import com.example.petstoremobile.utils.Resource; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -125,6 +126,33 @@ public class RefundViewModel extends ViewModel { refundCart.setValue(cart); } + public BigDecimal calculateRefundTotal() { + SaleDTO sale = currentSale.getValue(); + List cart = refundCart.getValue(); + if (sale == null || cart == null || cart.isEmpty()) return BigDecimal.ZERO; + + BigDecimal cartSubtotal = BigDecimal.ZERO; + for (RefundItem item : cart) cartSubtotal = cartSubtotal.add(item.getTotal()); + + BigDecimal originalSubtotal = sale.getSubtotalAmount(); + if (originalSubtotal == null || originalSubtotal.compareTo(BigDecimal.ZERO) == 0) { + if (sale.getItems() != null) { + originalSubtotal = BigDecimal.ZERO; + for (SaleDTO.SaleItemDTO item : sale.getItems()) { + if (item.getUnitPrice() != null && item.getQuantity() != null) + originalSubtotal = originalSubtotal.add(item.getUnitPrice().multiply(BigDecimal.valueOf(Math.abs(item.getQuantity())))); + } + } + } + if (originalSubtotal == null || originalSubtotal.compareTo(BigDecimal.ZERO) == 0) return cartSubtotal; + + BigDecimal originalTotal = sale.getTotalAmount(); + if (originalTotal == null) return cartSubtotal; + + BigDecimal ratio = cartSubtotal.divide(originalSubtotal, 10, RoundingMode.HALF_UP); + return originalTotal.abs().multiply(ratio).setScale(2, RoundingMode.HALF_UP); + } + public LiveData> submitRefund(String paymentMethod) { SaleDTO sale = currentSale.getValue(); List cart = refundCart.getValue(); diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/SaleDetailViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/SaleDetailViewModel.java index 658264c9..68369cce 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/SaleDetailViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/SaleDetailViewModel.java @@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import com.example.petstoremobile.dtos.CouponDTO; +import com.example.petstoremobile.dtos.CustomerDTO; import com.example.petstoremobile.dtos.DropdownDTO; import com.example.petstoremobile.dtos.ProductDTO; import com.example.petstoremobile.dtos.SaleDTO; @@ -39,6 +40,8 @@ public class SaleDetailViewModel extends ViewModel { private final MutableLiveData> productList = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData> cartItems = new MutableLiveData<>(new ArrayList<>()); private final MutableLiveData appliedCoupon = new MutableLiveData<>(null); + private final MutableLiveData selectedCustomerData = new MutableLiveData<>(null); + private final MutableLiveData useLoyaltyPoints = new MutableLiveData<>(false); private final MutableLiveData isLoading = new MutableLiveData<>(false); @Inject @@ -111,6 +114,34 @@ public class SaleDetailViewModel extends ViewModel { appliedCoupon.setValue(coupon); } + public void setUseLoyaltyPoints(boolean use) { + useLoyaltyPoints.setValue(use); + } + + public LiveData getUseLoyaltyPoints() { + return useLoyaltyPoints; + } + + public LiveData getSelectedCustomerData() { + return selectedCustomerData; + } + + public void selectCustomer(Long customerId) { + if (customerId == null) { + selectedCustomerData.setValue(null); + useLoyaltyPoints.setValue(false); + return; + } + customerRepository.getCustomerById(customerId).observeForever(new androidx.lifecycle.Observer>() { + @Override + public void onChanged(Resource resource) { + if (resource != null && resource.status == Resource.Status.SUCCESS) { + selectedCustomerData.setValue(resource.data); + } + } + }); + } + public void clearCoupon() { appliedCoupon.setValue(null); } @@ -125,6 +156,10 @@ public class SaleDetailViewModel extends ViewModel { } public BigDecimal calculateDiscount() { + return calculateCouponDiscount().add(calculateLoyaltyDiscount()); + } + + public BigDecimal calculateCouponDiscount() { CouponDTO coupon = appliedCoupon.getValue(); if (coupon == null || coupon.getDiscountValue() == null) return BigDecimal.ZERO; BigDecimal subtotal = calculateSubtotal(); @@ -135,6 +170,25 @@ public class SaleDetailViewModel extends ViewModel { } } + public BigDecimal calculateLoyaltyDiscount() { + if (Boolean.FALSE.equals(useLoyaltyPoints.getValue())) return BigDecimal.ZERO; + CustomerDTO customer = selectedCustomerData.getValue(); + if (customer == null || customer.getLoyaltyPoints() == null || customer.getLoyaltyPoints() < 20) { + return BigDecimal.ZERO; + } + + BigDecimal subtotalAfterCoupon = calculateSubtotal().subtract(calculateCouponDiscount()); + int maxPointsNeeded = subtotalAfterCoupon.multiply(BigDecimal.valueOf(20)).intValue(); + int pointsToUse = Math.min(customer.getLoyaltyPoints(), maxPointsNeeded); + + return BigDecimal.valueOf(pointsToUse).multiply(BigDecimal.valueOf(0.05)).setScale(2, java.math.RoundingMode.HALF_UP); + } + + public int calculatePointsToUse() { + BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(); + return loyaltyDiscount.divide(BigDecimal.valueOf(0.05), 0, java.math.RoundingMode.HALF_UP).intValue(); + } + public BigDecimal calculateSubtotal() { BigDecimal total = BigDecimal.ZERO; List items = cartItems.getValue(); diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java index cbee3bc3..7927465e 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java @@ -60,6 +60,14 @@ public class StaffDetailViewModel extends ViewModel { return isEditing; } + public EmployeeDTO createEmployeeDto(String username, String password, String firstName, + String lastName, String email, String phone, + String role, String staffRole, boolean active, Long storeId) { + EmployeeDTO dto = new EmployeeDTO(username, password, firstName, lastName, email, phone, role, staffRole, active, storeId); + dto.setFullName(firstName + " " + lastName); + return dto; + } + public LiveData> saveEmployee(EmployeeDTO dto) { if (isEditing && employeeId > 0) { return repository.updateEmployee(employeeId, dto); diff --git a/android/app/src/main/res/layout/fragment_customer_detail.xml b/android/app/src/main/res/layout/fragment_customer_detail.xml index a2c63030..83033dc5 100644 --- a/android/app/src/main/res/layout/fragment_customer_detail.xml +++ b/android/app/src/main/res/layout/fragment_customer_detail.xml @@ -195,14 +195,13 @@ android:textSize="12sp" android:layout_marginBottom="4dp"/> - + android:hint="0" + android:inputType="number" + android:layout_marginBottom="16dp"/> diff --git a/android/app/src/main/res/layout/fragment_pet_detail.xml b/android/app/src/main/res/layout/fragment_pet_detail.xml index e9018362..e42d8e30 100644 --- a/android/app/src/main/res/layout/fragment_pet_detail.xml +++ b/android/app/src/main/res/layout/fragment_pet_detail.xml @@ -109,14 +109,11 @@ android:textSize="12sp" android:layout_marginBottom="4dp"/> - + android:layout_marginBottom="16dp"/> + android:layout_marginBottom="8dp"/> + + + + + + + + + + items = new ArrayList<>(); @@ -233,6 +239,22 @@ public class Sale { this.pointsEarned = pointsEarned; } + public Integer getPointsUsed() { + return pointsUsed; + } + + public void setPointsUsed(Integer pointsUsed) { + this.pointsUsed = pointsUsed; + } + + public BigDecimal getPointsDiscountAmount() { + return pointsDiscountAmount; + } + + public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) { + this.pointsDiscountAmount = pointsDiscountAmount; + } + public List getItems() { return items; } diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index f020c57e..e5b5a958 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -19,7 +19,7 @@ public interface PetRepository extends JpaRepository { "WHERE LOWER(p.petStatus) = 'available' " + "AND NOT EXISTS (" + " SELECT 1 FROM Adoption a " + - " WHERE a.pet = p AND LOWER(a.adoptionStatus) = 'completed'" + + " WHERE a.pet = p AND (LOWER(a.adoptionStatus) = 'completed' OR LOWER(a.adoptionStatus) = 'pending')" + ") " + "ORDER BY p.petName ASC") List findAdoptablePetsOrderByPetNameAsc(); @@ -29,7 +29,7 @@ public interface PetRepository extends JpaRepository { "AND (:storeId IS NULL OR p.store.storeId = :storeId) " + "AND NOT EXISTS (" + " SELECT 1 FROM Adoption a " + - " WHERE a.pet = p AND LOWER(a.adoptionStatus) = 'completed'" + + " WHERE a.pet = p AND (LOWER(a.adoptionStatus) = 'completed' OR LOWER(a.adoptionStatus) = 'pending')" + ") " + "ORDER BY p.petName ASC") List findAdoptablePetsByStore(@Param("storeId") Long storeId); diff --git a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java index d5d66d67..0874aded 100644 --- a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java +++ b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java @@ -32,6 +32,7 @@ public class AdoptionService { private static final String ADOPTION_STATUS_MISSED = "Missed"; private static final String PET_STATUS_AVAILABLE = "Available"; private static final String PET_STATUS_ADOPTED = "Adopted"; + private static final String PET_STATUS_PENDING = "Pending"; private final AdoptionRepository adoptionRepository; private final PetRepository petRepository; @@ -263,10 +264,14 @@ public class AdoptionService { private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId, User customer) { boolean completedElsewhere = adoptionId != null && adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCaseAndAdoptionIdNot(pet.getPetId(), ADOPTION_STATUS_COMPLETED, adoptionId); + if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus) || completedElsewhere) { pet.setPetStatus(PET_STATUS_ADOPTED); pet.setOwner(customer); pet.setStore(null); + } else if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus)) { + pet.setPetStatus(PET_STATUS_PENDING); + pet.setOwner(customer); } else { pet.setPetStatus(PET_STATUS_AVAILABLE); pet.setOwner(null); diff --git a/backend/src/main/java/com/petshop/backend/service/SaleService.java b/backend/src/main/java/com/petshop/backend/service/SaleService.java index dd8e73d8..000bb3f4 100644 --- a/backend/src/main/java/com/petshop/backend/service/SaleService.java +++ b/backend/src/main/java/com/petshop/backend/service/SaleService.java @@ -160,14 +160,33 @@ public class SaleService { saleItems.add(saleItem); subtotalAmount = subtotalAmount.add(itemTotal); } - subtotalAmount = subtotalAmount.negate(); - sale.setSubtotalAmount(subtotalAmount); - sale.setTotalAmount(subtotalAmount); - sale.setCouponDiscountAmount(BigDecimal.ZERO); - sale.setEmployeeDiscountAmount(BigDecimal.ZERO); - sale.setLoyaltyDiscountAmount(BigDecimal.ZERO); - sale.setPointsUsed(0); - sale.setPointsEarned(0); + + Sale originalSale = sale.getOriginalSale(); + BigDecimal originalSubtotal = originalSale.getSubtotalAmount() != null + ? originalSale.getSubtotalAmount() + : originalSale.getItems().stream() + .map(i -> i.getUnitPrice().multiply(BigDecimal.valueOf(Math.abs(i.getQuantity())))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal refundRatio = originalSubtotal.compareTo(BigDecimal.ZERO) != 0 + ? subtotalAmount.divide(originalSubtotal, 10, RoundingMode.HALF_UP) + : BigDecimal.ONE; + + BigDecimal refundTotal = originalSale.getTotalAmount().abs() + .multiply(refundRatio).setScale(2, RoundingMode.HALF_UP); + + sale.setSubtotalAmount(subtotalAmount.negate()); + sale.setTotalAmount(refundTotal.negate()); + + User refundCustomer = customer != null ? customer : originalSale.getCustomer(); + if (refundCustomer != null) { + int pointsToRestore = BigDecimal.valueOf(originalSale.getPointsUsed()) + .multiply(refundRatio).setScale(0, RoundingMode.FLOOR).intValue(); + int pointsToDeduct = BigDecimal.valueOf(originalSale.getPointsEarned()) + .multiply(refundRatio).setScale(0, RoundingMode.FLOOR).intValue(); + refundCustomer.setLoyaltyPoints(refundCustomer.getLoyaltyPoints() + pointsToRestore - pointsToDeduct); + userRepository.save(refundCustomer); + } } else { if (request.getItems() == null || request.getItems().isEmpty()) { throw new BusinessException("At least one item is required"); @@ -204,14 +223,23 @@ public class SaleService { BigDecimal couponDiscount = calculateCouponDiscount(sale.getCoupon(), subtotalAmount); sale.setCouponDiscountAmount(couponDiscount); - BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount)); + BigDecimal pointsDiscount = BigDecimal.ZERO; + int pointsUsed = 0; + if (customer != null && request.getPointsUsed() != null && request.getPointsUsed() > 0) { + if (customer.getLoyaltyPoints() < request.getPointsUsed()) { + throw new BusinessException("Customer does not have enough loyalty points"); + } + pointsUsed = request.getPointsUsed(); + pointsDiscount = calculatePointsDiscount(pointsUsed); + customer.setLoyaltyPoints(customer.getLoyaltyPoints() - pointsUsed); + } + sale.setPointsUsed(pointsUsed); + sale.setPointsDiscountAmount(pointsDiscount); + + BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount).subtract(pointsDiscount)); sale.setEmployeeDiscountAmount(employeeDiscount); - BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(customer, subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount), Boolean.TRUE.equals(request.getUseLoyaltyPoints())); - sale.setLoyaltyDiscountAmount(loyaltyDiscount); - sale.setPointsUsed(toPointsUsed(loyaltyDiscount)); - - BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount).subtract(loyaltyDiscount); + BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(pointsDiscount).subtract(employeeDiscount); sale.setTotalAmount(finalTotal.max(BigDecimal.ZERO)); sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue()); @@ -262,6 +290,10 @@ public class SaleService { return discount.min(subtotal).setScale(2, RoundingMode.HALF_UP); } + private BigDecimal calculatePointsDiscount(int pointsUsed) { + return new BigDecimal(pointsUsed).divide(new BigDecimal("20"), 2, RoundingMode.HALF_UP); + } + private BigDecimal calculateEmployeeDiscount(User customer, BigDecimal remainingAmount) { if (customer == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; @@ -328,6 +360,8 @@ public class SaleService { response.setLoyaltyDiscountAmount(sale.getLoyaltyDiscountAmount()); response.setPointsUsed(sale.getPointsUsed()); response.setPointsEarned(sale.getPointsEarned()); + response.setPointsUsed(sale.getPointsUsed()); + response.setPointsDiscountAmount(sale.getPointsDiscountAmount()); response.setChannel(sale.getChannel()); if (sale.getCoupon() != null) { response.setCouponId(sale.getCoupon().getCouponId()); diff --git a/backend/src/main/java/com/petshop/backend/service/UserService.java b/backend/src/main/java/com/petshop/backend/service/UserService.java index 77141b92..61c6f096 100644 --- a/backend/src/main/java/com/petshop/backend/service/UserService.java +++ b/backend/src/main/java/com/petshop/backend/service/UserService.java @@ -75,6 +75,9 @@ public class UserService { user.setStaffRole(trimToNull(request.getStaffRole())); user.setPrimaryStore(resolveStore(request.getPrimaryStoreId())); user.setActive(request.getActive() != null ? request.getActive() : true); + if (request.getLoyaltyPoints() != null) { + user.setLoyaltyPoints(request.getLoyaltyPoints()); + } validateUniquePhone(user.getPhone(), null); @@ -111,6 +114,9 @@ public class UserService { user.setStaffRole(trimToNull(request.getStaffRole())); user.setPrimaryStore(resolveStore(request.getPrimaryStoreId())); user.setActive(request.getActive() != null ? request.getActive() : true); + if (request.getLoyaltyPoints() != null) { + user.setLoyaltyPoints(request.getLoyaltyPoints()); + } if (invalidateToken) { user.setTokenVersion(user.getTokenVersion() + 1); } diff --git a/backend/src/main/resources/db/migration/V5__add_points_columns_to_sale.sql b/backend/src/main/resources/db/migration/V5__add_points_columns_to_sale.sql new file mode 100644 index 00000000..288561ef --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__add_points_columns_to_sale.sql @@ -0,0 +1,3 @@ +ALTER TABLE sale + ADD COLUMN pointsUsed INT NOT NULL DEFAULT 0, + ADD COLUMN pointsDiscountAmount DECIMAL(10, 2) NOT NULL DEFAULT 0.00; diff --git a/backend/src/main/resources/dev/final-target/final_target_schema.sql b/backend/src/main/resources/dev/final-target/final_target_schema.sql index c94e10dc..8abf1beb 100644 --- a/backend/src/main/resources/dev/final-target/final_target_schema.sql +++ b/backend/src/main/resources/dev/final-target/final_target_schema.sql @@ -230,6 +230,8 @@ CREATE TABLE IF NOT EXISTS sale ( couponDiscountAmount DECIMAL(10, 2) NOT NULL DEFAULT 0.00, employeeDiscountAmount DECIMAL(10, 2) NOT NULL DEFAULT 0.00, pointsEarned INT NOT NULL DEFAULT 0, + pointsUsed INT NOT NULL DEFAULT 0, + pointsDiscountAmount DECIMAL(10, 2) NOT NULL DEFAULT 0.00, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_sale_employee FOREIGN KEY (employeeId) REFERENCES users(id), diff --git a/desktop/src/main/java/org/example/petshopdesktop/Validator.java b/desktop/src/main/java/org/example/petshopdesktop/Validator.java index 2940cde4..1a453556 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/Validator.java +++ b/desktop/src/main/java/org/example/petshopdesktop/Validator.java @@ -183,7 +183,7 @@ public class Validator { } /** - * Checks if the input is a valid phone number in format XXX-XXX-XXXX + * Checks if the input is a valid phone number in format (XXX) XXX-XXXX * @param value input of string * @param name name of input * @return error msg if input is not in valid phone format, otherwise empty @@ -191,14 +191,14 @@ public class Validator { public static String isValidPhoneNumber(String value, String name){ String msg = ""; if (value == null) { - msg += name + " must be in format XXX-XXX-XXXX. \n"; + msg += name + " must be in format (XXX) XXX-XXXX. \n"; return msg; } - String regex = "^\\d{3}-\\d{3}-\\d{4}$"; + String regex = "^\\(\\d{3}\\) \\d{3}-\\d{4}$"; if (!value.matches(regex)){ - msg += name + " must be in format XXX-XXX-XXXX. \n"; + msg += name + " must be in format (XXX) XXX-XXXX. \n"; } return msg; diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/user/UserResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/user/UserResponse.java index 3a42f128..f9fc4eac 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/user/UserResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/user/UserResponse.java @@ -10,6 +10,7 @@ public class UserResponse { private String phone; private String role; private Boolean active; + private Integer loyaltyPoints; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -72,6 +73,14 @@ public class UserResponse { this.active = active; } + public Integer getLoyaltyPoints() { + return loyaltyPoints; + } + + public void setLoyaltyPoints(Integer loyaltyPoints) { + this.loyaltyPoints = loyaltyPoints; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java index 127d75db..75c0c297 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java @@ -42,6 +42,15 @@ public class DropdownApi { return apiClient.getObjectMapper().readValue(response, new TypeReference>() {}); } + public List getPetBreeds(String species) throws Exception { + String encoded = java.net.URLEncoder.encode(species, java.nio.charset.StandardCharsets.UTF_8); + String response = apiClient.getRawResponse("/api/v1/dropdowns/pet-breeds?species=" + encoded); + if (response == null || response.isEmpty()) { + throw new IllegalStateException("Empty response from pet breeds endpoint"); + } + return apiClient.getObjectMapper().readValue(response, new TypeReference>() {}); + } + public List getProducts() throws Exception { String response = apiClient.getRawResponse("/api/v1/dropdowns/products"); if (response == null || response.isEmpty()) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/CustomerAccountsController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/CustomerAccountsController.java new file mode 100644 index 00000000..4de53100 --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/CustomerAccountsController.java @@ -0,0 +1,172 @@ +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.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.scene.control.*; +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.TableViewSupport; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +public class CustomerAccountsController { + + @FXML + private TableView tvCustomers; + + @FXML + private TableColumn colCustomerUsername; + + @FXML + private TableColumn colCustomerName; + + @FXML + private TableColumn colCustomerEmail; + + @FXML + private TableColumn colCustomerPhone; + + @FXML + private TableColumn colCustomerLoyaltyPoints; + + @FXML + private TableColumn colCustomerStatus; + + @FXML + private TableColumn colCustomerCreated; + + @FXML + private TextField txtSearchCustomer; + + @FXML + private Button btnEditCustomer; + + @FXML + private Button btnRefresh; + + @FXML + private Label lblError; + + @FXML + private Label lblStatus; + + private final ObservableList customerAccounts = FXCollections.observableArrayList(); + private FilteredList filteredCustomers; + + @FXML + public void initialize() { + 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())); + colCustomerPhone.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getPhone())); + colCustomerLoyaltyPoints.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getLoyaltyPoints())); + colCustomerStatus.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getActive() != null && data.getValue().getActive() ? "Active" : "Inactive")); + colCustomerCreated.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getCreatedAt())); + + filteredCustomers = new FilteredList<>(customerAccounts, a -> true); + TableViewSupport.bindSortedItems(tvCustomers, filteredCustomers); + TableViewSupport.installDoubleClickAction(tvCustomers, this::openEditDialog); + + tvCustomers.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> + btnEditCustomer.setDisable(newVal == null)); + btnEditCustomer.setDisable(true); + + txtSearchCustomer.textProperty().addListener((obs, o, n) -> applyCustomerFilter(n)); + + refresh(); + } + + @FXML + void btnRefreshClicked(ActionEvent event) { + txtSearchCustomer.clear(); + TableViewSupport.clearSort(tvCustomers); + refresh(); + TableViewSupport.flashStatus(lblStatus, "Refreshed"); + } + + @FXML + void btnEditCustomerClicked(ActionEvent event) { + lblError.setText(""); + openEditDialog(tvCustomers.getSelectionModel().getSelectedItem()); + } + + private void openEditDialog(UserResponse selected) { + if (selected == null) { + lblError.setText("Select a customer to edit."); + return; + } + + try { + FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/example/petshopdesktop/dialogviews/staff-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); + dialog.showAndWait(); + refresh(); + } catch (Exception e) { + ActivityLogger.getInstance().logException("CustomerAccountsController.openEditDialog", e, "Opening customer edit dialog"); + lblError.setText("Could not open customer account editor."); + } + } + + private void refresh() { + lblError.setText(""); + tvCustomers.setDisable(true); + + new Thread(() -> { + try { + Comparator byCreated = Comparator.comparing( + UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())); + + List customers = CustomerApi.getInstance().listCustomers(null).stream() + .sorted(byCreated) + .collect(Collectors.toList()); + + Platform.runLater(() -> { + customerAccounts.setAll(customers); + tvCustomers.setDisable(false); + }); + } catch (Exception e) { + ActivityLogger.getInstance().logException("CustomerAccountsController.refresh", e, "Loading customer accounts"); + Platform.runLater(() -> { + lblError.setText("Could not load customer accounts."); + tvCustomers.setDisable(false); + }); + } + }).start(); + } + + 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) + ); + } + + private static String safe(String v) { + return v == null ? "" : v.toLowerCase(); + } +} diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java index 61e7fa4e..997899d3 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/MainLayoutController.java @@ -89,6 +89,9 @@ public class MainLayoutController { @FXML private Button btnStaffAccounts; + @FXML + private Button btnCustomers; + @FXML private Button btnAnalytics; @@ -179,6 +182,12 @@ public class MainLayoutController { updateButtons(btnStaffAccounts); } + @FXML + void btnCustomersClicked(ActionEvent event) { + loadView("customer-accounts-view.fxml"); + updateButtons(btnCustomers); + } + @FXML void btnAnalyticsClicked(ActionEvent event) { loadView("analytics-view.fxml"); @@ -415,8 +424,13 @@ public class MainLayoutController { btnPurchaseOrders.setManaged(isAdmin); if (btnStaffAccounts != null) { - btnStaffAccounts.setVisible(true); - btnStaffAccounts.setManaged(true); + btnStaffAccounts.setVisible(isAdmin); + btnStaffAccounts.setManaged(isAdmin); + } + + if (btnCustomers != null) { + btnCustomers.setVisible(true); + btnCustomers.setManaged(true); } if (lblAdminSection != null) { @@ -493,6 +507,7 @@ public class MainLayoutController { btnProducts, btnPurchaseOrders, btnStaffAccounts, + btnCustomers, btnAnalytics, btnActivityLogs, btnCoupons, diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java index 3a90b787..9c7c6225 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java @@ -255,6 +255,8 @@ public class SaleController { boolean isAdmin = UserSession.getInstance().isAdmin(); vbCreateSale.setVisible(!isAdmin); vbCreateSale.setManaged(!isAdmin); + btnRefund.setVisible(!isAdmin); + btnRefund.setManaged(!isAdmin); lblModeNote.setText(isAdmin ? "(View only)" : "(Staff can create sales)"); } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/StaffAccountsController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/StaffAccountsController.java index ae749d4c..76771784 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/StaffAccountsController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/StaffAccountsController.java @@ -8,26 +8,25 @@ import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.TableColumn; -import javafx.scene.control.TableView; -import javafx.scene.control.TextField; +import javafx.scene.control.*; +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.endpoints.CustomerApi; import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.util.ActivityLogger; import org.example.petshopdesktop.util.TableViewSupport; -import java.util.List; import java.util.Comparator; +import java.util.List; import java.util.stream.Collectors; public class StaffAccountsController { + @FXML + private VBox staffSection; + @FXML private TableView tvStaff; @@ -55,12 +54,6 @@ public class StaffAccountsController { @FXML private TextField txtSearch; - @FXML - private Label lblError; - - @FXML - private Label lblStatus; - @FXML private Button btnRefresh; @@ -70,8 +63,14 @@ public class StaffAccountsController { @FXML private Button btnEditAccount; + @FXML + private Label lblError; + + @FXML + private Label lblStatus; + private final ObservableList staffAccounts = FXCollections.observableArrayList(); - private FilteredList filtered; + private FilteredList filteredStaff; @FXML public void initialize() { @@ -83,21 +82,15 @@ public class StaffAccountsController { colStatus.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getActive() != null && data.getValue().getActive() ? "Active" : "Inactive")); colCreated.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getCreatedAt())); - filtered = new FilteredList<>(staffAccounts, a -> true); - TableViewSupport.bindSortedItems(tvStaff, filtered); + filteredStaff = new FilteredList<>(staffAccounts, a -> true); + TableViewSupport.bindSortedItems(tvStaff, filteredStaff); TableViewSupport.installDoubleClickAction(tvStaff, this::openEditDialog); - txtSearch.textProperty().addListener((obs, o, n) -> applyFilter(n)); + tvStaff.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> + btnEditAccount.setDisable(newVal == null)); + btnEditAccount.setDisable(true); - tvStaff.getSelectionModel().selectedItemProperty().addListener((obs, oldValue, newValue) -> { - if (btnEditAccount != null) { - btnEditAccount.setDisable(newValue == null); - } - }); - - if (btnEditAccount != null) { - btnEditAccount.setDisable(true); - } + txtSearch.textProperty().addListener((obs, o, n) -> applyStaffFilter(n)); refresh(); } @@ -132,8 +125,7 @@ public class StaffAccountsController { @FXML void btnEditAccountClicked(ActionEvent event) { lblError.setText(""); - UserResponse selected = tvStaff.getSelectionModel().getSelectedItem(); - openEditDialog(selected); + openEditDialog(tvStaff.getSelectionModel().getSelectedItem()); } private void openEditDialog(UserResponse selected) { @@ -156,7 +148,7 @@ public class StaffAccountsController { Stage dialog = new Stage(); dialog.initOwner(tvStaff.getScene().getWindow()); dialog.initModality(Modality.APPLICATION_MODAL); - dialog.setTitle("Edit User Account"); + dialog.setTitle("Edit Staff Account"); dialog.setScene(new Scene(loader.load())); dialog.setResizable(false); var controller = (org.example.petshopdesktop.controllers.dialogcontrollers.StaffEditDialogController) loader.getController(); @@ -164,7 +156,7 @@ public class StaffAccountsController { dialog.showAndWait(); refresh(); } catch (Exception e) { - ActivityLogger.getInstance().logException("StaffAccountsController.btnEditAccountClicked", e, "Opening user edit dialog"); + ActivityLogger.getInstance().logException("StaffAccountsController.openEditDialog", e, "Opening user edit dialog"); lblError.setText("Could not open user account editor."); } } @@ -175,48 +167,40 @@ public class StaffAccountsController { new Thread(() -> { try { - UserSession session = UserSession.getInstance(); - List users; - if (session.isAdmin()) { - users = UserApi.getInstance().listUsers(null); - } else { - users = CustomerApi.getInstance().listCustomers(null); - } + Comparator byCreated = Comparator.comparing( + UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())); - List sortedUsers = users.stream() - .sorted(Comparator.comparing(UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()))) - .collect(Collectors.toList()); + List staff = UserApi.getInstance().listUsers(null).stream() + .filter(u -> !"CUSTOMER".equalsIgnoreCase(u.getRole())) + .sorted(byCreated) + .collect(Collectors.toList()); Platform.runLater(() -> { - staffAccounts.setAll(sortedUsers); + staffAccounts.setAll(staff); tvStaff.setDisable(false); }); } catch (Exception e) { - ActivityLogger.getInstance().logException("StaffAccountsController.refresh", e, "Loading user accounts"); + ActivityLogger.getInstance().logException("StaffAccountsController.refresh", e, "Loading staff accounts"); Platform.runLater(() -> { - String message = e.getMessage(); - lblError.setText(message == null || message.isBlank() - ? "Could not load user accounts." - : "Could not load user accounts: " + message); + lblError.setText("Could not load staff accounts."); tvStaff.setDisable(false); }); } }).start(); } - private void applyFilter(String text) { + private void applyStaffFilter(String text) { String q = text == null ? "" : text.trim().toLowerCase(); if (q.isEmpty()) { - filtered.setPredicate(a -> true); + filteredStaff.setPredicate(a -> true); return; } - - filtered.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) + 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) ); } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AdoptionDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AdoptionDialogController.java index 21a89e31..399f1ce6 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AdoptionDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AdoptionDialogController.java @@ -62,7 +62,7 @@ public class AdoptionDialogController { private boolean suppressPaymentDialog = false; private ObservableList statusList = FXCollections.observableArrayList( - "Pending", "Completed", "Cancelled" + "Pending", "Completed", "Missed", "Cancelled" ); @FXML @@ -282,6 +282,7 @@ public class AdoptionDialogController { } suppressPaymentDialog = true; + cbAdoptionStatus.setItems(statusList); for (String status : cbAdoptionStatus.getItems()) { if (status.equals(adoption.getAdoptionStatus())) { cbAdoptionStatus.getSelectionModel().select(status); @@ -289,13 +290,57 @@ public class AdoptionDialogController { } } suppressPaymentDialog = false; + applyEditModeLock(); } } + private void applyEditModeLock() { + String status = cbAdoptionStatus.getValue(); + + if ("Cancelled".equalsIgnoreCase(status)) { + cbPet.setDisable(true); + cbCustomer.setDisable(true); + cbEmployee.setDisable(true); + dpAdoptionDate.setDisable(true); + cbAdoptionStatus.setDisable(true); + cbAdoptionStatus.setItems(FXCollections.observableArrayList("Cancelled")); + btnSave.setDisable(true); + return; + } + + 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")); + dpAdoptionDate.setDisable(true); + } else { + cbAdoptionStatus.setItems(FXCollections.observableArrayList("Pending", "Cancelled")); + } + if (!cbAdoptionStatus.getItems().contains(cbAdoptionStatus.getValue())) { + cbAdoptionStatus.getSelectionModel().selectFirst(); + } + suppressPaymentDialog = false; + } + public void setMode(String mode) { this.mode = mode; lblMode.setText(mode + " Adoption"); lblAdoptionId.setVisible(mode.equals("Edit")); + if (mode.equals("Add")) { + suppressPaymentDialog = true; + cbAdoptionStatus.setItems(FXCollections.observableArrayList("Pending")); + cbAdoptionStatus.setValue("Pending"); + cbAdoptionStatus.setDisable(true); + suppressPaymentDialog = false; + } } private void applySelectedPet() { diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java index 91490c8f..c3980df5 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java @@ -43,24 +43,26 @@ public class AppointmentDialogController { @FXML private Label lblAppointmentId; @FXML private Label lblMode; - private String mode = null; + private String mode = null; private AppointmentDTO selectedAppointment = null; private Long pendingPetSelectionId = null; - - private ObservableList statusList = - FXCollections.observableArrayList( - "Booked", "Completed", "Missed", "Cancelled" - ); + private boolean isOriginallyCancel = false; + private boolean isOriginallyCompletedOrMissed = false; public void setMode(String mode) { this.mode = mode; lblMode.setText(mode + " Appointment"); lblAppointmentId.setVisible(!mode.equals("Add")); + if (mode.equals("Add")) { + cbAppointmentStatus.setItems(FXCollections.observableArrayList("Booked")); + cbAppointmentStatus.setValue("Booked"); + cbAppointmentStatus.setDisable(true); + } } @FXML public void initialize() { - cbAppointmentStatus.setItems(statusList); + cbAppointmentStatus.setItems(FXCollections.observableArrayList("Booked", "Completed", "Missed", "Cancelled")); cbPet.setDisable(true); cbEmployee.setPromptText("Select an employee"); cbPet.setPromptText("Select a customer first"); @@ -228,6 +230,46 @@ public class AppointmentDialogController { applySelectedService(); applySelectedCustomer(); applySelectedEmployee(); + applyEditModeLock(); + } + + private void applyEditModeLock() { + String status = cbAppointmentStatus.getValue(); + isOriginallyCancel = "Cancelled".equalsIgnoreCase(status); + isOriginallyCompletedOrMissed = "Completed".equalsIgnoreCase(status) || "Missed".equalsIgnoreCase(status); + + if (isOriginallyCancel) { + cbService.setDisable(true); + cbCustomer.setDisable(true); + cbPet.setDisable(true); + cbEmployee.setDisable(true); + cbHour.setDisable(true); + cbMinute.setDisable(true); + dpAppointmentDate.setDisable(true); + cbAppointmentStatus.setDisable(true); + cbAppointmentStatus.setItems(FXCollections.observableArrayList("Cancelled")); + btnSave.setDisable(true); + } else if (isOriginallyCompletedOrMissed) { + cbService.setDisable(true); + cbCustomer.setDisable(true); + cbPet.setDisable(true); + cbEmployee.setDisable(true); + cbHour.setDisable(true); + cbMinute.setDisable(true); + dpAppointmentDate.setDisable(true); + cbAppointmentStatus.setDisable(false); + cbAppointmentStatus.setItems(FXCollections.observableArrayList("Completed", "Missed")); + } else { + cbService.setDisable(true); + cbCustomer.setDisable(true); + cbPet.setDisable(true); + cbEmployee.setDisable(false); + cbHour.setDisable(false); + cbMinute.setDisable(false); + dpAppointmentDate.setDisable(false); + cbAppointmentStatus.setDisable(false); + cbAppointmentStatus.setItems(FXCollections.observableArrayList("Booked", "Cancelled")); + } } private void buttonSaveClicked(MouseEvent e) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/InventoryDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/InventoryDialogController.java index 499b5ae8..54ff00f5 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/InventoryDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/InventoryDialogController.java @@ -121,7 +121,7 @@ public class InventoryDialogController { //Validate inputs errorMsg += Validator.isPresent(txtQuantity.getText(), "Quantity"); errorMsg += Validator.isLessThanVarChars(txtQuantity.getText(), "Quantity", 11); - errorMsg += Validator.isNonNegativeInteger(txtQuantity.getText(), "Quantity"); + errorMsg += Validator.isPositiveInteger(txtQuantity.getText(), "Quantity"); //Operation only occurs if there are no errors if (errorMsg.isEmpty()) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java index bdb46dab..ea4e853a 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/PetDialogController.java @@ -28,65 +28,34 @@ import java.math.BigDecimal; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; public class PetDialogController { - @FXML - private Button btnCancel; + private static final String STATUS_AVAILABLE = "Available"; + private static final String STATUS_ADOPTED = "Adopted"; + private static final String STATUS_OWNED = "Owned"; + private static final String STATUS_PENDING = "Pending"; - @FXML - private Button btnSave; - - @FXML - private Button btnChangeImage; - - @FXML - private Button btnRemoveImage; - - @FXML - private ComboBox cbPetStatus; - - @FXML - private ComboBox cbCustomer; - - @FXML - private ComboBox cbStore; - - @FXML - private VBox vbCustomerField; - - @FXML - private VBox vbStoreField; - - @FXML - private VBox vbPriceField; - - @FXML - private Label lblMode; - - @FXML - private Label lblPetId; - - @FXML - private Label lblImageStatus; - - @FXML - private ImageView imgPetPreview; - - @FXML - private TextField txtPetAge; - - @FXML - private TextField txtPetBreed; - - @FXML - private TextField txtPetName; - - @FXML - private TextField txtPetPrice; - - @FXML - private ComboBox cbPetSpecies; + @FXML private Button btnCancel; + @FXML private Button btnSave; + @FXML private Button btnChangeImage; + @FXML private Button btnRemoveImage; + @FXML private ComboBox cbPetStatus; + @FXML private ComboBox cbPetSpecies; + @FXML private ComboBox cbPetBreed; + @FXML private ComboBox cbCustomer; + @FXML private ComboBox cbStore; + @FXML private VBox vbCustomerField; + @FXML private VBox vbStoreField; + @FXML private VBox vbPriceField; + @FXML private Label lblMode; + @FXML private Label lblPetId; + @FXML private Label lblImageStatus; + @FXML private ImageView imgPetPreview; + @FXML private TextField txtPetAge; + @FXML private TextField txtPetName; + @FXML private TextField txtPetPrice; private String mode = null; private File selectedImageFile; @@ -96,15 +65,17 @@ public class PetDialogController { private Long pendingCustomerId = null; private Long pendingStoreId = null; private Long originalCustomerId = null; + private boolean isOriginallyOwnedOrAdopted = false; + private String pendingBreedValue = null; - private ObservableList statusList = FXCollections.observableArrayList( - "Available", "Adopted", "Owned", "Pending" + private final ObservableList statusList = FXCollections.observableArrayList( + STATUS_AVAILABLE, STATUS_ADOPTED, STATUS_OWNED, STATUS_PENDING ); @FXML void initialize() { - cbPetStatus.setItems(statusList); + cbPetBreed.setDisable(true); cbCustomer.setCellFactory(param -> new ListCell<>() { @Override protected void updateItem(DropdownOption o, boolean empty) { @@ -132,14 +103,26 @@ public class PetDialogController { } }); - setFieldVisibility(vbCustomerField, false); - setFieldVisibility(vbStoreField, false); - setFieldVisibility(vbPriceField, true); + applyStatusRules(STATUS_AVAILABLE, false); loadSpecies(); + loadCustomers(); + loadStores(); + + cbPetSpecies.valueProperty().addListener((obs, oldVal, newVal) -> { + if (newVal != null && !newVal.isBlank()) { + if (!cbPetSpecies.isDisabled()) cbPetBreed.setDisable(false); + loadBreeds(newVal); + } else { + cbPetBreed.setItems(FXCollections.observableArrayList()); + cbPetBreed.setValue(null); + cbPetBreed.setPromptText("Select Species first"); + if (!cbPetSpecies.isDisabled()) cbPetBreed.setDisable(true); + } + }); cbPetStatus.valueProperty().addListener((obs, oldVal, newVal) -> { - updateStatusFieldVisibility(newVal); + if (newVal != null) applyStatusRules(newVal, true); }); btnSave.setOnMouseClicked(new EventHandler() { @@ -159,52 +142,81 @@ public class PetDialogController { btnChangeImage.setOnMouseClicked(mouseEvent -> handleChangeImage()); btnRemoveImage.setOnMouseClicked(mouseEvent -> handleRemoveImage()); refreshImagePreview(); + } - loadCustomers(); - loadStores(); + private void loadBreeds(String species) { + cbPetBreed.setPromptText("Loading breeds..."); + new Thread(() -> { + try { + List options = DropdownApi.getInstance().getPetBreeds(species); + List breeds = options.stream().map(DropdownOption::getLabel).collect(Collectors.toList()); + Platform.runLater(() -> { + cbPetBreed.setItems(FXCollections.observableArrayList(breeds)); + cbPetBreed.setPromptText("Select Breed"); + if (pendingBreedValue != null) { + cbPetBreed.setValue(pendingBreedValue); + pendingBreedValue = null; + } + }); + } catch (Exception e) { + Platform.runLater(() -> { + ActivityLogger.getInstance().logException("PetDialogController.loadBreeds", e, "Loading breeds for species: " + species); + cbPetBreed.setPromptText("Unable to load breeds"); + }); + } + }).start(); + } + + private void applyStatusRules(String status, boolean clearInvalidSelections) { + if (STATUS_AVAILABLE.equalsIgnoreCase(status)) { + vbCustomerField.setDisable(true); + vbStoreField.setDisable(false); + if (clearInvalidSelections) cbCustomer.setValue(null); + return; + } + if (STATUS_OWNED.equalsIgnoreCase(status)) { + vbCustomerField.setDisable(false); + vbStoreField.setDisable(true); + if (clearInvalidSelections) cbStore.setValue(null); + return; + } + vbCustomerField.setDisable(false); + vbStoreField.setDisable(false); } private void buttonSaveClicked(MouseEvent mouseEvent) { String errorMsg = ""; - //Check validation (input required) errorMsg += Validator.isPresent(txtPetName.getText(), "Pet Name"); errorMsg += Validator.isPresent(txtPetAge.getText(), "Age"); - errorMsg += Validator.isPresent(txtPetBreed.getText(), "Breed"); String speciesValue = cbPetSpecies.getValue() != null ? cbPetSpecies.getValue().trim() : ""; if (speciesValue.isEmpty()) errorMsg += "Species is required\n"; + String breedValue = cbPetBreed.getValue() != null ? cbPetBreed.getValue().trim() : ""; + if (breedValue.isEmpty()) errorMsg += "Breed is required\n"; + if (cbPetStatus.getSelectionModel().getSelectedItem() == null) errorMsg += "Status is required\n"; + errorMsg += Validator.isPresent(txtPetPrice.getText(), "Price"); + String selectedStatus = cbPetStatus.getValue(); - boolean needsPrice = !("Owned".equalsIgnoreCase(selectedStatus) || "Adopted".equalsIgnoreCase(selectedStatus)); - if (needsPrice) { - errorMsg += Validator.isPresent(txtPetPrice.getText(), "Price"); + if (STATUS_AVAILABLE.equalsIgnoreCase(selectedStatus) && cbStore.getValue() == null) { + errorMsg += "Store is required for Available status\n"; } - if (cbPetStatus.getSelectionModel().getSelectedItem() == null){ - errorMsg += "Status is required"; - } - if ("Owned".equalsIgnoreCase(selectedStatus) && cbCustomer.getValue() == null && UserSession.getInstance().isAdmin()) { + if (STATUS_OWNED.equalsIgnoreCase(selectedStatus) && cbCustomer.getValue() == null) { errorMsg += "Customer is required for Owned status\n"; } - boolean storeRequired = requiresStore(selectedStatus) && !"Adopted".equalsIgnoreCase(selectedStatus); - if (storeRequired && cbStore.getValue() == null) { - errorMsg += "Store is required for " + selectedStatus + " status\n"; + if (STATUS_ADOPTED.equalsIgnoreCase(selectedStatus)) { + if (cbCustomer.getValue() == null) errorMsg += "Customer is required for Adopted status\n"; + if (cbStore.getValue() == null) errorMsg += "Store is required for Adopted status\n"; } - //Check validation (length size) errorMsg += Validator.isLessThanVarChars(txtPetName.getText(), "Pet Name", 50); errorMsg += Validator.isLessThanVarChars(speciesValue, "Species", 50); - errorMsg += Validator.isLessThanVarChars(txtPetBreed.getText(), "Breed", 50); - if (needsPrice) { - errorMsg += Validator.isLessThanVarChars(txtPetPrice.getText(), "Price", 12); - } + errorMsg += Validator.isLessThanVarChars(breedValue, "Breed", 50); + errorMsg += Validator.isLessThanVarChars(txtPetPrice.getText(), "Price", 12); errorMsg += Validator.isLessThanVarChars(txtPetAge.getText(), "Age", 11); - - //Check validation (format) - if (needsPrice) { - errorMsg += Validator.isNonNegativeDouble(txtPetPrice.getText(), "Price"); - } + errorMsg += Validator.isNonNegativeDouble(txtPetPrice.getText(), "Price"); errorMsg += Validator.isPositiveInteger(txtPetAge.getText(), "Age"); - if(errorMsg.isEmpty()){ + if (errorMsg.isEmpty()) { if ("Edit".equals(mode) && UserSession.getInstance().isAdmin()) { Long newCustomerId = cbCustomer.getValue() != null ? cbCustomer.getValue().getId() : null; if (!Objects.equals(originalCustomerId, newCustomerId)) { @@ -212,44 +224,34 @@ public class PetDialogController { confirm.setHeaderText("Confirm Owner Change"); confirm.setContentText("Are you sure you want to reassign this pet to a different owner?"); Optional result = confirm.showAndWait(); - if (result.isEmpty() || result.get() != ButtonType.OK) { - return; - } + if (result.isEmpty() || result.get() != ButtonType.OK) return; } } PetRequest request = buildPetRequest(); try { - if(mode.equals("Add")) { + if (mode.equals("Add")) { PetResponse response = PetApi.getInstance().createPet(request); applyImageChanges(response.getPetId()); } else { String[] parts = lblPetId.getText().split(": "); - if (parts.length < 2) { - throw new IllegalStateException("Invalid pet ID format"); - } + if (parts.length < 2) throw new IllegalStateException("Invalid pet ID format"); Long petId = Long.parseLong(parts[1]); PetApi.getInstance().updatePet(petId, request); applyImageChanges(petId); } - - //tell the user operation was successful Alert alert = new Alert(Alert.AlertType.INFORMATION); alert.setHeaderText("Saved"); alert.setContentText(mode + " succeeded"); alert.showAndWait(); closeStage(mouseEvent); } catch (Exception e) { - ActivityLogger.getInstance().logException( - "PetDialogController.buttonSaveClicked", - e, - mode + " pet record"); + ActivityLogger.getInstance().logException("PetDialogController.buttonSaveClicked", e, mode + " pet record"); Alert alert = new Alert(Alert.AlertType.ERROR); alert.setHeaderText("Operation Error"); alert.setContentText(mode + " failed: " + e.getMessage()); alert.showAndWait(); } - } - else{ + } else { Alert alert = new Alert(Alert.AlertType.ERROR); alert.setHeaderText("Input Error"); alert.setContentText(errorMsg); @@ -261,11 +263,10 @@ public class PetDialogController { PetRequest request = new PetRequest(); request.setPetName(txtPetName.getText()); request.setPetSpecies(cbPetSpecies.getValue() != null ? cbPetSpecies.getValue().trim() : ""); - request.setPetBreed(txtPetBreed.getText()); + request.setPetBreed(cbPetBreed.getValue() != null ? cbPetBreed.getValue().trim() : ""); request.setPetStatus(cbPetStatus.getValue()); - String buildStatus = cbPetStatus.getValue(); - boolean buildNeedsPrice = !("Owned".equalsIgnoreCase(buildStatus) || "Adopted".equalsIgnoreCase(buildStatus)); - if (buildNeedsPrice && txtPetPrice.getText() != null && !txtPetPrice.getText().isBlank()) { + + if (txtPetPrice.getText() != null && !txtPetPrice.getText().isBlank()) { try { request.setPetPrice(new BigDecimal(txtPetPrice.getText())); } catch (NumberFormatException e) { @@ -273,21 +274,14 @@ public class PetDialogController { } } - int age; try { - age = Integer.parseInt(txtPetAge.getText()); + request.setPetAge(Integer.parseInt(txtPetAge.getText())); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid age format"); } - request.setPetAge(age); - String status = cbPetStatus.getValue(); - if (("Owned".equalsIgnoreCase(status) || "Adopted".equalsIgnoreCase(status)) && cbCustomer.getValue() != null) { - request.setCustomerId(cbCustomer.getValue().getId()); - } - if (requiresStore(status) && cbStore.getValue() != null) { - request.setStoreId(cbStore.getValue().getId()); - } + if (cbCustomer.getValue() != null) request.setCustomerId(cbCustomer.getValue().getId()); + if (cbStore.getValue() != null) request.setStoreId(cbStore.getValue().getId()); return request; } @@ -296,15 +290,11 @@ public class PetDialogController { new Thread(() -> { try { List options = DropdownApi.getInstance().getPetSpecies(); - List species = options.stream() - .map(DropdownOption::getLabel) - .collect(java.util.stream.Collectors.toList()); + List species = options.stream().map(DropdownOption::getLabel).collect(Collectors.toList()); Platform.runLater(() -> { String current = cbPetSpecies.getValue(); cbPetSpecies.setItems(FXCollections.observableArrayList(species)); - if (current != null && !current.isBlank()) { - cbPetSpecies.setValue(current); - } + if (current != null && !current.isBlank()) cbPetSpecies.setValue(current); }); } catch (Exception e) { Platform.runLater(() -> ActivityLogger.getInstance().logException( @@ -323,8 +313,7 @@ public class PetDialogController { }); } catch (Exception e) { Platform.runLater(() -> { - ActivityLogger.getInstance().logException( - "PetDialogController.loadCustomers", e, "Loading customers"); + ActivityLogger.getInstance().logException("PetDialogController.loadCustomers", e, "Loading customers"); cbCustomer.setDisable(true); cbCustomer.setPromptText("Unable to load customers"); }); @@ -342,8 +331,7 @@ public class PetDialogController { }); } catch (Exception e) { Platform.runLater(() -> { - ActivityLogger.getInstance().logException( - "PetDialogController.loadStores", e, "Loading stores"); + ActivityLogger.getInstance().logException("PetDialogController.loadStores", e, "Loading stores"); cbStore.setDisable(true); cbStore.setPromptText("Unable to load stores"); }); @@ -383,55 +371,78 @@ public class PetDialogController { stage.close(); } - public void displayPetDetails(Pet pet){ - if (pet!=null){ - lblPetId.setText("ID: " + pet.getPetId()); - txtPetName.setText(pet.getPetName()); - cbPetSpecies.setValue(pet.getPetSpecies()); - txtPetBreed.setText(pet.getPetBreed()); - txtPetAge.setText(pet.getPetAge() + ""); - txtPetPrice.setText(pet.getPetPrice() + ""); - currentImageUrl = pet.getImageUrl(); - selectedImageFile = null; - removeImageRequested = false; - refreshImagePreview(); + public void displayPetDetails(Pet pet) { + if (pet == null) return; - pendingCustomerId = pet.getCustomerId() > 0 ? pet.getCustomerId() : null; - originalCustomerId = pendingCustomerId; - pendingStoreId = pet.getStoreId() > 0 ? pet.getStoreId() : null; + lblPetId.setText("ID: " + pet.getPetId()); + txtPetName.setText(pet.getPetName()); + txtPetAge.setText(String.valueOf(pet.getPetAge())); + txtPetPrice.setText(String.valueOf(pet.getPetPrice())); + currentImageUrl = pet.getImageUrl(); + selectedImageFile = null; + removeImageRequested = false; + refreshImagePreview(); - for (String status : cbPetStatus.getItems()) { - if(status.equals(pet.getPetStatus())){ - cbPetStatus.getSelectionModel().select(status); - break; - } + pendingCustomerId = pet.getCustomerId() > 0 ? pet.getCustomerId() : null; + originalCustomerId = pendingCustomerId; + pendingStoreId = pet.getStoreId() > 0 ? pet.getStoreId() : null; + + isOriginallyOwnedOrAdopted = STATUS_OWNED.equalsIgnoreCase(pet.getPetStatus()) + || STATUS_ADOPTED.equalsIgnoreCase(pet.getPetStatus()); + + pendingBreedValue = pet.getPetBreed(); + cbPetSpecies.setValue(pet.getPetSpecies()); + + for (String status : cbPetStatus.getItems()) { + if (status.equals(pet.getPetStatus())) { + cbPetStatus.getSelectionModel().select(status); + break; } - updateStatusFieldVisibility(cbPetStatus.getValue()); + } + + applyStatusRules(cbPetStatus.getValue(), false); + applyEditModeLock(); + } + + private void applyEditModeLock() { + cbPetSpecies.setDisable(true); + cbPetBreed.setDisable(true); + + boolean isStaff = !UserSession.getInstance().isAdmin(); + if (isStaff && isOriginallyOwnedOrAdopted) { + cbPetStatus.setDisable(true); + vbCustomerField.setDisable(true); + vbStoreField.setDisable(true); } } public void setMode(String mode) { this.mode = mode; lblMode.setText(mode + " Pet"); - if(mode.equals("Add")) { + + if (mode.equals("Add")) { lblPetId.setVisible(false); currentImageUrl = null; selectedImageFile = null; removeImageRequested = false; + cbPetSpecies.setDisable(false); + cbPetBreed.setDisable(true); + cbPetBreed.setItems(FXCollections.observableArrayList()); + cbPetBreed.setValue(null); + cbPetBreed.setPromptText("Select Species first"); + cbPetStatus.setDisable(false); + cbPetStatus.getSelectionModel().select(STATUS_AVAILABLE); + applyStatusRules(STATUS_AVAILABLE, false); refreshImagePreview(); - } - else if(mode.equals("Edit")) { + } else if (mode.equals("Edit")) { lblPetId.setVisible(true); refreshImagePreview(); } - updateStatusFieldVisibility(cbPetStatus.getValue()); } private void handleChangeImage() { File file = FilePickerSupport.pickImageFile(btnSave.getScene().getWindow()); - if (file == null) { - return; - } + if (file == null) return; selectedImageFile = file; removeImageRequested = false; lblImageStatus.setText("Selected: " + file.getName()); @@ -468,9 +479,7 @@ public class PetDialogController { } private void refreshImagePreview() { - if (imgPetPreview == null || lblImageStatus == null || btnRemoveImage == null) { - return; - } + if (imgPetPreview == null || lblImageStatus == null || btnRemoveImage == null) return; imgPetPreview.setImage(null); if (selectedImageFile != null) { lblImageStatus.setText("Selected: " + selectedImageFile.getName()); @@ -487,30 +496,4 @@ public class PetDialogController { lblImageStatus.setText("No image selected"); btnRemoveImage.setDisable(true); } - - private void updateStatusFieldVisibility(String status) { - boolean statusNeedsCustomer = "Owned".equalsIgnoreCase(status) || "Adopted".equalsIgnoreCase(status); - boolean needsCustomer = statusNeedsCustomer && UserSession.getInstance().isAdmin(); - boolean storeBased = requiresStore(status); - boolean needsPrice = !statusNeedsCustomer; - setFieldVisibility(vbCustomerField, needsCustomer); - setFieldVisibility(vbStoreField, storeBased); - setFieldVisibility(vbPriceField, needsPrice); - } - - private boolean requiresStore(String status) { - return "Available".equalsIgnoreCase(status) - || "Pending".equalsIgnoreCase(status) - || "Unadopted".equalsIgnoreCase(status) - || "Adopted".equalsIgnoreCase(status); - } - - private void setFieldVisibility(VBox field, boolean visible) { - if (field == null) { - return; - } - field.setVisible(visible); - field.setManaged(visible); - } - } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SaleDetailDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SaleDetailDialogController.java index abeb3355..6cd3bc28 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SaleDetailDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SaleDetailDialogController.java @@ -13,6 +13,7 @@ import javafx.fxml.FXMLLoader; import javafx.scene.Scene; import javafx.stage.Modality; import org.example.petshopdesktop.util.ActivityLogger; +import org.example.petshopdesktop.auth.UserSession; import java.util.function.Consumer; @@ -46,6 +47,12 @@ public class SaleDetailDialogController { colQuantity.setCellValueFactory(new PropertyValueFactory<>("quantity")); colUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice")); colLineTotal.setCellValueFactory(new PropertyValueFactory<>("total")); + + if (btnRefund != null) { + boolean isAdmin = UserSession.getInstance().isAdmin(); + btnRefund.setVisible(!isAdmin); + btnRefund.setManaged(!isAdmin); + } } public void displaySaleDetails(SaleDetail sale) { @@ -57,7 +64,10 @@ public class SaleDetailDialogController { lblTotal.setText(currency.format(sale.getTotalAmount())); tvItems.setItems(sale.getItems()); if (btnRefund != null) { - btnRefund.setDisable(sale.isRefund()); + boolean isAdmin = UserSession.getInstance().isAdmin(); + if (!isAdmin) { + btnRefund.setDisable(sale.isRefund()); + } } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffEditDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffEditDialogController.java index 8b88e98f..45dd6dae 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffEditDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffEditDialogController.java @@ -15,6 +15,7 @@ import org.example.petshopdesktop.api.endpoints.UserApi; 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 StaffEditDialogController { @@ -47,6 +48,11 @@ public class StaffEditDialogController { private UserResponse user; + @FXML + void initialize() { + TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone); + } + public void setUser(UserResponse user) { this.user = user; String fullName = user.getFullName() == null ? "" : user.getFullName(); diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffRegisterDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffRegisterDialogController.java index 03b7a241..91208a70 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffRegisterDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/StaffRegisterDialogController.java @@ -15,6 +15,7 @@ import org.example.petshopdesktop.api.endpoints.CustomerApi; import org.example.petshopdesktop.auth.UserSession; import org.example.petshopdesktop.Validator; import org.example.petshopdesktop.util.ActivityLogger; +import org.example.petshopdesktop.util.TextFieldFormatSupport; public class StaffRegisterDialogController { @@ -45,6 +46,11 @@ public class StaffRegisterDialogController { @FXML private Button btnCreate; + @FXML + void initialize() { + TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone); + } + @FXML void btnCreateClicked(ActionEvent event) { lblError.setText(""); @@ -82,6 +88,10 @@ public class StaffRegisterDialogController { lblError.setText("Password is required."); return; } + if (password.length() < 6) { + lblError.setText("Password must be at least 6 characters."); + return; + } if (!password.equals(confirm)) { lblError.setText("Passwords do not match."); return; diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SupplierDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SupplierDialogController.java index 8b61daca..44131a6c 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SupplierDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/SupplierDialogController.java @@ -15,6 +15,7 @@ import org.example.petshopdesktop.api.dto.supplier.SupplierResponse; import org.example.petshopdesktop.api.endpoints.SupplierApi; import org.example.petshopdesktop.models.Supplier; import org.example.petshopdesktop.util.ActivityLogger; +import org.example.petshopdesktop.util.TextFieldFormatSupport; public class SupplierDialogController { @@ -52,6 +53,8 @@ public class SupplierDialogController { */ @FXML void initialize() { + TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone); + //Set up mouse handlers for buttons btnSave.setOnMouseClicked(new EventHandler() { @Override diff --git a/desktop/src/main/java/org/example/petshopdesktop/util/TextFieldFormatSupport.java b/desktop/src/main/java/org/example/petshopdesktop/util/TextFieldFormatSupport.java new file mode 100644 index 00000000..7dba0d9f --- /dev/null +++ b/desktop/src/main/java/org/example/petshopdesktop/util/TextFieldFormatSupport.java @@ -0,0 +1,55 @@ +package org.example.petshopdesktop.util; + +import javafx.application.Platform; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; + +import java.util.function.UnaryOperator; + +public class TextFieldFormatSupport { + + /** + * Applies a phone number formatter to a TextField. + * The formatter only allows digits and automatically formats the input as (XXX) XXX-XXXX. + * + * @param textField The TextField to apply the formatter to. + */ + public static void applyPhoneNumberFormat(TextField textField) { + textField.textProperty().addListener((observable, oldValue, newValue) -> { + if (newValue == null) return; + + // Remove all non-digit characters + String digits = newValue.replaceAll("\\D", ""); + + // Limit to 10 digits + if (digits.length() > 10) { + digits = digits.substring(0, 10); + } + + StringBuilder formatted = new StringBuilder(); + int len = digits.length(); + + if (len > 0) { + formatted.append("("); + if (len <= 3) { + formatted.append(digits); + } else { + formatted.append(digits, 0, 3).append(") "); + if (len <= 6) { + formatted.append(digits.substring(3)); + } else { + formatted.append(digits, 3, 6).append("-").append(digits.substring(6)); + } + } + } + + String result = formatted.toString(); + if (!result.equals(newValue)) { + Platform.runLater(() -> { + textField.setText(result); + textField.positionCaret(result.length()); + }); + } + }); + } +} diff --git a/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml index 6f8bf1e0..cb92bec5 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/dialogviews/pet-dialog-view.fxml @@ -91,7 +91,7 @@ - + @@ -105,11 +105,11 @@ - + - + - + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml index a9478744..7192ea69 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/main-layout-view.fxml @@ -176,6 +176,14 @@ + @@ -220,7 +228,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml index 63569aa2..2138c2ca 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml @@ -48,7 +48,7 @@ - + - + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/staff-accounts-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/staff-accounts-view.fxml index 7aab7ad4..373537f3 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/staff-accounts-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/staff-accounts-view.fxml @@ -11,7 +11,7 @@ - + @@ -19,28 +19,12 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - +