Merge branch 'AttachmentsToChat'

This commit is contained in:
Alex
2026-04-13 22:36:53 -06:00
53 changed files with 1285 additions and 390 deletions

View File

@@ -103,11 +103,21 @@ public class PetAdapter extends RecyclerView.Adapter<PetAdapter.PetViewHolder> 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

View File

@@ -50,6 +50,9 @@ public interface PetApi {
@GET("api/v1/dropdowns/pet-species")
Call<List<DropdownDTO>> getPetSpeciesDropdowns();
@GET("api/v1/dropdowns/pet-breeds")
Call<List<DropdownDTO>> getPetBreedsDropdowns(@Query("species") String species);
// Get pet by id
@GET("api/v1/pets/{id}")
Call<PetDTO> getPetById(@Path("id") Long id);

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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) {

View File

@@ -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<DropdownDTO> 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<DropdownDTO> 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);
}

View File

@@ -302,11 +302,7 @@ public class RefundFragment extends Fragment {
}
private void updateRefundTotal() {
BigDecimal total = BigDecimal.ZERO;
List<RefundViewModel.RefundItem> 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()

View File

@@ -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);

View File

@@ -161,7 +161,7 @@ public class StaffDetailFragment extends Fragment {
List<DropdownDTO> 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,

View File

@@ -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());

View File

@@ -58,6 +58,10 @@ public class PetRepository extends BaseRepository {
return executeCall(petApi.getPetSpeciesDropdowns());
}
public LiveData<Resource<List<DropdownDTO>>> getPetBreedsDropdowns(String species) {
return executeCall(petApi.getPetBreedsDropdowns(species));
}
/**
* Retrieves available pets for a specific store.
*/

View File

@@ -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;
}

View File

@@ -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<Resource<CustomerDTO>> loadCustomer(long id) {
return repository.getCustomerById(id);
}

View File

@@ -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<List<DropdownDTO>> customerList = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<DropdownDTO>> storeList = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<DropdownDTO>> speciesList = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<DropdownDTO>> breedList = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private final MutableLiveData<ViewState> 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<DropdownDTO> 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<DropdownDTO> 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<List<DropdownDTO>> getBreedList() {
return breedList;
}
public LiveData<Boolean> 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;
}

View File

@@ -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<RefundItem> 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<Resource<SaleDTO>> submitRefund(String paymentMethod) {
SaleDTO sale = currentSale.getValue();
List<RefundItem> cart = refundCart.getValue();

View File

@@ -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<List<ProductDTO>> productList = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<SaleDTO.SaleItemDTO>> cartItems = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<CouponDTO> appliedCoupon = new MutableLiveData<>(null);
private final MutableLiveData<CustomerDTO> selectedCustomerData = new MutableLiveData<>(null);
private final MutableLiveData<Boolean> useLoyaltyPoints = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> 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<Boolean> getUseLoyaltyPoints() {
return useLoyaltyPoints;
}
public LiveData<CustomerDTO> 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<Resource<CustomerDTO>>() {
@Override
public void onChanged(Resource<CustomerDTO> 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<SaleDTO.SaleItemDTO> items = cartItems.getValue();

View File

@@ -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<Resource<EmployeeDTO>> saveEmployee(EmployeeDTO dto) {
if (isEditing && employeeId > 0) {
return repository.updateEmployee(employeeId, dto);

View File

@@ -195,14 +195,13 @@
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<TextView
android:id="@+id/tvCustomerLoyaltyPoints"
<EditText
android:id="@+id/etCustomerLoyaltyPoints"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0"
android:textColor="@color/text_dark"
android:textSize="16sp"
android:layout_marginBottom="8dp"/>
android:hint="0"
android:inputType="number"
android:layout_marginBottom="16dp"/>
</LinearLayout>

View File

@@ -109,14 +109,11 @@
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<EditText
android:id="@+id/etPetBreed"
<Spinner
android:id="@+id/spinnerPetBreed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter breed"
android:inputType="text"
android:layout_marginBottom="16dp"
android:textColor="@color/text_dark"/>
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"

View File

@@ -128,7 +128,30 @@
android:id="@+id/spinnerSaleCustomer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"/>
android:layout_marginBottom="8dp"/>
<LinearLayout
android:id="@+id/llLoyaltyPoints"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp"
android:visibility="gone">
<CheckBox
android:id="@+id/cbUseLoyaltyPoints"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Use Loyalty Points"/>
<TextView
android:id="@+id/tvAvailablePoints"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@color/text_light"
android:textSize="12sp"
android:text="(Available: 0)"/>
</LinearLayout>
<!-- Payment Method -->
<TextView
@@ -390,6 +413,27 @@
android:textColor="@color/status_adopted"/>
</LinearLayout>
<LinearLayout
android:id="@+id/llLoyaltyDiscount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp"
android:visibility="gone">
<TextView
android:id="@+id/tvLoyaltyDiscountLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Loyalty Discount:"/>
<TextView
android:id="@+id/tvSaleLoyaltyDiscount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="-$0.00"
android:textColor="@color/status_adopted"/>
</LinearLayout>
<TextView
android:id="@+id/tvSaleDetailTotal"
android:layout_width="wrap_content"

View File

@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Objects;
import java.math.BigDecimal;
public class SaleRequest {
@NotNull(message = "Store ID is required")
@@ -28,7 +29,9 @@ public class SaleRequest {
private Long cartId;
private Boolean useLoyaltyPoints = false;
private Integer pointsUsed;
private BigDecimal pointsDiscountAmount;
public Long getStoreId() {
return storeId;
@@ -102,12 +105,20 @@ public class SaleRequest {
this.cartId = cartId;
}
public Boolean getUseLoyaltyPoints() {
return useLoyaltyPoints;
public Integer getPointsUsed() {
return pointsUsed;
}
public void setUseLoyaltyPoints(Boolean useLoyaltyPoints) {
this.useLoyaltyPoints = useLoyaltyPoints;
public void setPointsUsed(Integer pointsUsed) {
this.pointsUsed = pointsUsed;
}
public BigDecimal getPointsDiscountAmount() {
return pointsDiscountAmount;
}
public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) {
this.pointsDiscountAmount = pointsDiscountAmount;
}
@Override

View File

@@ -21,6 +21,8 @@ public class SaleResponse {
private BigDecimal loyaltyDiscountAmount;
private Integer pointsUsed;
private Integer pointsEarned;
private Integer pointsUsed;
private BigDecimal pointsDiscountAmount;
private String channel;
private Long couponId;
private Long cartId;
@@ -153,6 +155,22 @@ public class SaleResponse {
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 String getChannel() {
return channel;
}

View File

@@ -39,6 +39,8 @@ public class UserRequest {
private Boolean active = true;
private Integer loyaltyPoints;
public String getUsername() {
return username;
}
@@ -127,6 +129,14 @@ public class UserRequest {
this.active = active;
}
public Integer getLoyaltyPoints() {
return loyaltyPoints;
}
public void setLoyaltyPoints(Integer loyaltyPoints) {
this.loyaltyPoints = loyaltyPoints;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@@ -75,6 +75,12 @@ public class Sale {
@Column(nullable = false)
private Integer pointsEarned = 0;
@Column(nullable = false)
private Integer pointsUsed = 0;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal pointsDiscountAmount = BigDecimal.ZERO;
@OneToMany(mappedBy = "sale", cascade = CascadeType.ALL)
private List<SaleItem> 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<SaleItem> getItems() {
return items;
}

View File

@@ -19,7 +19,7 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
"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<Pet> findAdoptablePetsOrderByPetNameAsc();
@@ -29,7 +29,7 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
"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<Pet> findAdoptablePetsByStore(@Param("storeId") Long storeId);

View File

@@ -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);

View File

@@ -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());

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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),

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -42,6 +42,15 @@ public class DropdownApi {
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {});
}
public List<DropdownOption> 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<List<DropdownOption>>() {});
}
public List<DropdownOption> getProducts() throws Exception {
String response = apiClient.getRawResponse("/api/v1/dropdowns/products");
if (response == null || response.isEmpty()) {

View File

@@ -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<UserResponse> tvCustomers;
@FXML
private TableColumn<UserResponse, String> colCustomerUsername;
@FXML
private TableColumn<UserResponse, String> colCustomerName;
@FXML
private TableColumn<UserResponse, String> colCustomerEmail;
@FXML
private TableColumn<UserResponse, String> colCustomerPhone;
@FXML
private TableColumn<UserResponse, Object> colCustomerLoyaltyPoints;
@FXML
private TableColumn<UserResponse, String> colCustomerStatus;
@FXML
private TableColumn<UserResponse, Object> colCustomerCreated;
@FXML
private TextField txtSearchCustomer;
@FXML
private Button btnEditCustomer;
@FXML
private Button btnRefresh;
@FXML
private Label lblError;
@FXML
private Label lblStatus;
private final ObservableList<UserResponse> customerAccounts = FXCollections.observableArrayList();
private FilteredList<UserResponse> 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<UserResponse> byCreated = Comparator.comparing(
UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
List<UserResponse> 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();
}
}

View File

@@ -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,

View File

@@ -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)");
}

View File

@@ -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<UserResponse> 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<UserResponse> staffAccounts = FXCollections.observableArrayList();
private FilteredList<UserResponse> filtered;
private FilteredList<UserResponse> 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<UserResponse> users;
if (session.isAdmin()) {
users = UserApi.getInstance().listUsers(null);
} else {
users = CustomerApi.getInstance().listCustomers(null);
}
Comparator<UserResponse> byCreated = Comparator.comparing(
UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
List<UserResponse> sortedUsers = users.stream()
.sorted(Comparator.comparing(UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.collect(Collectors.toList());
List<UserResponse> 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)
);
}

View File

@@ -62,7 +62,7 @@ public class AdoptionDialogController {
private boolean suppressPaymentDialog = false;
private ObservableList<String> 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() {

View File

@@ -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<String> 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) {

View File

@@ -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()) {

View File

@@ -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<String> cbPetStatus;
@FXML
private ComboBox<DropdownOption> cbCustomer;
@FXML
private ComboBox<DropdownOption> 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<String> cbPetSpecies;
@FXML private Button btnCancel;
@FXML private Button btnSave;
@FXML private Button btnChangeImage;
@FXML private Button btnRemoveImage;
@FXML private ComboBox<String> cbPetStatus;
@FXML private ComboBox<String> cbPetSpecies;
@FXML private ComboBox<String> cbPetBreed;
@FXML private ComboBox<DropdownOption> cbCustomer;
@FXML private ComboBox<DropdownOption> 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<String> statusList = FXCollections.observableArrayList(
"Available", "Adopted", "Owned", "Pending"
private final ObservableList<String> 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<MouseEvent>() {
@@ -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<DropdownOption> options = DropdownApi.getInstance().getPetBreeds(species);
List<String> 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<ButtonType> 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<DropdownOption> options = DropdownApi.getInstance().getPetSpecies();
List<String> species = options.stream()
.map(DropdownOption::getLabel)
.collect(java.util.stream.Collectors.toList());
List<String> 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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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<MouseEvent>() {
@Override

View File

@@ -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());
});
}
});
}
}

View File

@@ -91,7 +91,7 @@
<Font name="System Bold" size="16.0" />
</font>
</Label>
<ComboBox fx:id="cbPetSpecies" editable="true" prefHeight="29.0" prefWidth="336.0" promptText="Select or enter species" style="-fx-border-color: #E8EBED; -fx-border-width: 2; -fx-border-radius: 10; -fx-background-radius: 10; -fx-background-color: white;">
<ComboBox fx:id="cbPetSpecies" prefHeight="29.0" prefWidth="336.0" promptText="Select Species" style="-fx-border-color: #E8EBED; -fx-border-width: 2; -fx-border-radius: 10; -fx-background-radius: 10; -fx-background-color: white;">
<padding>
<Insets bottom="3.0" left="10.0" right="10.0" top="3.0" />
</padding>
@@ -105,11 +105,11 @@
<Font name="System Bold" size="16.0" />
</font>
</Label>
<TextField fx:id="txtPetBreed" style="-fx-border-color: #E8EBED; -fx-border-width: 2; -fx-border-radius: 10; -fx-background-radius: 10;">
<ComboBox fx:id="cbPetBreed" prefHeight="29.0" prefWidth="336.0" promptText="Select Species first" style="-fx-border-color: #E8EBED; -fx-border-width: 2; -fx-border-radius: 10; -fx-background-radius: 10; -fx-background-color: white;">
<padding>
<Insets bottom="7.0" left="10.0" right="10.0" top="7.0" />
<Insets bottom="3.0" left="10.0" right="10.0" top="3.0" />
</padding>
</TextField>
</ComboBox>
</children>
</VBox>
<VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0" GridPane.columnIndex="1" GridPane.rowIndex="1">

View File

@@ -176,6 +176,14 @@
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<Button fx:id="btnCustomers" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnCustomersClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Customers" textFill="#cbd5e1">
<font>
<Font name="System" size="12.0" />
</font>
<padding>
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<Separator fx:id="separatorAdmin" prefWidth="200.0" style="-fx-background-color: #444444; -fx-opacity: 0.35;" />
@@ -220,7 +228,7 @@
<Insets bottom="8.0" left="10.0" right="10.0" top="8.0" />
</padding>
</Button>
<Button fx:id="btnStaffAccounts" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnStaffAccountsClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="User Accounts" textFill="#cbd5e1">
<Button fx:id="btnStaffAccounts" alignment="CENTER_LEFT" maxWidth="Infinity" mnemonicParsing="false" onAction="#btnStaffAccountsClicked" style="-fx-background-color: transparent; -fx-background-radius: 8; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;" text="Staff Accounts" textFill="#cbd5e1">
<font>
<Font name="System" size="12.0" />
</font>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?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.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Font?>
<VBox spacing="16.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.CustomerAccountsController">
<padding>
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
</padding>
<children>
<HBox alignment="CENTER_LEFT" spacing="20.0">
<children>
<Label text="Customers" textFill="#2c3e50">
<font>
<Font name="System Bold" size="30.0" />
</font>
</Label>
<Region HBox.hgrow="ALWAYS" />
<Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefreshClicked" prefHeight="44.0" prefWidth="118.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Refresh" textFill="WHITE">
<font>
<Font name="System Bold" size="14.0" />
</font>
<padding>
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
</padding>
</Button>
</children>
</HBox>
<VBox spacing="10.0" VBox.vgrow="ALWAYS">
<children>
<HBox alignment="CENTER_LEFT" spacing="12.0">
<children>
<Region HBox.hgrow="ALWAYS" />
<Button fx:id="btnEditCustomer" mnemonicParsing="false" onAction="#btnEditCustomerClicked" prefHeight="40.0" style="-fx-background-color: #F4A261; -fx-cursor: hand; -fx-background-radius: 8;" text="Edit Customer" textFill="WHITE">
<font>
<Font name="System Bold" size="14.0" />
</font>
<padding>
<Insets bottom="10.0" left="20.0" right="20.0" top="10.0" />
</padding>
</Button>
</children>
</HBox>
<HBox alignment="CENTER_LEFT" spacing="10.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 1; -fx-border-radius: 14; -fx-border-color: #e6e6e6;">
<padding>
<Insets bottom="10.0" left="15.0" right="15.0" top="10.0" />
</padding>
<children>
<TextField fx:id="txtSearchCustomer" promptText="Search customers..." style="-fx-border-width: 0; -fx-background-color: transparent;" HBox.hgrow="ALWAYS">
<font>
<Font size="15.0" />
</font>
</TextField>
</children>
</HBox>
<TableView fx:id="tvCustomers" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="colCustomerUsername" prefWidth="130.0" text="Username" />
<TableColumn fx:id="colCustomerName" prefWidth="160.0" text="Name" />
<TableColumn fx:id="colCustomerEmail" prefWidth="200.0" text="Email" />
<TableColumn fx:id="colCustomerPhone" prefWidth="130.0" text="Phone" />
<TableColumn fx:id="colCustomerLoyaltyPoints" prefWidth="120.0" text="Loyalty Points" />
<TableColumn fx:id="colCustomerStatus" prefWidth="90.0" text="Status" />
<TableColumn fx:id="colCustomerCreated" prefWidth="150.0" text="Created" />
</columns>
</TableView>
</children>
</VBox>
<Label fx:id="lblStatus" text="" textFill="#64748b" visible="false" managed="true">
<font>
<Font size="13.0" />
</font>
</Label>
<Label fx:id="lblError" text="" textFill="#FF6B6B" wrapText="true" />
</children>
</VBox>

View File

@@ -48,7 +48,7 @@
</HBox>
</children>
</VBox>
<FlowPane hgap="8.0" maxWidth="Infinity" prefWrapLength="260.0" vgap="8.0">
<HBox alignment="CENTER_RIGHT" spacing="8.0">
<children>
<Button fx:id="btnRefund" mnemonicParsing="false" onAction="#btnRefund" prefHeight="32.0" style="-fx-background-color: #FF6b6b; -fx-cursor: hand; -fx-background-radius: 8;" text="Process Refund" textFill="WHITE">
<font>
@@ -67,7 +67,7 @@
</padding>
</Button>
</children>
</FlowPane>
</HBox>
</children>
</HBox>

View File

@@ -11,7 +11,7 @@
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Font?>
<VBox spacing="20.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.StaffAccountsController">
<VBox spacing="16.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.StaffAccountsController">
<padding>
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
</padding>
@@ -19,28 +19,12 @@
<children>
<HBox alignment="CENTER_LEFT" spacing="20.0">
<children>
<Label text="User Accounts" textFill="#2c3e50">
<Label text="Staff Accounts" textFill="#2c3e50">
<font>
<Font name="System Bold" size="30.0" />
</font>
</Label>
<Region HBox.hgrow="ALWAYS" />
<Button fx:id="btnCreateAccount" mnemonicParsing="false" onAction="#btnCreateAccountClicked" prefHeight="44.0" style="-fx-background-color: #FF6B6B; -fx-cursor: hand; -fx-background-radius: 8;" text="Create Account" textFill="WHITE">
<font>
<Font name="System Bold" size="14.0" />
</font>
<padding>
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
</padding>
</Button>
<Button fx:id="btnEditAccount" mnemonicParsing="false" onAction="#btnEditAccountClicked" prefHeight="44.0" style="-fx-background-color: #F4A261; -fx-cursor: hand; -fx-background-radius: 8;" text="Edit Account" textFill="WHITE">
<font>
<Font name="System Bold" size="14.0" />
</font>
<padding>
<Insets bottom="12.0" left="24.0" right="24.0" top="12.0" />
</padding>
</Button>
<Button fx:id="btnRefresh" mnemonicParsing="false" onAction="#btnRefreshClicked" prefHeight="44.0" prefWidth="118.0" style="-fx-background-color: #4ECDC4; -fx-cursor: hand; -fx-background-radius: 8;" text="Refresh" textFill="WHITE">
<font>
<Font name="System Bold" size="14.0" />
@@ -52,31 +36,56 @@
</children>
</HBox>
<HBox alignment="CENTER_LEFT" spacing="10.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 1; -fx-border-radius: 14; -fx-border-color: #e6e6e6;">
<padding>
<Insets bottom="10.0" left="15.0" right="15.0" top="10.0" />
</padding>
<VBox fx:id="staffSection" spacing="10.0" VBox.vgrow="ALWAYS">
<children>
<TextField fx:id="txtSearch" promptText="Search users..." style="-fx-border-width: 0; -fx-background-color: transparent;" HBox.hgrow="ALWAYS">
<font>
<Font size="15.0" />
</font>
</TextField>
<HBox alignment="CENTER_LEFT" spacing="12.0">
<children>
<Region HBox.hgrow="ALWAYS" />
<Button fx:id="btnCreateAccount" mnemonicParsing="false" onAction="#btnCreateAccountClicked" prefHeight="40.0" style="-fx-background-color: #FF6B6B; -fx-cursor: hand; -fx-background-radius: 8;" text="Create Account" textFill="WHITE">
<font>
<Font name="System Bold" size="14.0" />
</font>
<padding>
<Insets bottom="10.0" left="20.0" right="20.0" top="10.0" />
</padding>
</Button>
<Button fx:id="btnEditAccount" mnemonicParsing="false" onAction="#btnEditAccountClicked" prefHeight="40.0" style="-fx-background-color: #F4A261; -fx-cursor: hand; -fx-background-radius: 8;" text="Edit Account" textFill="WHITE">
<font>
<Font name="System Bold" size="14.0" />
</font>
<padding>
<Insets bottom="10.0" left="20.0" right="20.0" top="10.0" />
</padding>
</Button>
</children>
</HBox>
<HBox alignment="CENTER_LEFT" spacing="10.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 1; -fx-border-radius: 14; -fx-border-color: #e6e6e6;">
<padding>
<Insets bottom="10.0" left="15.0" right="15.0" top="10.0" />
</padding>
<children>
<TextField fx:id="txtSearch" promptText="Search staff..." style="-fx-border-width: 0; -fx-background-color: transparent;" HBox.hgrow="ALWAYS">
<font>
<Font size="15.0" />
</font>
</TextField>
</children>
</HBox>
<TableView fx:id="tvStaff" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="colUsername" prefWidth="140.0" text="Username" />
<TableColumn fx:id="colName" prefWidth="170.0" text="Name" />
<TableColumn fx:id="colEmail" prefWidth="210.0" text="Email" />
<TableColumn fx:id="colPhone" prefWidth="130.0" text="Phone" />
<TableColumn fx:id="colRole" prefWidth="100.0" text="Role" />
<TableColumn fx:id="colStatus" prefWidth="90.0" text="Status" />
<TableColumn fx:id="colCreated" prefWidth="150.0" text="Created" />
</columns>
</TableView>
</children>
</HBox>
<TableView fx:id="tvStaff" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="colUsername" prefWidth="140.0" text="Username" />
<TableColumn fx:id="colName" prefWidth="170.0" text="Name" />
<TableColumn fx:id="colEmail" prefWidth="210.0" text="Email" />
<TableColumn fx:id="colPhone" prefWidth="130.0" text="Phone" />
<TableColumn fx:id="colRole" prefWidth="100.0" text="Role" />
<TableColumn fx:id="colStatus" prefWidth="90.0" text="Status" />
<TableColumn fx:id="colCreated" prefWidth="150.0" text="Created" />
</columns>
</TableView>
</VBox>
<Label fx:id="lblStatus" text="" textFill="#64748b" visible="false" managed="true">
<font>