merge main
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")",
|
||||
"Bash(grep -E \"\\\\.js$|^d\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.petstoremobile.activities;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
@@ -9,14 +10,26 @@ import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.example.petstoremobile.api.auth.AuthApi;
|
||||
import com.example.petstoremobile.databinding.ActivityForgotPasswordBinding;
|
||||
import com.example.petstoremobile.utils.InputValidator;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
@AndroidEntryPoint
|
||||
public class ForgotPasswordActivity extends AppCompatActivity {
|
||||
|
||||
@Inject
|
||||
AuthApi authApi;
|
||||
|
||||
private ActivityForgotPasswordBinding binding;
|
||||
|
||||
@Override
|
||||
@@ -33,14 +46,39 @@ public class ForgotPasswordActivity extends AppCompatActivity {
|
||||
});
|
||||
|
||||
binding.btnSubmit.setOnClickListener(v -> {
|
||||
if (InputValidator.isValidEmail(binding.etEmail)) {
|
||||
String email = binding.etEmail.getText().toString().trim();
|
||||
// TODO: Implement password reset logic here
|
||||
Toast.makeText(this, "If this email is linked, a reset email will be sent.", Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
if (!InputValidator.isValidEmail(binding.etEmail)) return;
|
||||
String email = binding.etEmail.getText().toString().trim();
|
||||
sendResetLink(email);
|
||||
});
|
||||
|
||||
binding.btnBackToLogin.setOnClickListener(v -> finish());
|
||||
}
|
||||
|
||||
private void sendResetLink(String email) {
|
||||
binding.btnSubmit.setEnabled(false);
|
||||
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("usernameOrEmail", email);
|
||||
|
||||
authApi.forgotPassword(body).enqueue(new Callback<Void>() {
|
||||
@Override
|
||||
public void onResponse(Call<Void> call, Response<Void> response) {
|
||||
if (binding == null) return;
|
||||
binding.btnSubmit.setEnabled(true);
|
||||
Toast.makeText(ForgotPasswordActivity.this,
|
||||
"If this email is registered, a reset link will be sent.",
|
||||
Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<Void> call, Throwable t) {
|
||||
if (binding == null) return;
|
||||
binding.btnSubmit.setEnabled(true);
|
||||
Toast.makeText(ForgotPasswordActivity.this,
|
||||
"Could not send reset link. Please try again.",
|
||||
Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.model.GlideUrl;
|
||||
import com.bumptech.glide.load.model.LazyHeaders;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
import com.example.petstoremobile.R;
|
||||
import com.example.petstoremobile.databinding.ItemMessageReceivedBinding;
|
||||
import com.example.petstoremobile.databinding.ItemMessageSentBinding;
|
||||
@@ -35,6 +36,13 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
public MessageAdapter(List<Message> messages, Long currentUserId) {
|
||||
this.messages = messages;
|
||||
this.currentUserId = currentUserId;
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
Message m = messages.get(position);
|
||||
return m.getId() != null ? m.getId() : position;
|
||||
}
|
||||
|
||||
public void setCurrentUserId(Long id) {
|
||||
@@ -150,6 +158,7 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
iv.setVisibility(View.GONE);
|
||||
tvName.setVisibility(View.GONE);
|
||||
return;
|
||||
@@ -166,18 +175,25 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
.build());
|
||||
}
|
||||
|
||||
// Use a signature to prevent Glide from showing stale cached images for the same URL/ID
|
||||
String signatureKey = (m.getTimestamp() != null ? m.getTimestamp() : "") + m.getId();
|
||||
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
Glide.with(iv.getContext())
|
||||
.load(loadTarget)
|
||||
.signature(new ObjectKey(signatureKey))
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.placeholder(R.drawable.placeholder)
|
||||
.error(R.drawable.placeholder)
|
||||
.into(iv);
|
||||
} else {
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
iv.setVisibility(View.GONE);
|
||||
tvName.setVisibility(View.VISIBLE);
|
||||
tvName.setText(m.getAttachmentName() != null ? m.getAttachmentName() : "Attachment");
|
||||
}
|
||||
} else {
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
iv.setVisibility(View.GONE);
|
||||
tvName.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,12 @@ public class SaleAdapter extends RecyclerView.Adapter<SaleAdapter.SaleViewHolder
|
||||
|
||||
binding.tvSaleId.setText("Sale #" + (s.getSaleId() != null ? s.getSaleId() : ""));
|
||||
binding.tvSaleEmployee.setText("By: " + (s.getEmployeeName() != null ? s.getEmployeeName() : ""));
|
||||
if (s.getCustomerName() != null && !s.getCustomerName().isEmpty()) {
|
||||
binding.tvSaleCustomer.setText("Customer: " + s.getCustomerName());
|
||||
binding.tvSaleCustomer.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.tvSaleCustomer.setVisibility(View.GONE);
|
||||
}
|
||||
binding.tvSaleDate.setText(s.getSaleDate() != null ? s.getSaleDate().substring(0, Math.min(10, s.getSaleDate().length())) : "");
|
||||
binding.tvSalePayment.setText(s.getPaymentMethod() != null ? s.getPaymentMethod() : "");
|
||||
binding.tvSaleTotal.setText(s.getTotalAmount() != null ? "$" + s.getTotalAmount() : "");
|
||||
|
||||
@@ -20,6 +20,7 @@ public interface SaleApi {
|
||||
@Query("paymentMethod") String paymentMethod,
|
||||
@Query("storeId") Long storeId,
|
||||
@Query("isRefund") Boolean isRefund,
|
||||
@Query("customerId") Long customerId,
|
||||
@Query("sort") String sort);
|
||||
|
||||
@GET("api/v1/sales/{id}")
|
||||
|
||||
@@ -43,4 +43,8 @@ public interface AuthApi {
|
||||
@DELETE("api/v1/auth/me/avatar")
|
||||
Call<Void> deleteAvatar();
|
||||
|
||||
//forgot password endpoint
|
||||
@POST("api/v1/auth/forgot-password")
|
||||
Call<Void> forgotPassword(@Body Map<String, String> body);
|
||||
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
LinearLayoutManager lm = new LinearLayoutManager(getContext());
|
||||
lm.setStackFromEnd(true);
|
||||
binding.rvMessages.setLayoutManager(lm);
|
||||
binding.rvMessages.setItemAnimator(null);
|
||||
binding.rvMessages.setAdapter(messageAdapter);
|
||||
setConversationActive(false, null);
|
||||
}
|
||||
@@ -285,9 +286,14 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
});
|
||||
|
||||
viewModel.getMessageList().observe(getViewLifecycleOwner(), list -> {
|
||||
int prevSize = messageList.size();
|
||||
messageList.clear();
|
||||
messageList.addAll(list);
|
||||
messageAdapter.notifyDataSetChanged();
|
||||
if (prevSize > 0 && list.size() == prevSize + 1) {
|
||||
messageAdapter.notifyItemInserted(list.size() - 1);
|
||||
} else {
|
||||
messageAdapter.notifyDataSetChanged();
|
||||
}
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
|
||||
}
|
||||
|
||||
private void setupStatusFilter() {
|
||||
String[] statuses = {"All Statuses", "Completed", "Pending", "Cancelled"};
|
||||
String[] statuses = {"All Statuses", "Completed", "Pending", "Missed", "Cancelled"};
|
||||
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusAdoption, statuses, this::loadAdoptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import android.widget.*;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.databinding.FragmentAnalyticsBinding;
|
||||
import com.example.petstoremobile.utils.SpinnerUtils;
|
||||
import com.example.petstoremobile.utils.UIUtils;
|
||||
import com.example.petstoremobile.viewmodels.AnalyticsViewModel;
|
||||
import dagger.hilt.android.AndroidEntryPoint;
|
||||
import javax.inject.Inject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
@@ -19,6 +21,9 @@ import java.util.*;
|
||||
@AndroidEntryPoint
|
||||
public class AnalyticsFragment extends Fragment {
|
||||
|
||||
@Inject
|
||||
TokenManager tokenManager;
|
||||
|
||||
private FragmentAnalyticsBinding binding;
|
||||
private AnalyticsViewModel viewModel;
|
||||
private boolean filtersExpanded = false;
|
||||
@@ -33,6 +38,7 @@ public class AnalyticsFragment extends Fragment {
|
||||
viewModel = new ViewModelProvider(this).get(AnalyticsViewModel.class);
|
||||
|
||||
setupFilterPanel();
|
||||
setupViewModeToggle();
|
||||
observeViewModel();
|
||||
viewModel.loadAnalytics();
|
||||
|
||||
@@ -42,6 +48,39 @@ public class AnalyticsFragment extends Fragment {
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
private static final int COLOR_SELECTED = 0xFF4ECDC4;
|
||||
private static final int COLOR_UNSELECTED = 0xFFCBD5E1;
|
||||
|
||||
private void setupViewModeToggle() {
|
||||
updateViewModeButtonStyles(viewModel.getViewMode());
|
||||
|
||||
binding.btnMyAnalytics.setOnClickListener(v -> {
|
||||
viewModel.setViewMode("mine");
|
||||
updateViewModeButtonStyles("mine");
|
||||
updateStoreFilterVisibility("mine");
|
||||
});
|
||||
|
||||
binding.btnStoreAnalytics.setOnClickListener(v -> {
|
||||
viewModel.setViewMode("store");
|
||||
updateViewModeButtonStyles("store");
|
||||
updateStoreFilterVisibility("store");
|
||||
});
|
||||
}
|
||||
|
||||
private void updateViewModeButtonStyles(String mode) {
|
||||
binding.btnMyAnalytics.setBackgroundTintList(
|
||||
android.content.res.ColorStateList.valueOf(mode.equals("mine") ? COLOR_SELECTED : COLOR_UNSELECTED));
|
||||
binding.btnStoreAnalytics.setBackgroundTintList(
|
||||
android.content.res.ColorStateList.valueOf(mode.equals("store") ? COLOR_SELECTED : COLOR_UNSELECTED));
|
||||
}
|
||||
|
||||
private void updateStoreFilterVisibility(String mode) {
|
||||
boolean isAdmin = "ADMIN".equalsIgnoreCase(tokenManager.getRole());
|
||||
int vis = (isAdmin && mode.equals("store")) ? View.VISIBLE : View.GONE;
|
||||
binding.tvStoreFilterLabel.setVisibility(vis);
|
||||
binding.spinnerFilterStore.setVisibility(vis);
|
||||
}
|
||||
|
||||
// Filter Panel
|
||||
|
||||
private void setupFilterPanel() {
|
||||
@@ -96,6 +135,9 @@ public class AnalyticsFragment extends Fragment {
|
||||
int topNPos = binding.spinnerTopN.getSelectedItemPosition();
|
||||
filter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
|
||||
|
||||
Object store = binding.spinnerFilterStore.getSelectedItem();
|
||||
viewModel.setStoreFilter(store != null ? store.toString() : "All Stores");
|
||||
|
||||
updateFilterSummary();
|
||||
viewModel.applyFilter(filter);
|
||||
}
|
||||
@@ -104,8 +146,8 @@ public class AnalyticsFragment extends Fragment {
|
||||
binding.etFilterStartDate.setText("");
|
||||
binding.etFilterEndDate.setText("");
|
||||
binding.spinnerTopN.setSelection(0);
|
||||
// Reset payment method to "All"
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, "All");
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, "All Stores");
|
||||
updateFilterSummary();
|
||||
viewModel.resetFilter();
|
||||
}
|
||||
@@ -162,6 +204,16 @@ public class AnalyticsFragment extends Fragment {
|
||||
methods.toArray(new String[0]));
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, currentSelection);
|
||||
});
|
||||
|
||||
viewModel.getAvailableStores().observe(getViewLifecycleOwner(), stores -> {
|
||||
if (stores == null || stores.isEmpty()) return;
|
||||
String currentSelection = binding.spinnerFilterStore.getSelectedItem() != null
|
||||
? binding.spinnerFilterStore.getSelectedItem().toString() : "All Stores";
|
||||
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerFilterStore,
|
||||
stores.toArray(new String[0]));
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, currentSelection);
|
||||
updateStoreFilterVisibility(viewModel.getViewMode());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -224,17 +276,22 @@ public class AnalyticsFragment extends Fragment {
|
||||
}
|
||||
|
||||
// Employee Performance
|
||||
binding.llEmployeePerformance.removeAllViews();
|
||||
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
|
||||
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
|
||||
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
|
||||
for (Map.Entry<String, BigDecimal> e : data.employeePerformance) {
|
||||
addBarRow(binding.llEmployeePerformance, e.getKey(),
|
||||
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
|
||||
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
|
||||
boolean showEmployeeSection = viewModel.getViewMode().equals("store");
|
||||
View empParent = (View) binding.llEmployeePerformance.getParent();
|
||||
if (empParent != null) empParent.setVisibility(showEmployeeSection ? View.VISIBLE : View.GONE);
|
||||
if (showEmployeeSection) {
|
||||
binding.llEmployeePerformance.removeAllViews();
|
||||
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
|
||||
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
|
||||
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
|
||||
for (Map.Entry<String, BigDecimal> e : data.employeePerformance) {
|
||||
addBarRow(binding.llEmployeePerformance, e.getKey(),
|
||||
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
|
||||
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
|
||||
}
|
||||
} else {
|
||||
addEmptyRow(binding.llEmployeePerformance, "No data");
|
||||
}
|
||||
} else {
|
||||
addEmptyRow(binding.llEmployeePerformance, "No data");
|
||||
}
|
||||
|
||||
// Daily Revenue
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.example.petstoremobile.R;
|
||||
import com.example.petstoremobile.adapters.SaleAdapter;
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.databinding.FragmentSaleBinding;
|
||||
import com.example.petstoremobile.dtos.CustomerDTO;
|
||||
import com.example.petstoremobile.dtos.SaleDTO;
|
||||
import com.example.petstoremobile.dtos.StoreDTO;
|
||||
import com.example.petstoremobile.utils.SpinnerUtils;
|
||||
@@ -57,6 +58,7 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
|
||||
setupStoreFilter();
|
||||
setupPaymentMethodFilter();
|
||||
setupRefundStatusFilter();
|
||||
setupCustomerFilter();
|
||||
setupSwipeRefresh();
|
||||
setupFilterToggle();
|
||||
observeViewModel();
|
||||
@@ -89,6 +91,11 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
|
||||
StoreDTO::getStoreName, "All Stores", null, StoreDTO::getStoreId);
|
||||
});
|
||||
|
||||
viewModel.getCustomers().observe(getViewLifecycleOwner(), list -> {
|
||||
SpinnerUtils.populateWhiteSpinner(requireContext(), binding.spinnerCustomer, list,
|
||||
CustomerDTO::getFullName, "All Customers", null, CustomerDTO::getCustomerId);
|
||||
});
|
||||
|
||||
viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading -> {
|
||||
binding.swipeRefreshSale.setRefreshing(loading);
|
||||
});
|
||||
@@ -98,16 +105,17 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (!isStaff()) viewModel.loadStores();
|
||||
viewModel.loadCustomers();
|
||||
}
|
||||
|
||||
private void setupFilterToggle() {
|
||||
if (isStaff()) {
|
||||
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, binding.etSearchSale,
|
||||
binding.spinnerPaymentMethod, binding.spinnerRefundStatus);
|
||||
binding.spinnerPaymentMethod, binding.spinnerRefundStatus, binding.spinnerCustomer);
|
||||
binding.spinnerStore.setVisibility(View.GONE);
|
||||
} else {
|
||||
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter, binding.etSearchSale,
|
||||
binding.spinnerPaymentMethod, binding.spinnerStore, binding.spinnerRefundStatus);
|
||||
binding.spinnerPaymentMethod, binding.spinnerStore, binding.spinnerRefundStatus, binding.spinnerCustomer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +141,10 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
|
||||
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerRefundStatus, refundStatuses, () -> loadSales(true));
|
||||
}
|
||||
|
||||
private void setupCustomerFilter() {
|
||||
SpinnerUtils.setupFilterSpinner(binding.spinnerCustomer, () -> loadSales(true));
|
||||
}
|
||||
|
||||
private void setupRecyclerView() {
|
||||
adapter = new SaleAdapter(saleList, this);
|
||||
binding.recyclerViewSales.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
@@ -189,7 +201,13 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
|
||||
isRefund = binding.spinnerRefundStatus.getSelectedItemPosition() == 2;
|
||||
}
|
||||
|
||||
viewModel.loadSales(reset, query, paymentMethod, storeId, isRefund);
|
||||
Long customerId = null;
|
||||
List<CustomerDTO> customerList = viewModel.getCustomers().getValue();
|
||||
if (binding.spinnerCustomer.getSelectedItemPosition() > 0 && customerList != null && !customerList.isEmpty()) {
|
||||
customerId = customerList.get(binding.spinnerCustomer.getSelectedItemPosition() - 1).getCustomerId();
|
||||
}
|
||||
|
||||
viewModel.loadSales(reset, query, paymentMethod, storeId, isRefund, customerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -111,6 +111,7 @@ public class SaleDetailFragment extends Fragment {
|
||||
binding.llLoyaltyPoints.setVisibility(View.GONE);
|
||||
binding.cbUseLoyaltyPoints.setChecked(false);
|
||||
}
|
||||
updateTotal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -420,6 +421,15 @@ public class SaleDetailFragment extends Fragment {
|
||||
}
|
||||
|
||||
binding.tvSaleDetailTotal.setText("Total: $" + String.format(Locale.getDefault(), "%.2f", total));
|
||||
|
||||
CustomerDTO customer = viewModel.getSelectedCustomerData().getValue();
|
||||
if (customer != null && !viewModel.isViewOnly()) {
|
||||
int pointsToEarn = total.max(BigDecimal.ZERO).intValue();
|
||||
binding.tvPointsToEarn.setText("+" + pointsToEarn + " pts");
|
||||
binding.llPointsToEarn.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.llPointsToEarn.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveSale() {
|
||||
|
||||
@@ -28,8 +28,6 @@ public class StaffDetailFragment extends Fragment {
|
||||
private FragmentStaffDetailBinding binding;
|
||||
private StaffDetailViewModel viewModel;
|
||||
|
||||
private final String[] ROLES = {"STAFF", "ADMIN"};
|
||||
private final String[] STAFF_ROLES = {"STORE_MANAGER", "SALES_ASSOCIATE", "GROOMER", "VETERINARIAN"};
|
||||
private final String[] STATUSES = {"Active", "Inactive"};
|
||||
|
||||
private long preselectedStoreId = -1;
|
||||
@@ -59,8 +57,6 @@ public class StaffDetailFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void setupSpinners() {
|
||||
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffRole, ROLES);
|
||||
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffType, STAFF_ROLES);
|
||||
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffStatus, STATUSES);
|
||||
}
|
||||
|
||||
@@ -112,8 +108,6 @@ public class StaffDetailFragment extends Fragment {
|
||||
binding.etStaffEmail.setText(e.getEmail());
|
||||
binding.etStaffPhone.setText(e.getPhone());
|
||||
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerStaffRole, e.getRole());
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerStaffType, e.getStaffRole());
|
||||
binding.spinnerStaffStatus.setSelection(Boolean.TRUE.equals(e.getActive()) ? 0 : 1);
|
||||
|
||||
preselectedStoreId = e.getPrimaryStoreId() != null ? e.getPrimaryStoreId() : -1;
|
||||
@@ -132,14 +126,32 @@ public class StaffDetailFragment extends Fragment {
|
||||
private void save() {
|
||||
if (!InputValidator.isNotEmpty(binding.etStaffUsername, "Username")) return;
|
||||
|
||||
String password = binding.etStaffPassword.getText().toString().trim();
|
||||
String confirmPassword = binding.etStaffConfirmPassword.getText().toString().trim();
|
||||
|
||||
if (!viewModel.isEditing()) {
|
||||
if (!InputValidator.isNotEmpty(binding.etStaffPassword, "Password")) return;
|
||||
String pass = binding.etStaffPassword.getText().toString();
|
||||
if (pass.length() < 6) {
|
||||
if (password.length() < 6) {
|
||||
binding.etStaffPassword.setError("At least 6 characters");
|
||||
binding.etStaffPassword.requestFocus();
|
||||
return;
|
||||
}
|
||||
if (!password.equals(confirmPassword)) {
|
||||
binding.etStaffConfirmPassword.setError("Passwords do not match");
|
||||
binding.etStaffConfirmPassword.requestFocus();
|
||||
return;
|
||||
}
|
||||
} else if (!password.isEmpty()) {
|
||||
if (password.length() < 6) {
|
||||
binding.etStaffPassword.setError("At least 6 characters");
|
||||
binding.etStaffPassword.requestFocus();
|
||||
return;
|
||||
}
|
||||
if (!password.equals(confirmPassword)) {
|
||||
binding.etStaffConfirmPassword.setError("Passwords do not match");
|
||||
binding.etStaffConfirmPassword.requestFocus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!InputValidator.isNotEmpty(binding.etStaffFirstName, "First Name")) return;
|
||||
@@ -149,13 +161,12 @@ public class StaffDetailFragment extends Fragment {
|
||||
if (!InputValidator.isSpinnerSelected(binding.spinnerStaffStore, "Primary Store")) return;
|
||||
|
||||
String username = binding.etStaffUsername.getText().toString().trim();
|
||||
String password = binding.etStaffPassword.getText().toString().trim();
|
||||
String firstName = binding.etStaffFirstName.getText().toString().trim();
|
||||
String lastName = binding.etStaffLastName.getText().toString().trim();
|
||||
String email = binding.etStaffEmail.getText().toString().trim();
|
||||
String phone = binding.etStaffPhone.getText().toString().trim();
|
||||
String role = ROLES[binding.spinnerStaffRole.getSelectedItemPosition()];
|
||||
String staffRole = STAFF_ROLES[binding.spinnerStaffType.getSelectedItemPosition()];
|
||||
String role = "STAFF";
|
||||
String staffRole = null;
|
||||
boolean active = binding.spinnerStaffStatus.getSelectedItemPosition() == 0;
|
||||
|
||||
List<DropdownDTO> stores = viewModel.getStoreList().getValue();
|
||||
|
||||
@@ -20,8 +20,8 @@ public class SaleRepository extends BaseRepository {
|
||||
this.saleApi = saleApi;
|
||||
}
|
||||
|
||||
public LiveData<Resource<PageResponse<SaleDTO>>> getAllSales(int page, int size, String query, String paymentMethod, Long storeId, Boolean isRefund, String sortBy) {
|
||||
return executeCall(saleApi.getAllSales(page, size, query, paymentMethod, storeId, isRefund, sortBy));
|
||||
public LiveData<Resource<PageResponse<SaleDTO>>> getAllSales(int page, int size, String query, String paymentMethod, Long storeId, Boolean isRefund, Long customerId, String sortBy) {
|
||||
return executeCall(saleApi.getAllSales(page, size, query, paymentMethod, storeId, isRefund, customerId, sortBy));
|
||||
}
|
||||
|
||||
public LiveData<Resource<SaleDTO>> getSaleById(Long id) {
|
||||
|
||||
@@ -100,13 +100,12 @@ public class InputValidator {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks if the phone number is valid in (XXX) XXX-XXXX format
|
||||
// Checks if the phone number is valid: XXX-XXX-XXXX, (XXX) XXX-XXXX, or XXXXXXXXXX
|
||||
public static boolean isValidPhone(EditText field) {
|
||||
String phone = field.getText().toString().trim();
|
||||
// Matches (XXX) XXX-XXXX format
|
||||
String pattern = "^\\(\\d{3}\\) \\d{3}-\\d{4}$";
|
||||
String pattern = "^(\\(\\d{3}\\) \\d{3}-\\d{4}|\\d{3}-\\d{3}-\\d{4}|\\d{10})$";
|
||||
if (phone.isEmpty() || !phone.matches(pattern)) {
|
||||
field.setError("Enter a valid phone number: (XXX) XXX-XXXX");
|
||||
field.setError("Enter a valid phone number");
|
||||
field.requestFocus();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.dtos.SaleDTO;
|
||||
import com.example.petstoremobile.repositories.SaleRepository;
|
||||
import com.example.petstoremobile.utils.Resource;
|
||||
@@ -21,6 +22,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
@@ -29,33 +31,40 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
|
||||
@HiltViewModel
|
||||
public class AnalyticsViewModel extends ViewModel {
|
||||
private final SaleRepository saleRepository;
|
||||
private final TokenManager tokenManager;
|
||||
|
||||
private final MutableLiveData<AnalyticsData> analyticsData = new MutableLiveData<>();
|
||||
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
|
||||
private final MutableLiveData<List<String>> availablePaymentMethods = new MutableLiveData<>(new ArrayList<>());
|
||||
private final MutableLiveData<List<String>> availableStores = new MutableLiveData<>(new ArrayList<>());
|
||||
|
||||
private List<SaleDTO> cachedSales = new ArrayList<>();
|
||||
private FilterState currentFilter = new FilterState();
|
||||
private String viewMode = "store";
|
||||
private String storeFilter = "All Stores";
|
||||
|
||||
@Inject
|
||||
public AnalyticsViewModel(SaleRepository saleRepository) {
|
||||
public AnalyticsViewModel(SaleRepository saleRepository, TokenManager tokenManager) {
|
||||
this.saleRepository = saleRepository;
|
||||
this.tokenManager = tokenManager;
|
||||
}
|
||||
|
||||
public LiveData<AnalyticsData> getAnalyticsData() { return analyticsData; }
|
||||
public LiveData<Boolean> getIsLoading() { return isLoading; }
|
||||
public LiveData<String> getErrorMessage() { return errorMessage; }
|
||||
public LiveData<List<String>> getAvailablePaymentMethods() { return availablePaymentMethods; }
|
||||
public LiveData<List<String>> getAvailableStores() { return availableStores; }
|
||||
|
||||
public void loadAnalytics() {
|
||||
isLoading.setValue(true);
|
||||
errorMessage.setValue(null);
|
||||
observeOnce(saleRepository.getAllSales(0, 2000, null, null, null, null, "saleDate,desc"), resource -> {
|
||||
observeOnce(saleRepository.getAllSales(0, 2000, null, null, null, null, null, "saleDate,desc"), resource -> {
|
||||
if (resource != null) {
|
||||
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
|
||||
cachedSales = resource.data.getContent();
|
||||
derivePaymentMethods();
|
||||
deriveStores();
|
||||
applyCurrentFilter();
|
||||
isLoading.setValue(false);
|
||||
} else if (resource.status == Resource.Status.ERROR) {
|
||||
@@ -73,14 +82,61 @@ public class AnalyticsViewModel extends ViewModel {
|
||||
|
||||
public void resetFilter() {
|
||||
currentFilter = new FilterState();
|
||||
storeFilter = "All Stores";
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
public void setViewMode(String mode) {
|
||||
viewMode = mode;
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
public String getViewMode() {
|
||||
return viewMode;
|
||||
}
|
||||
|
||||
public void setStoreFilter(String store) {
|
||||
storeFilter = (store != null && !store.isEmpty()) ? store : "All Stores";
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
public String getStoreFilter() {
|
||||
return storeFilter;
|
||||
}
|
||||
|
||||
private void applyCurrentFilter() {
|
||||
List<SaleDTO> filtered = filterSales(cachedSales, currentFilter);
|
||||
List<SaleDTO> salesForMode;
|
||||
if (viewMode.equals("mine")) {
|
||||
String currentUser = tokenManager.getUsername();
|
||||
salesForMode = cachedSales.stream()
|
||||
.filter(s -> currentUser != null && currentUser.equalsIgnoreCase(s.getEmployeeName() != null ? s.getEmployeeName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
} else {
|
||||
salesForMode = cachedSales;
|
||||
}
|
||||
if (!storeFilter.equals("All Stores") && !storeFilter.isEmpty()) {
|
||||
final String sf = storeFilter;
|
||||
salesForMode = salesForMode.stream()
|
||||
.filter(s -> sf.equalsIgnoreCase(s.getStoreName() != null ? s.getStoreName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
List<SaleDTO> filtered = filterSales(salesForMode, currentFilter);
|
||||
computeAnalytics(filtered, currentFilter);
|
||||
}
|
||||
|
||||
private void deriveStores() {
|
||||
java.util.Set<String> stores = new java.util.TreeSet<>();
|
||||
for (SaleDTO s : cachedSales) {
|
||||
if (s.getStoreName() != null && !s.getStoreName().isEmpty()) {
|
||||
stores.add(s.getStoreName());
|
||||
}
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
result.add("All Stores");
|
||||
result.addAll(stores);
|
||||
availableStores.setValue(result);
|
||||
}
|
||||
|
||||
private void derivePaymentMethods() {
|
||||
java.util.Set<String> methods = new java.util.TreeSet<>();
|
||||
for (SaleDTO s : cachedSales) {
|
||||
|
||||
@@ -35,7 +35,7 @@ public class RefundViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
public LiveData<Resource<PageResponse<SaleDTO>>> loadAllSales() {
|
||||
return saleRepository.getAllSales(0, 1000, null, null, null, null, "saleDate,desc");
|
||||
return saleRepository.getAllSales(0, 1000, null, null, null, null, null, "saleDate,desc");
|
||||
}
|
||||
|
||||
public void setAllSales(List<SaleDTO> sales) {
|
||||
|
||||
@@ -4,9 +4,11 @@ import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import com.example.petstoremobile.dtos.CustomerDTO;
|
||||
import com.example.petstoremobile.dtos.PageResponse;
|
||||
import com.example.petstoremobile.dtos.SaleDTO;
|
||||
import com.example.petstoremobile.dtos.StoreDTO;
|
||||
import com.example.petstoremobile.repositories.CustomerRepository;
|
||||
import com.example.petstoremobile.repositories.SaleRepository;
|
||||
import com.example.petstoremobile.repositories.StoreRepository;
|
||||
import com.example.petstoremobile.utils.Resource;
|
||||
@@ -24,9 +26,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
|
||||
public class SaleListViewModel extends ViewModel {
|
||||
private final SaleRepository saleRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final CustomerRepository customerRepository;
|
||||
|
||||
private final MutableLiveData<List<SaleDTO>> sales = new MutableLiveData<>(new ArrayList<>());
|
||||
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
|
||||
private final MutableLiveData<List<CustomerDTO>> customers = new MutableLiveData<>(new ArrayList<>());
|
||||
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
|
||||
|
||||
private int currentPage = 0;
|
||||
@@ -34,17 +38,19 @@ public class SaleListViewModel extends ViewModel {
|
||||
private static final int PAGE_SIZE = 20;
|
||||
|
||||
@Inject
|
||||
public SaleListViewModel(SaleRepository saleRepository, StoreRepository storeRepository) {
|
||||
public SaleListViewModel(SaleRepository saleRepository, StoreRepository storeRepository, CustomerRepository customerRepository) {
|
||||
this.saleRepository = saleRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.customerRepository = customerRepository;
|
||||
}
|
||||
|
||||
public LiveData<List<SaleDTO>> getSales() { return sales; }
|
||||
public LiveData<List<StoreDTO>> getStores() { return stores; }
|
||||
public LiveData<List<CustomerDTO>> getCustomers() { return customers; }
|
||||
public LiveData<Boolean> getIsLoading() { return isLoading; }
|
||||
public boolean isLastPage() { return isLastPage; }
|
||||
|
||||
public void loadSales(boolean reset, String query, String paymentMethod, Long storeId, Boolean isRefund) {
|
||||
public void loadSales(boolean reset, String query, String paymentMethod, Long storeId, Boolean isRefund, Long customerId) {
|
||||
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
|
||||
|
||||
if (reset) {
|
||||
@@ -53,7 +59,7 @@ public class SaleListViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
isLoading.setValue(true);
|
||||
observeOnce(saleRepository.getAllSales(currentPage, PAGE_SIZE, query, paymentMethod, storeId, isRefund, "saleDate,desc"), resource -> {
|
||||
observeOnce(saleRepository.getAllSales(currentPage, PAGE_SIZE, query, paymentMethod, storeId, isRefund, customerId, "saleDate,desc"), resource -> {
|
||||
if (resource != null) {
|
||||
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
|
||||
List<SaleDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(sales.getValue());
|
||||
@@ -77,6 +83,14 @@ public class SaleListViewModel extends ViewModel {
|
||||
});
|
||||
}
|
||||
|
||||
public void loadCustomers() {
|
||||
observeOnce(customerRepository.getAllCustomers(0, 500), resource -> {
|
||||
if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) {
|
||||
customers.setValue(resource.data.getContent());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private <T> void observeOnce(LiveData<Resource<T>> liveData, Observer<Resource<T>> handler) {
|
||||
liveData.observeForever(new Observer<Resource<T>>() {
|
||||
@Override
|
||||
|
||||
@@ -45,6 +45,40 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/llViewModeToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnMyAnalytics"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_weight="1"
|
||||
android:text="My Analytics"
|
||||
android:textSize="12sp"
|
||||
android:backgroundTint="#CBD5E1"
|
||||
android:textColor="@color/white"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStoreAnalytics"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Store Analytics"
|
||||
android:textSize="12sp"
|
||||
android:backgroundTint="@color/primary_medium"
|
||||
android:textColor="@color/white"
|
||||
android:layout_marginStart="4dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -102,6 +136,23 @@
|
||||
android:layout_marginTop="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStoreFilterLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Store"
|
||||
android:textColor="@color/text_light"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerFilterStore"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -151,6 +151,15 @@
|
||||
android:layout_marginStart="4dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerCustomer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/bg_spinner"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="8dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
|
||||
@@ -444,6 +444,30 @@
|
||||
android:textColor="@color/accent_coral"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="12dp"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/llPointsToEarn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="4dp"
|
||||
android:visibility="gone">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Points to earn: "
|
||||
android:textColor="#4ECDC4"
|
||||
android:textSize="13sp"/>
|
||||
<TextView
|
||||
android:id="@+id/tvPointsToEarn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="+0 pts"
|
||||
android:textColor="#4ECDC4"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -101,6 +101,23 @@
|
||||
android:inputType="textPassword"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Confirm Password"
|
||||
android:textColor="@color/text_dark"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginBottom="4dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etStaffConfirmPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Re-enter password"
|
||||
android:inputType="textPassword"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<!-- First Name -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
@@ -169,35 +186,6 @@
|
||||
android:inputType="phone"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<!-- Role -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="User Role"
|
||||
android:textColor="@color/text_dark"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginBottom="4dp"/>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerStaffRole"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Staff Role"
|
||||
android:textColor="@color/text_dark"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginBottom="4dp"/>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerStaffType"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -74,6 +74,15 @@
|
||||
android:textColor="#888888"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSaleCustomer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#888888"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSaleDate"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
OPENROUTER_API_KEY=sk-or-v1-...
|
||||
RESEND_API_KEY=re_...
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -44,7 +44,8 @@ build/
|
||||
.env
|
||||
|
||||
### Project Specific ###
|
||||
src/test/
|
||||
!src/test/
|
||||
!src/test/**
|
||||
tmp/
|
||||
uploads/*
|
||||
!uploads/avatars/
|
||||
|
||||
@@ -96,6 +96,12 @@
|
||||
<version>25.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.resend</groupId>
|
||||
<artifactId>resend-java</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.activity.ActivityLogResponse;
|
||||
import com.petshop.backend.service.ActivityLogService;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -26,8 +28,10 @@ public class ActivityLogController {
|
||||
@RequestParam(defaultValue = "2000") int limit,
|
||||
@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) String role,
|
||||
@RequestParam(required = false) String search) {
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||
int safeLimit = Math.min(Math.max(1, limit), 10000);
|
||||
return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search));
|
||||
return ResponseEntity.ok(activityLogService.getLogs(safeLimit, storeId, role, search, startDate, endDate));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.petshop.backend.repository.PetRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.service.OpenRouterService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import com.petshop.backend.util.ContentFilter;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -49,6 +50,7 @@ public class AiChatController {
|
||||
if (request.getMessage() == null || request.getMessage().isBlank()) {
|
||||
return ResponseEntity.badRequest().body(AiChatResponse.fail("Message cannot be empty"));
|
||||
}
|
||||
ContentFilter.validate(request.getMessage());
|
||||
|
||||
User user = getCurrentUser();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.JwtUtil;
|
||||
import com.petshop.backend.service.ActivityLogService;
|
||||
import com.petshop.backend.service.AvatarStorageService;
|
||||
import com.petshop.backend.service.EmailService;
|
||||
import com.petshop.backend.service.PasswordResetService;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import com.petshop.backend.util.PhoneUtils;
|
||||
@@ -55,8 +56,9 @@ public class AuthController {
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
private final ActivityLogService activityLogService;
|
||||
private final PasswordResetService passwordResetService;
|
||||
private final EmailService emailService;
|
||||
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService) {
|
||||
public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) {
|
||||
this.authenticationManager = authenticationManager;
|
||||
this.userRepository = userRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
@@ -64,6 +66,7 @@ public class AuthController {
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
this.activityLogService = activityLogService;
|
||||
this.passwordResetService = passwordResetService;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@@ -108,6 +111,8 @@ public class AuthController {
|
||||
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
emailService.sendWelcome(savedUser);
|
||||
|
||||
String token = jwtUtil.generateToken(savedUser);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse(
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.petshop.backend.controller;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.dto.user.UserRequest;
|
||||
import com.petshop.backend.dto.user.UserResponse;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -32,30 +33,30 @@ public class CustomerController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getCustomerById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(userService.getUserById(id));
|
||||
return ResponseEntity.ok(userService.getUserById(id, User.Role.CUSTOMER));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createCustomer(@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request, User.Role.CUSTOMER));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateCustomer(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.ok(userService.updateUser(id, request));
|
||||
return ResponseEntity.ok(userService.updateUser(id, request, User.Role.CUSTOMER));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteCustomer(@PathVariable Long id) {
|
||||
userService.deleteUser(id);
|
||||
userService.deleteUser(id, User.Role.CUSTOMER);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-delete")
|
||||
public ResponseEntity<Void> bulkDeleteCustomers(@Valid @RequestBody BulkDeleteRequest request) {
|
||||
userService.bulkDeleteUsers(request);
|
||||
userService.bulkDeleteUsers(request, User.Role.CUSTOMER);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.user.UserRequest;
|
||||
import com.petshop.backend.dto.user.UserResponse;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.service.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -31,24 +32,24 @@ public class EmployeeController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> getEmployeeById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(userService.getUserById(id));
|
||||
return ResponseEntity.ok(userService.getUserById(id, User.Role.STAFF));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<UserResponse> createEmployee(@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request, User.Role.STAFF));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<UserResponse> updateEmployee(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserRequest request) {
|
||||
return ResponseEntity.ok(userService.updateUser(id, request));
|
||||
return ResponseEntity.ok(userService.updateUser(id, request, User.Role.STAFF));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteEmployee(@PathVariable Long id) {
|
||||
userService.deleteUser(id);
|
||||
userService.deleteUser(id, User.Role.STAFF);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public class RefundController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF')")
|
||||
public ResponseEntity<RefundResponse> createRefund(@Valid @RequestBody RefundRequest request) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String role = authentication.getAuthorities().stream()
|
||||
@@ -85,7 +85,7 @@ public class RefundController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
@PreAuthorize("hasRole('STAFF')")
|
||||
public ResponseEntity<RefundResponse> updateRefund(@PathVariable Long id, @Valid @RequestBody RefundUpdateRequest request) {
|
||||
return ResponseEntity.ok(refundService.updateRefundStatus(id, request.getStatus()));
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ public class SaleController {
|
||||
@RequestParam(required = false) String paymentMethod,
|
||||
@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) Boolean isRefund,
|
||||
@RequestParam(required = false) Long customerId,
|
||||
Pageable pageable) {
|
||||
return ResponseEntity.ok(saleService.getAllSales(q, paymentMethod, storeId, isRefund, pageable));
|
||||
return ResponseEntity.ok(saleService.getAllSales(q, paymentMethod, storeId, isRefund, customerId, pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@@ -39,7 +40,7 @@ public class SaleController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
@PreAuthorize("hasRole('STAFF')")
|
||||
public ResponseEntity<SaleResponse> createSale(@Valid @RequestBody SaleRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(saleService.createSale(request));
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ public class ServiceController {
|
||||
@GetMapping
|
||||
public ResponseEntity<Page<ServiceResponse>> getAllServices(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String species,
|
||||
Pageable pageable) {
|
||||
return ResponseEntity.ok(serviceService.getAllServices(q, pageable));
|
||||
return ResponseEntity.ok(serviceService.getAllServices(q, species, pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
@@ -24,7 +24,7 @@ public class UserAvatarController {
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/avatar/file")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Resource> getUserAvatarFile(@PathVariable Long userId) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null || !avatarStorageService.hasAvatar(user)) {
|
||||
|
||||
@@ -11,21 +11,19 @@ public class PurchaseOrderResponse {
|
||||
private Long storeId;
|
||||
private String storeName;
|
||||
private LocalDate orderDate;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public PurchaseOrderResponse() {
|
||||
}
|
||||
|
||||
public PurchaseOrderResponse(Long purchaseOrderId, Long supId, String supplierName, Long storeId, String storeName, LocalDate orderDate, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public PurchaseOrderResponse(Long purchaseOrderId, Long supId, String supplierName, Long storeId, String storeName, LocalDate orderDate, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.purchaseOrderId = purchaseOrderId;
|
||||
this.supId = supId;
|
||||
this.supplierName = supplierName;
|
||||
this.storeId = storeId;
|
||||
this.storeName = storeName;
|
||||
this.orderDate = orderDate;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -78,14 +76,6 @@ public class PurchaseOrderResponse {
|
||||
this.orderDate = orderDate;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -107,12 +97,12 @@ public class PurchaseOrderResponse {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
PurchaseOrderResponse that = (PurchaseOrderResponse) o;
|
||||
return Objects.equals(purchaseOrderId, that.purchaseOrderId) && Objects.equals(supId, that.supId) && Objects.equals(supplierName, that.supplierName) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName) && Objects.equals(orderDate, that.orderDate) && Objects.equals(status, that.status) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
return Objects.equals(purchaseOrderId, that.purchaseOrderId) && Objects.equals(supId, that.supId) && Objects.equals(supplierName, that.supplierName) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName) && Objects.equals(orderDate, that.orderDate) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(purchaseOrderId, supId, supplierName, storeId, storeName, orderDate, status, createdAt, updatedAt);
|
||||
return Objects.hash(purchaseOrderId, supId, supplierName, storeId, storeName, orderDate, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -124,7 +114,6 @@ public class PurchaseOrderResponse {
|
||||
", storeId=" + storeId +
|
||||
", storeName='" + storeName + '\'' +
|
||||
", orderDate=" + orderDate +
|
||||
", status='" + status + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -28,6 +28,8 @@ public class SaleRequest {
|
||||
|
||||
private Long cartId;
|
||||
|
||||
private Integer pointsUsed;
|
||||
|
||||
|
||||
public Long getStoreId() {
|
||||
return storeId;
|
||||
@@ -101,6 +103,14 @@ public class SaleRequest {
|
||||
this.cartId = cartId;
|
||||
}
|
||||
|
||||
public Integer getPointsUsed() {
|
||||
return pointsUsed;
|
||||
}
|
||||
|
||||
public void setPointsUsed(Integer pointsUsed) {
|
||||
this.pointsUsed = pointsUsed;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
@@ -115,12 +125,13 @@ public class SaleRequest {
|
||||
Objects.equals(customerId, that.customerId) &&
|
||||
Objects.equals(channel, that.channel) &&
|
||||
Objects.equals(couponId, that.couponId) &&
|
||||
Objects.equals(cartId, that.cartId);
|
||||
Objects.equals(cartId, that.cartId) &&
|
||||
Objects.equals(pointsUsed, that.pointsUsed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId);
|
||||
return Objects.hash(storeId, paymentMethod, items, isRefund, originalSaleId, customerId, channel, couponId, cartId, pointsUsed);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -135,6 +146,7 @@ public class SaleRequest {
|
||||
", channel='" + channel + '\'' +
|
||||
", couponId=" + couponId +
|
||||
", cartId=" + cartId +
|
||||
", pointsUsed=" + pointsUsed +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ public class PurchaseOrder {
|
||||
@Column(nullable = false)
|
||||
private LocalDate orderDate;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String status;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -41,11 +38,10 @@ public class PurchaseOrder {
|
||||
public PurchaseOrder() {
|
||||
}
|
||||
|
||||
public PurchaseOrder(Long purchaseOrderId, Supplier supplier, LocalDate orderDate, String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
public PurchaseOrder(Long purchaseOrderId, Supplier supplier, LocalDate orderDate, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
this.purchaseOrderId = purchaseOrderId;
|
||||
this.supplier = supplier;
|
||||
this.orderDate = orderDate;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
@@ -82,14 +78,6 @@ public class PurchaseOrder {
|
||||
this.orderDate = orderDate;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
@@ -125,7 +113,6 @@ public class PurchaseOrder {
|
||||
"purchaseOrderId=" + purchaseOrderId +
|
||||
", supplier=" + supplier +
|
||||
", orderDate=" + orderDate +
|
||||
", status='" + status + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
'}';
|
||||
|
||||
@@ -42,9 +42,10 @@ public class GlobalExceptionHandler {
|
||||
errors.put(fieldName, errorMessage);
|
||||
});
|
||||
|
||||
String firstMessage = errors.values().stream().findFirst().orElse("Validation failed");
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("status", HttpStatus.BAD_REQUEST.value());
|
||||
response.put("message", "Validation failed");
|
||||
response.put("message", firstMessage);
|
||||
response.put("errors", errors);
|
||||
response.put("details", buildDetails(ex));
|
||||
response.put("path", request.getRequestURI());
|
||||
|
||||
@@ -40,4 +40,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
|
||||
boolean existsByPet_IdAndAdoptionStatusIgnoreCase(Long petId, String adoptionStatus);
|
||||
|
||||
List<Adoption> findByCustomer_IdAndAdoptionStatusIgnoreCase(Long customerId, String adoptionStatus);
|
||||
|
||||
List<Adoption> findByAdoptionDateAndAdoptionStatusIgnoreCase(LocalDate date, String status);
|
||||
}
|
||||
|
||||
@@ -52,4 +52,6 @@ public interface AppointmentRepository extends JpaRepository<Appointment, Long>
|
||||
List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
|
||||
|
||||
List<Appointment> findByPet_Id(Long petId);
|
||||
|
||||
List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import org.springframework.stereotype.Repository;
|
||||
@Repository
|
||||
public interface ProductRepository extends JpaRepository<Product, Long> {
|
||||
|
||||
boolean existsByProdNameIgnoreCase(String prodName);
|
||||
|
||||
boolean existsByProdNameIgnoreCaseAndProdIdNot(String prodName, Long prodId);
|
||||
|
||||
@Query("SELECT p FROM Product p WHERE " +
|
||||
"(:q IS NULL OR LOWER(p.prodName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.prodDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||
"(:categoryId IS NULL OR p.category.categoryId = :categoryId)")
|
||||
|
||||
@@ -22,8 +22,9 @@ public interface SaleRepository extends JpaRepository<Sale, Long> {
|
||||
")) AND " +
|
||||
"(:paymentMethod IS NULL OR LOWER(s.paymentMethod) = LOWER(:paymentMethod)) AND " +
|
||||
"(:isRefund IS NULL OR s.isRefund = :isRefund) AND " +
|
||||
"(:storeId IS NULL OR s.store.storeId = :storeId)")
|
||||
Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, Pageable pageable);
|
||||
"(:storeId IS NULL OR s.store.storeId = :storeId) AND " +
|
||||
"(:customerId IS NULL OR s.customer.id = :customerId)")
|
||||
Page<Sale> searchSales(@Param("q") String query, @Param("paymentMethod") String paymentMethod, @Param("storeId") Long storeId, @Param("isRefund") Boolean isRefund, @Param("customerId") Long customerId, Pageable pageable);
|
||||
|
||||
List<Sale> findByOriginalSaleSaleId(Long originalSaleId);
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import org.springframework.stereotype.Repository;
|
||||
@Repository
|
||||
public interface ServiceRepository extends JpaRepository<Service, Long> {
|
||||
|
||||
@Query("SELECT s FROM Service s WHERE " +
|
||||
"LOWER(s.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
|
||||
"LOWER(s.serviceDesc) LIKE LOWER(CONCAT('%', :q, '%'))")
|
||||
Page<Service> searchServices(@Param("q") String query, Pageable pageable);
|
||||
@Query("SELECT DISTINCT s FROM Service s LEFT JOIN s.species sp WHERE " +
|
||||
"(:q IS NULL OR LOWER(s.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(s.serviceDesc, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
|
||||
"(:species IS NULL OR LOWER(sp) = LOWER(:species))")
|
||||
Page<Service> searchServices(@Param("q") String q, @Param("species") String species, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.petshop.backend.security;
|
||||
|
||||
import com.petshop.backend.exception.ApiErrorResponder;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class RateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Map<String, int[]> RULES = Map.of(
|
||||
"/api/v1/auth/login", new int[]{10, 15},
|
||||
"/api/v1/auth/register", new int[]{5, 60},
|
||||
"/api/v1/auth/forgot-password", new int[]{3, 10},
|
||||
"/api/v1/auth/reset-password", new int[]{10, 15}
|
||||
);
|
||||
|
||||
private final RateLimiterService rateLimiterService;
|
||||
private final ApiErrorResponder apiErrorResponder;
|
||||
|
||||
public RateLimitFilter(RateLimiterService rateLimiterService, ApiErrorResponder apiErrorResponder) {
|
||||
this.rateLimiterService = rateLimiterService;
|
||||
this.apiErrorResponder = apiErrorResponder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
int[] rule = RULES.get(path);
|
||||
|
||||
if (rule != null) {
|
||||
String ip = extractIp(request);
|
||||
String key = path + ":" + ip;
|
||||
if (!rateLimiterService.isAllowed(key, rule[0], Duration.ofMinutes(rule[1]))) {
|
||||
apiErrorResponder.write(response, HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Too many requests. Please try again later.", null, path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractIp(HttpServletRequest request) {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (forwarded != null && !forwarded.isBlank()) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.petshop.backend.security;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class RateLimiterService {
|
||||
|
||||
private final Map<String, Deque<Instant>> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
public boolean isAllowed(String key, int maxRequests, Duration window) {
|
||||
Instant now = Instant.now();
|
||||
Instant windowStart = now.minus(window);
|
||||
|
||||
Deque<Instant> timestamps = buckets.computeIfAbsent(key, k -> new ArrayDeque<>());
|
||||
synchronized (timestamps) {
|
||||
while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(windowStart)) {
|
||||
timestamps.pollFirst();
|
||||
}
|
||||
if (timestamps.size() >= maxRequests) {
|
||||
return false;
|
||||
}
|
||||
timestamps.addLast(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 300_000)
|
||||
public void evictStale() {
|
||||
Instant cutoff = Instant.now().minus(Duration.ofHours(2));
|
||||
buckets.entrySet().removeIf(entry -> {
|
||||
Deque<Instant> timestamps = entry.getValue();
|
||||
synchronized (timestamps) {
|
||||
return timestamps.isEmpty() || timestamps.peekLast().isBefore(cutoff);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,18 @@ import java.util.List;
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthFilter;
|
||||
private final RateLimitFilter rateLimitFilter;
|
||||
private final UserDetailsService userDetailsService;
|
||||
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
|
||||
private final RestAccessDeniedHandler restAccessDeniedHandler;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
|
||||
RateLimitFilter rateLimitFilter,
|
||||
UserDetailsService userDetailsService,
|
||||
RestAuthenticationEntryPoint restAuthenticationEntryPoint,
|
||||
RestAccessDeniedHandler restAccessDeniedHandler) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
this.rateLimitFilter = rateLimitFilter;
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
|
||||
this.restAccessDeniedHandler = restAccessDeniedHandler;
|
||||
@@ -76,6 +79,7 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authenticationProvider(daoAuthenticationProvider())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
http.addFilterBefore(rateLimitFilter, JwtAuthenticationFilter.class);
|
||||
http.addFilterAfter(activityLoggingFilter, JwtAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -52,6 +53,11 @@ public class ActivityLogService {
|
||||
entry.setStoreNameSnapshot(store != null ? store.getStoreName() : null);
|
||||
entry.setActivity(activity.trim());
|
||||
activityLogRepository.save(entry);
|
||||
log.info("[ACTIVITY] {} | {} | {} | {}",
|
||||
entry.getRoleSnapshot(),
|
||||
entry.getUsernameSnapshot(),
|
||||
entry.getStoreNameSnapshot() != null ? entry.getStoreNameSnapshot() : "no store",
|
||||
entry.getActivity());
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to persist activity log", ex);
|
||||
}
|
||||
@@ -65,7 +71,7 @@ public class ActivityLogService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search) {
|
||||
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search, LocalDate startDate, LocalDate endDate) {
|
||||
Specification<ActivityLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
@@ -87,6 +93,14 @@ public class ActivityLogService {
|
||||
predicates.add(searchPredicate);
|
||||
}
|
||||
|
||||
if (startDate != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("logTimestamp"), startDate.atStartOfDay()));
|
||||
}
|
||||
|
||||
if (endDate != null) {
|
||||
predicates.add(cb.lessThan(root.get("logTimestamp"), endDate.plusDays(1).atStartOfDay()));
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
|
||||
@@ -96,9 +110,14 @@ public class ActivityLogService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivityLogResponse> getLogs(int limit, Long storeId, String role, String search) {
|
||||
return getLogs(limit, storeId, role, search, null, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ActivityLogResponse> getLogs(int limit) {
|
||||
return getLogs(limit, null, null, null);
|
||||
return getLogs(limit, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private ActivityLogResponse toResponse(ActivityLog entry) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class AdoptionService {
|
||||
@@ -39,13 +40,15 @@ public class AdoptionService {
|
||||
private final UserRepository userRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final SaleRepository saleRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository) {
|
||||
public AdoptionService(AdoptionRepository adoptionRepository, PetRepository petRepository, UserRepository userRepository, StoreRepository storeRepository, SaleRepository saleRepository, EmailService emailService) {
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.petRepository = petRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.saleRepository = saleRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
public Page<AdoptionResponse> getAllAdoptions(String query, Long customerId, String status, Long storeId, LocalDate date, Pageable pageable) {
|
||||
@@ -106,6 +109,9 @@ public class AdoptionService {
|
||||
if (ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
|
||||
createSaleForAdoption(adoption, request.getPaymentMethod());
|
||||
}
|
||||
if (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
|
||||
emailService.sendAdoptionConfirmation(adoption);
|
||||
}
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@@ -125,6 +131,7 @@ public class AdoptionService {
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId()))
|
||||
: null;
|
||||
boolean wasCompleted = ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoption.getAdoptionStatus());
|
||||
String previousStatus = adoption.getAdoptionStatus();
|
||||
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
|
||||
Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null;
|
||||
validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId);
|
||||
@@ -144,6 +151,10 @@ public class AdoptionService {
|
||||
if (!wasCompleted && ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus)) {
|
||||
createSaleForAdoption(adoption, request.getPaymentMethod());
|
||||
}
|
||||
boolean statusChanged = !adoptionStatus.equalsIgnoreCase(previousStatus);
|
||||
if (statusChanged && (ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoptionStatus) || ADOPTION_STATUS_COMPLETED.equalsIgnoreCase(adoptionStatus))) {
|
||||
emailService.sendAdoptionConfirmation(adoption);
|
||||
}
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@@ -200,15 +211,37 @@ public class AdoptionService {
|
||||
|
||||
@Transactional
|
||||
public void deleteAdoption(Long id) {
|
||||
if (!adoptionRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("Adoption not found with id: " + id);
|
||||
}
|
||||
adoptionRepository.deleteById(id);
|
||||
Adoption adoption = adoptionRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + id));
|
||||
Pet pet = adoption.getPet();
|
||||
String status = adoption.getAdoptionStatus();
|
||||
adoptionRepository.delete(adoption);
|
||||
resetPetIfPending(pet, status);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteAdoptions(BulkDeleteRequest request) {
|
||||
adoptionRepository.deleteAllById(request.getIds());
|
||||
List<Adoption> adoptions = adoptionRepository.findAllById(request.getIds());
|
||||
adoptionRepository.deleteAll(adoptions);
|
||||
for (Adoption adoption : adoptions) {
|
||||
resetPetIfPending(adoption.getPet(), adoption.getAdoptionStatus());
|
||||
}
|
||||
}
|
||||
|
||||
private void resetPetIfPending(Pet pet, String deletedAdoptionStatus) {
|
||||
if (!ADOPTION_STATUS_PENDING.equalsIgnoreCase(deletedAdoptionStatus)) {
|
||||
return;
|
||||
}
|
||||
if (!PET_STATUS_PENDING.equalsIgnoreCase(pet.getPetStatus())) {
|
||||
return;
|
||||
}
|
||||
boolean completedElsewhere = adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCase(
|
||||
pet.getPetId(), ADOPTION_STATUS_COMPLETED);
|
||||
if (!completedElsewhere) {
|
||||
pet.setPetStatus(PET_STATUS_AVAILABLE);
|
||||
pet.setOwner(null);
|
||||
petRepository.save(pet);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeFilter(String value) {
|
||||
@@ -309,7 +342,8 @@ public class AdoptionService {
|
||||
sale.setPaymentMethod(paymentMethod != null && !paymentMethod.isBlank() ? paymentMethod : "Cash");
|
||||
sale.setIsRefund(false);
|
||||
sale.setChannel("ADOPTION");
|
||||
saleRepository.save(sale);
|
||||
Sale savedSale = saleRepository.save(sale);
|
||||
emailService.sendPurchaseReceipt(savedSale);
|
||||
}
|
||||
|
||||
private void syncPetStatus(Pet pet, String adoptionStatus, Long adoptionId, User customer) {
|
||||
|
||||
@@ -3,11 +3,14 @@ package com.petshop.backend.service;
|
||||
import com.petshop.backend.dto.appointment.AppointmentRequest;
|
||||
import com.petshop.backend.dto.appointment.AppointmentResponse;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.entity.Adoption;
|
||||
import com.petshop.backend.entity.Appointment;
|
||||
import com.petshop.backend.entity.Pet;
|
||||
import com.petshop.backend.entity.StoreLocation;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.BusinessException;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.AdoptionRepository;
|
||||
import com.petshop.backend.repository.AppointmentRepository;
|
||||
import com.petshop.backend.repository.PetRepository;
|
||||
import com.petshop.backend.repository.ServiceRepository;
|
||||
@@ -26,6 +29,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -36,13 +40,17 @@ public class AppointmentService {
|
||||
private final PetRepository petRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AdoptionRepository adoptionRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository) {
|
||||
public AppointmentService(AppointmentRepository appointmentRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, AdoptionRepository adoptionRepository, EmailService emailService) {
|
||||
this.appointmentRepository = appointmentRepository;
|
||||
this.serviceRepository = serviceRepository;
|
||||
this.petRepository = petRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -118,6 +126,7 @@ public class AppointmentService {
|
||||
validateSpeciesServiceCompatibility(pet, service);
|
||||
|
||||
validateStoreAccess(store.getStoreId(), authenticatedUser);
|
||||
validatePetServiceCompatibility(pet, service);
|
||||
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
|
||||
|
||||
Appointment appointment = new Appointment();
|
||||
@@ -131,6 +140,7 @@ public class AppointmentService {
|
||||
appointment.setPet(pet);
|
||||
|
||||
appointment = appointmentRepository.save(appointment);
|
||||
emailService.sendAppointmentConfirmation(appointment);
|
||||
return mapToResponse(appointment);
|
||||
}
|
||||
|
||||
@@ -156,6 +166,7 @@ public class AppointmentService {
|
||||
User employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId());
|
||||
|
||||
validateStoreAccess(store.getStoreId(), authenticatedUser);
|
||||
validatePetServiceCompatibility(pet, service);
|
||||
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), id);
|
||||
|
||||
appointment.setCustomer(customer);
|
||||
@@ -168,6 +179,7 @@ public class AppointmentService {
|
||||
appointment.setEmployee(employee);
|
||||
|
||||
appointment = appointmentRepository.save(appointment);
|
||||
emailService.sendAppointmentConfirmation(appointment);
|
||||
return mapToResponse(appointment);
|
||||
}
|
||||
|
||||
@@ -243,7 +255,7 @@ public class AppointmentService {
|
||||
return availableSlots;
|
||||
}
|
||||
|
||||
//Update booked status to completed at every midnight
|
||||
//Update booked status to completed at every midnight, and send 24h reminders
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
@Transactional
|
||||
public void updatePastAppointmentsStatus() {
|
||||
@@ -255,6 +267,20 @@ public class AppointmentService {
|
||||
appointment.setAppointmentStatus("COMPLETED");
|
||||
appointmentRepository.save(appointment);
|
||||
}
|
||||
|
||||
LocalDate tomorrow = currentDate.plusDays(1);
|
||||
|
||||
List<Appointment> tomorrowAppointments = appointmentRepository
|
||||
.findByAppointmentDateAndAppointmentStatusIgnoreCase(tomorrow, "Booked");
|
||||
for (Appointment appointment : tomorrowAppointments) {
|
||||
emailService.sendAppointmentReminder(appointment);
|
||||
}
|
||||
|
||||
List<Adoption> tomorrowAdoptions = adoptionRepository
|
||||
.findByAdoptionDateAndAdoptionStatusIgnoreCase(tomorrow, "Pending");
|
||||
for (Adoption adoption : tomorrowAdoptions) {
|
||||
emailService.sendAdoptionReminder(adoption);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeFilter(String value) {
|
||||
@@ -265,6 +291,17 @@ public class AppointmentService {
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private void validatePetServiceCompatibility(Pet pet, com.petshop.backend.entity.Service service) {
|
||||
if (pet == null) return;
|
||||
Set<String> allowed = service.getSpecies();
|
||||
if (allowed == null || allowed.isEmpty()) return;
|
||||
boolean compatible = allowed.stream().anyMatch(s -> s.equalsIgnoreCase(pet.getPetSpecies()));
|
||||
if (!compatible) {
|
||||
throw new BusinessException(
|
||||
"Service \"" + service.getServiceName() + "\" is not available for " + pet.getPetSpecies());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAppointmentRequest(AppointmentRequest request) {
|
||||
if ("Booked".equalsIgnoreCase(request.getAppointmentStatus())) {
|
||||
LocalDateTime appointmentDateTime = LocalDateTime.of(request.getAppointmentDate(), request.getAppointmentTime());
|
||||
|
||||
@@ -22,7 +22,6 @@ import java.util.UUID;
|
||||
public class AvatarStorageService {
|
||||
|
||||
private static final String STORED_PREFIX = "/uploads/avatars/";
|
||||
private static final String OWNER_ENDPOINT = "/api/v1/auth/me/avatar/file";
|
||||
|
||||
@Value("${app.upload.base-dir:uploads}")
|
||||
private String uploadBaseDir;
|
||||
@@ -65,7 +64,7 @@ public class AvatarStorageService {
|
||||
}
|
||||
|
||||
public String toOwnerAvatarUrl(User user) {
|
||||
return hasAvatar(user) ? OWNER_ENDPOINT : null;
|
||||
return hasAvatar(user) ? "/api/v1/users/" + user.getId() + "/avatar/file" : null;
|
||||
}
|
||||
|
||||
public String toStoredAvatarUrl(String avatarFilenamePath) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.petshop.backend.entity.Conversation;
|
||||
import com.petshop.backend.entity.Message;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.util.ContentFilter;
|
||||
import com.petshop.backend.repository.ConversationRepository;
|
||||
import com.petshop.backend.repository.MessageRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
@@ -31,17 +32,20 @@ public class ChatService {
|
||||
private final UserRepository userRepository;
|
||||
private final AvatarStorageService avatarStorageService;
|
||||
private final ChatAttachmentStorageService attachmentStorageService;
|
||||
private final EmailService emailService;
|
||||
|
||||
public ChatService(ConversationRepository conversationRepository,
|
||||
MessageRepository messageRepository,
|
||||
UserRepository userRepository,
|
||||
AvatarStorageService avatarStorageService,
|
||||
ChatAttachmentStorageService attachmentStorageService) {
|
||||
ChatAttachmentStorageService attachmentStorageService,
|
||||
EmailService emailService) {
|
||||
this.conversationRepository = conversationRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.avatarStorageService = avatarStorageService;
|
||||
this.attachmentStorageService = attachmentStorageService;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -135,6 +139,8 @@ public class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
ContentFilter.validate(request.getContent());
|
||||
|
||||
Message message = new Message();
|
||||
message.setConversationId(conversationId);
|
||||
message.setSenderId(userId);
|
||||
@@ -261,13 +267,19 @@ public class ChatService {
|
||||
}
|
||||
|
||||
conversation.setStatus(Conversation.ConversationStatus.valueOf(request.getStatus()));
|
||||
conversation = conversationRepository.save(conversation);
|
||||
Conversation savedConversation = conversationRepository.save(conversation);
|
||||
|
||||
List<Message> messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId);
|
||||
|
||||
if (Conversation.ConversationStatus.CLOSED.name().equals(request.getStatus())) {
|
||||
userRepository.findById(savedConversation.getCustomerId()).ifPresent(customer ->
|
||||
emailService.sendChatTranscript(savedConversation, messages, customer));
|
||||
}
|
||||
|
||||
Message last = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
||||
String lastMessage = last != null && last.getContent() != null ? last.getContent() : "";
|
||||
Long lastSenderId = last != null ? last.getSenderId() : null;
|
||||
return ConversationResponse.fromEntity(conversation, lastMessage, lastSenderId);
|
||||
return ConversationResponse.fromEntity(savedConversation, lastMessage, lastSenderId);
|
||||
}
|
||||
|
||||
public List<MessageResponse> getMessages(Long conversationId, Long userId, User.Role role) {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.entity.Adoption;
|
||||
import com.petshop.backend.entity.Appointment;
|
||||
import com.petshop.backend.entity.Conversation;
|
||||
import com.petshop.backend.entity.Message;
|
||||
import com.petshop.backend.entity.Sale;
|
||||
import com.petshop.backend.entity.SaleItem;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.resend.Resend;
|
||||
import com.resend.services.emails.model.CreateEmailOptions;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("MMMM d, yyyy");
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("h:mm a");
|
||||
|
||||
private final Resend resend;
|
||||
private final String from;
|
||||
private final String frontendUrl;
|
||||
private final ActivityLogService activityLogService;
|
||||
|
||||
public EmailService(
|
||||
@Value("${resend.api-key}") String apiKey,
|
||||
@Value("${resend.from}") String from,
|
||||
@Value("${app.frontend-url}") String frontendUrl,
|
||||
ActivityLogService activityLogService) {
|
||||
this.resend = new Resend(apiKey);
|
||||
this.from = from;
|
||||
this.frontendUrl = frontendUrl;
|
||||
this.activityLogService = activityLogService;
|
||||
}
|
||||
|
||||
public void sendWelcome(User user) {
|
||||
if (user.getEmail() == null || user.getEmail().isBlank()) return;
|
||||
String subject = "Welcome to PetShop!";
|
||||
String html = """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Welcome to PetShop, %s!</h2>
|
||||
<p>Your account has been created successfully.</p>
|
||||
<p>You can now log in and start exploring our pets, products, and services.</p>
|
||||
<p>Thank you for joining us!</p>
|
||||
</div>""".formatted(firstName(user));
|
||||
send(user.getId(), user.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
public void sendPasswordResetLink(User user, String rawToken) {
|
||||
if (user.getEmail() == null || user.getEmail().isBlank()) return;
|
||||
String link = frontendUrl + "/reset-password?token=" + rawToken;
|
||||
String subject = "Reset your PetShop password";
|
||||
String html = """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>Hi %s,</p>
|
||||
<p>We received a request to reset your password. Click the button below to proceed.</p>
|
||||
<p><a href="%s" style="background:#2563eb;color:#fff;padding:10px 20px;border-radius:4px;text-decoration:none;display:inline-block">Reset Password</a></p>
|
||||
<p>This link expires in <strong>30 minutes</strong>. If you did not request a reset, you can safely ignore this email.</p>
|
||||
</div>""".formatted(firstName(user), link);
|
||||
send(user.getId(), user.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
public void sendPurchaseReceipt(Sale sale) {
|
||||
User customer = sale.getCustomer();
|
||||
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
|
||||
String subject = "Your PetShop receipt";
|
||||
String html = buildReceiptHtml(sale);
|
||||
send(customer.getId(), customer.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
public void sendAdoptionConfirmation(Adoption adoption) {
|
||||
User customer = adoption.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Your adoption — " + adoption.getPet().getPetName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAdoptionHtml(adoption, customer));
|
||||
}
|
||||
User employee = adoption.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Adoption assigned — " + adoption.getPet().getPetName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAdoptionHtml(adoption, customer));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendAdoptionReminder(Adoption adoption) {
|
||||
User customer = adoption.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAdoptionReminderHtml(adoption));
|
||||
}
|
||||
User employee = adoption.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Reminder: adoption tomorrow — " + adoption.getPet().getPetName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAdoptionReminderHtml(adoption));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendAppointmentConfirmation(Appointment appointment) {
|
||||
User customer = appointment.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Appointment confirmed — " + appointment.getService().getServiceName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAppointmentHtml(appointment));
|
||||
}
|
||||
User employee = appointment.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Appointment assigned — " + appointment.getService().getServiceName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAppointmentHtml(appointment));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendAppointmentReminder(Appointment appointment) {
|
||||
User customer = appointment.getCustomer();
|
||||
if (customer != null && customer.getEmail() != null && !customer.getEmail().isBlank()) {
|
||||
String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName();
|
||||
send(customer.getId(), customer.getEmail(), subject, buildAppointmentReminderHtml(appointment));
|
||||
}
|
||||
User employee = appointment.getEmployee();
|
||||
if (employee != null && employee.getEmail() != null && !employee.getEmail().isBlank()) {
|
||||
String subject = "Reminder: appointment tomorrow — " + appointment.getService().getServiceName();
|
||||
send(employee.getId(), employee.getEmail(), subject, buildAppointmentReminderHtml(appointment));
|
||||
}
|
||||
}
|
||||
|
||||
public void sendChatTranscript(Conversation conversation, List<Message> messages, User customer) {
|
||||
if (customer == null || customer.getEmail() == null || customer.getEmail().isBlank()) return;
|
||||
String subject = "Your PetShop support transcript";
|
||||
String html = buildTranscriptHtml(conversation, messages, customer);
|
||||
send(customer.getId(), customer.getEmail(), subject, html);
|
||||
}
|
||||
|
||||
private void send(Long recipientUserId, String to, String subject, String html) {
|
||||
try {
|
||||
CreateEmailOptions options = CreateEmailOptions.builder()
|
||||
.from(from)
|
||||
.to(List.of(to))
|
||||
.subject(subject)
|
||||
.html(html)
|
||||
.build();
|
||||
resend.emails().send(options);
|
||||
activityLogService.record(recipientUserId, "Email sent: " + subject + " → " + to);
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to send email '{}' to {}: {}", subject, to, ex.getMessage());
|
||||
activityLogService.record(recipientUserId, "Email failed: " + subject + " → " + to);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildReceiptHtml(Sale sale) {
|
||||
StringBuilder rows = new StringBuilder();
|
||||
List<SaleItem> items = sale.getItems();
|
||||
if (items != null) {
|
||||
for (SaleItem item : items) {
|
||||
String name = item.getProduct() != null ? item.getProduct().getProdName() : "—";
|
||||
rows.append("<tr><td style='padding:6px 8px'>").append(esc(name)).append("</td>")
|
||||
.append("<td style='padding:6px 8px;text-align:center'>").append(item.getQuantity()).append("</td>")
|
||||
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice())).append("</td>")
|
||||
.append("<td style='padding:6px 8px;text-align:right'>$").append(fmt(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))).append("</td></tr>");
|
||||
}
|
||||
}
|
||||
String date = sale.getSaleDate() != null ? sale.getSaleDate().format(DateTimeFormatter.ofPattern("MMMM d, yyyy h:mm a")) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Thank you for your purchase!</h2>
|
||||
<p><strong>Date:</strong> %s</p>
|
||||
<p><strong>Payment method:</strong> %s</p>
|
||||
<table style="width:100%%;border-collapse:collapse;margin:16px 0">
|
||||
<thead><tr style="background:#f3f4f6">
|
||||
<th style="padding:6px 8px;text-align:left">Item</th>
|
||||
<th style="padding:6px 8px;text-align:center">Qty</th>
|
||||
<th style="padding:6px 8px;text-align:right">Unit price</th>
|
||||
<th style="padding:6px 8px;text-align:right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
<p><strong>Subtotal:</strong> $%s</p>
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
<p style="font-size:1.1em"><strong>Total:</strong> $%s</p>
|
||||
%s
|
||||
</div>""".formatted(
|
||||
date,
|
||||
esc(sale.getPaymentMethod()),
|
||||
rows.toString(),
|
||||
fmt(sale.getSubtotalAmount()),
|
||||
discountLine("Coupon discount", sale.getCouponDiscountAmount()),
|
||||
discountLine("Employee discount", sale.getEmployeeDiscountAmount()),
|
||||
discountLine("Loyalty discount", sale.getLoyaltyDiscountAmount()),
|
||||
fmt(sale.getTotalAmount()),
|
||||
sale.getPointsEarned() != null && sale.getPointsEarned() > 0
|
||||
? "<p>You earned <strong>" + sale.getPointsEarned() + " loyalty points</strong> on this order.</p>"
|
||||
: ""
|
||||
);
|
||||
}
|
||||
|
||||
private String buildAdoptionHtml(Adoption adoption, User customer) {
|
||||
String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—";
|
||||
String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—";
|
||||
String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—";
|
||||
String customerName = customer != null ? firstName(customer) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Adoption update — %s</h2>
|
||||
<p><strong>Customer:</strong> %s</p>
|
||||
<p><strong>Pet:</strong> %s</p>
|
||||
<p><strong>Date:</strong> %s</p>
|
||||
<p><strong>Store:</strong> %s</p>
|
||||
<p><strong>Status:</strong> %s</p>
|
||||
</div>""".formatted(esc(petName), esc(customerName), esc(petName), date, esc(storeName), esc(adoption.getAdoptionStatus()));
|
||||
}
|
||||
|
||||
private String buildAdoptionReminderHtml(Adoption adoption) {
|
||||
String petName = adoption.getPet() != null ? adoption.getPet().getPetName() : "—";
|
||||
String storeName = adoption.getSourceStore() != null ? adoption.getSourceStore().getStoreName() : "—";
|
||||
String date = adoption.getAdoptionDate() != null ? adoption.getAdoptionDate().format(DATE_FMT) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Reminder: adoption tomorrow</h2>
|
||||
<p>This is a reminder that the adoption of <strong>%s</strong> is scheduled for <strong>%s</strong> at <strong>%s</strong>.</p>
|
||||
</div>""".formatted(esc(petName), date, esc(storeName));
|
||||
}
|
||||
|
||||
private String buildAppointmentHtml(Appointment appointment) {
|
||||
String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—";
|
||||
String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—";
|
||||
String date = appointment.getAppointmentDate() != null ? appointment.getAppointmentDate().format(DATE_FMT) : "—";
|
||||
String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "—";
|
||||
String employee = appointment.getEmployee() != null
|
||||
? appointment.getEmployee().getFirstName() + " " + appointment.getEmployee().getLastName() : "—";
|
||||
String pet = appointment.getPet() != null ? appointment.getPet().getPetName() : null;
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Appointment confirmed</h2>
|
||||
<p><strong>Service:</strong> %s</p>
|
||||
<p><strong>Date:</strong> %s</p>
|
||||
<p><strong>Time:</strong> %s</p>
|
||||
<p><strong>Location:</strong> %s</p>
|
||||
<p><strong>Staff:</strong> %s</p>
|
||||
%s
|
||||
</div>""".formatted(esc(service), date, time, esc(store), esc(employee),
|
||||
pet != null ? "<p><strong>Pet:</strong> " + esc(pet) + "</p>" : "");
|
||||
}
|
||||
|
||||
private String buildAppointmentReminderHtml(Appointment appointment) {
|
||||
String service = appointment.getService() != null ? appointment.getService().getServiceName() : "—";
|
||||
String store = appointment.getStore() != null ? appointment.getStore().getStoreName() : "—";
|
||||
String time = appointment.getAppointmentTime() != null ? appointment.getAppointmentTime().format(TIME_FMT) : "—";
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:600px;margin:auto">
|
||||
<h2>Reminder: appointment tomorrow</h2>
|
||||
<p>Your <strong>%s</strong> appointment is scheduled for tomorrow at <strong>%s</strong> at <strong>%s</strong>.</p>
|
||||
</div>""".formatted(esc(service), time, esc(store));
|
||||
}
|
||||
|
||||
private String buildTranscriptHtml(Conversation conversation, List<Message> messages, User customer) {
|
||||
StringBuilder rows = new StringBuilder();
|
||||
for (Message msg : messages) {
|
||||
String sender = msg.getSenderId() != null && msg.getSenderId().equals(customer.getId())
|
||||
? firstName(customer) : "Support";
|
||||
String ts = msg.getTimestamp() != null
|
||||
? msg.getTimestamp().format(DateTimeFormatter.ofPattern("MMM d, h:mm a")) : "";
|
||||
String content = msg.getContent() != null ? esc(msg.getContent()) : "";
|
||||
rows.append("<tr><td style='padding:6px 8px;white-space:nowrap'>").append(ts).append("</td>")
|
||||
.append("<td style='padding:6px 8px;white-space:nowrap'>").append(esc(sender)).append("</td>")
|
||||
.append("<td style='padding:6px 8px'>").append(content).append("</td></tr>");
|
||||
}
|
||||
return """
|
||||
<div style="font-family:sans-serif;max-width:700px;margin:auto">
|
||||
<h2>Support chat transcript</h2>
|
||||
<p>Hi %s, here is the transcript of your support conversation.</p>
|
||||
<table style="width:100%%;border-collapse:collapse;margin:16px 0;font-size:0.9em">
|
||||
<thead><tr style="background:#f3f4f6">
|
||||
<th style="padding:6px 8px;text-align:left">Time</th>
|
||||
<th style="padding:6px 8px;text-align:left">Sender</th>
|
||||
<th style="padding:6px 8px;text-align:left">Message</th>
|
||||
</tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
</div>""".formatted(firstName(customer), rows.toString());
|
||||
}
|
||||
|
||||
private String firstName(User user) {
|
||||
if (user.getFirstName() != null && !user.getFirstName().isBlank()) return esc(user.getFirstName());
|
||||
if (user.getFullName() != null && !user.getFullName().isBlank()) return esc(user.getFullName().split("\\s+")[0]);
|
||||
return esc(user.getUsername());
|
||||
}
|
||||
|
||||
private String fmt(BigDecimal value) {
|
||||
return value != null ? String.format("%.2f", value) : "0.00";
|
||||
}
|
||||
|
||||
private String discountLine(String label, BigDecimal value) {
|
||||
if (value == null || value.compareTo(BigDecimal.ZERO) == 0) return "";
|
||||
return "<p><strong>" + label + ":</strong> -$" + fmt(value) + "</p>";
|
||||
}
|
||||
|
||||
private String esc(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,17 @@ public class PasswordResetService {
|
||||
private final PasswordResetTokenRepository passwordResetTokenRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final EmailService emailService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
|
||||
UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
PasswordEncoder passwordEncoder,
|
||||
EmailService emailService) {
|
||||
this.passwordResetTokenRepository = passwordResetTokenRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -66,9 +69,11 @@ public class PasswordResetService {
|
||||
resetToken.setExpiresAt(now.plusMinutes(RESET_TOKEN_MINUTES));
|
||||
passwordResetTokenRepository.save(resetToken);
|
||||
|
||||
emailService.sendPasswordResetLink(managedUser, rawToken);
|
||||
|
||||
return new ForgotPasswordResponse(
|
||||
"If an account matches that username or email, a reset token has been generated.",
|
||||
rawToken
|
||||
"If an account matches that username or email, a reset link has been sent.",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.petshop.backend.dto.product.ProductRequest;
|
||||
import com.petshop.backend.dto.product.ProductResponse;
|
||||
import com.petshop.backend.entity.Category;
|
||||
import com.petshop.backend.entity.Product;
|
||||
import com.petshop.backend.exception.BusinessException;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.CategoryRepository;
|
||||
import com.petshop.backend.repository.ProductRepository;
|
||||
@@ -45,6 +46,10 @@ public class ProductService {
|
||||
|
||||
@Transactional
|
||||
public ProductResponse createProduct(ProductRequest request) {
|
||||
if (productRepository.existsByProdNameIgnoreCase(request.getProdName())) {
|
||||
throw new BusinessException("A product with this name already exists");
|
||||
}
|
||||
|
||||
Category category = categoryRepository.findById(request.getCategoryId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
||||
|
||||
@@ -63,6 +68,10 @@ public class ProductService {
|
||||
Product product = productRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
|
||||
|
||||
if (productRepository.existsByProdNameIgnoreCaseAndProdIdNot(request.getProdName(), id)) {
|
||||
throw new BusinessException("A product with this name already exists");
|
||||
}
|
||||
|
||||
Category category = categoryRepository.findById(request.getCategoryId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + request.getCategoryId()));
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ public class PurchaseOrderService {
|
||||
store != null ? store.getStoreId() : null,
|
||||
store != null ? store.getStoreName() : null,
|
||||
purchaseOrder.getOrderDate(),
|
||||
purchaseOrder.getStatus(),
|
||||
purchaseOrder.getCreatedAt(),
|
||||
purchaseOrder.getUpdatedAt()
|
||||
);
|
||||
|
||||
@@ -31,8 +31,9 @@ public class SaleService {
|
||||
private final UserRepository userRepository;
|
||||
private final CouponRepository couponRepository;
|
||||
private final CartRepository cartRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository) {
|
||||
public SaleService(SaleRepository saleRepository, ProductRepository productRepository, StoreRepository storeRepository, InventoryRepository inventoryRepository, UserRepository userRepository, CouponRepository couponRepository, CartRepository cartRepository, EmailService emailService) {
|
||||
this.saleRepository = saleRepository;
|
||||
this.productRepository = productRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
@@ -40,11 +41,12 @@ public class SaleService {
|
||||
this.userRepository = userRepository;
|
||||
this.couponRepository = couponRepository;
|
||||
this.cartRepository = cartRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<SaleResponse> getAllSales(String query, String paymentMethod, Long storeId, Boolean isRefund, Pageable pageable) {
|
||||
Page<Sale> sales = saleRepository.searchSales(normalizeFilter(query), normalizeFilter(paymentMethod), storeId, isRefund, pageable);
|
||||
public Page<SaleResponse> getAllSales(String query, String paymentMethod, Long storeId, Boolean isRefund, Long customerId, Pageable pageable) {
|
||||
Page<Sale> sales = saleRepository.searchSales(normalizeFilter(query), normalizeFilter(paymentMethod), storeId, isRefund, customerId, pageable);
|
||||
return sales.map(this::mapToResponse);
|
||||
}
|
||||
|
||||
@@ -162,10 +164,41 @@ public class SaleService {
|
||||
}
|
||||
subtotalAmount = subtotalAmount.negate();
|
||||
sale.setSubtotalAmount(subtotalAmount);
|
||||
sale.setTotalAmount(subtotalAmount);
|
||||
sale.setCouponDiscountAmount(BigDecimal.ZERO);
|
||||
|
||||
Sale originalSale = sale.getOriginalSale();
|
||||
BigDecimal originalSubtotal = originalSale.getSubtotalAmount();
|
||||
BigDecimal loyaltyDiscountRefunded = BigDecimal.ZERO;
|
||||
BigDecimal couponDiscountRefunded = BigDecimal.ZERO;
|
||||
BigDecimal refundTotal;
|
||||
|
||||
if (originalSubtotal != null && originalSubtotal.compareTo(BigDecimal.ZERO) > 0) {
|
||||
BigDecimal ratio = subtotalAmount.divide(originalSubtotal, 10, RoundingMode.HALF_UP);
|
||||
refundTotal = originalSale.getTotalAmount().abs().multiply(ratio).negate().setScale(2, RoundingMode.HALF_UP);
|
||||
if (originalSale.getLoyaltyDiscountAmount() != null) {
|
||||
loyaltyDiscountRefunded = originalSale.getLoyaltyDiscountAmount().multiply(ratio).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
if (originalSale.getCouponDiscountAmount() != null) {
|
||||
couponDiscountRefunded = originalSale.getCouponDiscountAmount().multiply(ratio).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
User refundCustomer = originalSale.getCustomer();
|
||||
if (refundCustomer != null) {
|
||||
sale.setCustomer(refundCustomer);
|
||||
int pointsToRestore = toPointsUsed(loyaltyDiscountRefunded);
|
||||
int pointsEarnedToReverse = originalSale.getPointsEarned() != null
|
||||
? ratio.multiply(BigDecimal.valueOf(originalSale.getPointsEarned())).setScale(0, RoundingMode.FLOOR).intValue()
|
||||
: 0;
|
||||
int currentPoints = refundCustomer.getLoyaltyPoints() != null ? refundCustomer.getLoyaltyPoints() : 0;
|
||||
refundCustomer.setLoyaltyPoints(Math.max(0, currentPoints + pointsToRestore - pointsEarnedToReverse));
|
||||
userRepository.save(refundCustomer);
|
||||
}
|
||||
} else {
|
||||
refundTotal = subtotalAmount.negate();
|
||||
}
|
||||
|
||||
sale.setTotalAmount(refundTotal);
|
||||
sale.setCouponDiscountAmount(couponDiscountRefunded);
|
||||
sale.setEmployeeDiscountAmount(BigDecimal.ZERO);
|
||||
sale.setLoyaltyDiscountAmount(BigDecimal.ZERO);
|
||||
sale.setLoyaltyDiscountAmount(loyaltyDiscountRefunded);
|
||||
sale.setPointsEarned(0);
|
||||
} else {
|
||||
if (request.getItems() == null || request.getItems().isEmpty()) {
|
||||
@@ -206,18 +239,29 @@ public class SaleService {
|
||||
BigDecimal employeeDiscount = calculateEmployeeDiscount(customer, subtotalAmount.subtract(couponDiscount));
|
||||
sale.setEmployeeDiscountAmount(employeeDiscount);
|
||||
|
||||
boolean useLoyaltyPoints = sale.getCart() != null && Boolean.TRUE.equals(sale.getCart().getPointsApplied());
|
||||
BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(customer, subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount), useLoyaltyPoints);
|
||||
BigDecimal remainingAfterDiscounts = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount);
|
||||
BigDecimal loyaltyDiscount;
|
||||
int pointsDeducted;
|
||||
if (request.getPointsUsed() != null && request.getPointsUsed() > 0) {
|
||||
loyaltyDiscount = BigDecimal.valueOf(request.getPointsUsed())
|
||||
.divide(BigDecimal.valueOf(LOYALTY_POINTS_PER_DOLLAR), 2, RoundingMode.HALF_UP)
|
||||
.min(remainingAfterDiscounts.max(BigDecimal.ZERO))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
pointsDeducted = request.getPointsUsed();
|
||||
} else {
|
||||
boolean useLoyaltyPoints = sale.getCart() != null && Boolean.TRUE.equals(sale.getCart().getPointsApplied());
|
||||
loyaltyDiscount = calculateLoyaltyDiscount(customer, remainingAfterDiscounts, useLoyaltyPoints);
|
||||
pointsDeducted = toPointsUsed(loyaltyDiscount);
|
||||
}
|
||||
sale.setLoyaltyDiscountAmount(loyaltyDiscount);
|
||||
|
||||
BigDecimal finalTotal = subtotalAmount.subtract(couponDiscount).subtract(employeeDiscount).subtract(loyaltyDiscount);
|
||||
sale.setTotalAmount(finalTotal.max(BigDecimal.ZERO));
|
||||
|
||||
int pointsUsed = toPointsUsed(loyaltyDiscount);
|
||||
sale.setPointsEarned(sale.getTotalAmount().setScale(0, RoundingMode.FLOOR).intValue());
|
||||
if (customer != null) {
|
||||
int currentPoints = customer.getLoyaltyPoints() != null ? customer.getLoyaltyPoints() : 0;
|
||||
int updatedPoints = currentPoints - pointsUsed + sale.getPointsEarned();
|
||||
int updatedPoints = currentPoints - pointsDeducted + sale.getPointsEarned();
|
||||
customer.setLoyaltyPoints(Math.max(updatedPoints, 0));
|
||||
userRepository.save(customer);
|
||||
}
|
||||
@@ -226,6 +270,11 @@ public class SaleService {
|
||||
sale.setItems(saleItems);
|
||||
|
||||
Sale savedSale = saleRepository.save(sale);
|
||||
|
||||
if (!Boolean.TRUE.equals(savedSale.getIsRefund()) && savedSale.getCustomer() != null) {
|
||||
emailService.sendPurchaseReceipt(savedSale);
|
||||
}
|
||||
|
||||
return mapToResponse(savedSale);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,10 @@ public class ServiceService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<ServiceResponse> getAllServices(String query, Pageable pageable) {
|
||||
Page<com.petshop.backend.entity.Service> services;
|
||||
if (query != null && !query.trim().isEmpty()) {
|
||||
services = serviceRepository.searchServices(query, pageable);
|
||||
} else {
|
||||
services = serviceRepository.findAll(pageable);
|
||||
}
|
||||
return services.map(this::mapToResponse);
|
||||
public Page<ServiceResponse> getAllServices(String query, String species, Pageable pageable) {
|
||||
String q = (query != null && !query.trim().isEmpty()) ? query.trim() : null;
|
||||
String sp = (species != null && !species.trim().isEmpty()) ? species.trim() : null;
|
||||
return serviceRepository.searchServices(q, sp, pageable).map(this::mapToResponse);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -3,21 +3,27 @@ package com.petshop.backend.service;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.dto.user.UserRequest;
|
||||
import com.petshop.backend.dto.user.UserResponse;
|
||||
import com.petshop.backend.repository.ActivityLogRepository;
|
||||
import com.petshop.backend.entity.StoreLocation;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.ActivityLogRepository;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.repository.StoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.util.AuthenticationHelper;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||
@@ -54,13 +60,26 @@ public class UserService {
|
||||
}
|
||||
|
||||
public UserResponse getUserById(Long id) {
|
||||
return getUserById(id, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UserResponse getUserById(Long id, User.Role requiredRole) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
|
||||
requireRoleOrNotFound(user, requiredRole, id);
|
||||
return mapToResponse(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse createUser(UserRequest request) {
|
||||
return createUser(request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse createUser(UserRequest request, User.Role requiredRole) {
|
||||
requireRequestedRole(request.getRole(), requiredRole);
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(trimToNull(request.getUsername()));
|
||||
if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) {
|
||||
@@ -88,9 +107,18 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public UserResponse updateUser(Long id, UserRequest request) {
|
||||
return updateUser(id, request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserResponse updateUser(Long id, UserRequest request, User.Role requiredRole) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
|
||||
|
||||
requireRoleOrNotFound(user, requiredRole, id);
|
||||
requireRequestedRole(request.getRole(), requiredRole);
|
||||
requireAdminMutationAllowed(user, request.getRole());
|
||||
|
||||
boolean invalidateToken =
|
||||
!Objects.equals(user.getUsername(), request.getUsername())
|
||||
|| user.getRole() != request.getRole()
|
||||
@@ -127,9 +155,17 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(Long id) {
|
||||
if (!userRepository.existsById(id)) {
|
||||
throw new ResourceNotFoundException("User not found with id: " + id);
|
||||
}
|
||||
deleteUser(id, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(Long id, User.Role requiredRole) {
|
||||
User user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
|
||||
|
||||
requireRoleOrNotFound(user, requiredRole, id);
|
||||
requireAdminDeletionAllowed(user);
|
||||
|
||||
if (activityLogRepository.existsByUser_Id(id)) {
|
||||
throw new ResponseStatusException(CONFLICT, "User cannot be deleted because activity logs exist");
|
||||
}
|
||||
@@ -138,7 +174,27 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteUsers(BulkDeleteRequest request) {
|
||||
if (request.getIds() != null && request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
|
||||
bulkDeleteUsers(request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void bulkDeleteUsers(BulkDeleteRequest request, User.Role requiredRole) {
|
||||
if (request.getIds() == null || request.getIds().isEmpty()) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "IDs list cannot be empty");
|
||||
}
|
||||
|
||||
Set<Long> requestedIds = new HashSet<>(request.getIds());
|
||||
ArrayList<User> usersToDelete = new ArrayList<>();
|
||||
userRepository.findAllById(requestedIds).forEach(usersToDelete::add);
|
||||
|
||||
if (usersToDelete.size() != requestedIds.size()) {
|
||||
throw new ResourceNotFoundException("One or more users not found for bulk delete");
|
||||
}
|
||||
|
||||
requireRoleOrNotFound(usersToDelete, requiredRole);
|
||||
requireAdminDeletionAllowed(usersToDelete);
|
||||
|
||||
if (request.getIds().stream().anyMatch(activityLogRepository::existsByUser_Id)) {
|
||||
throw new ResponseStatusException(CONFLICT, "One or more users cannot be deleted because activity logs exist");
|
||||
}
|
||||
userRepository.deleteAllById(request.getIds());
|
||||
@@ -169,6 +225,51 @@ public class UserService {
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + storeId));
|
||||
}
|
||||
|
||||
private void requireRoleOrNotFound(User user, User.Role requiredRole, Long id) {
|
||||
if (requiredRole != null && user.getRole() != requiredRole) {
|
||||
throw new ResourceNotFoundException("User not found with id: " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireRoleOrNotFound(Collection<User> users, User.Role requiredRole) {
|
||||
if (requiredRole == null) {
|
||||
return;
|
||||
}
|
||||
for (User user : users) {
|
||||
if (user.getRole() != requiredRole) {
|
||||
throw new ResourceNotFoundException("User not found with id: " + user.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireRequestedRole(User.Role requestedRole, User.Role requiredRole) {
|
||||
if (requiredRole != null && requestedRole != requiredRole) {
|
||||
throw new AccessDeniedException("Target user must have role " + requiredRole.name());
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdminMutationAllowed(User target, User.Role requestedRole) {
|
||||
User actor = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
if ((target.getRole() == User.Role.ADMIN && !actor.getId().equals(target.getId()))
|
||||
|| (requestedRole == User.Role.ADMIN && !actor.getId().equals(target.getId()))) {
|
||||
throw new AccessDeniedException("Admins cannot modify other admin accounts");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdminDeletionAllowed(User target) {
|
||||
if (target.getRole() == User.Role.ADMIN) {
|
||||
throw new AccessDeniedException("Admins cannot delete admin accounts");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireAdminDeletionAllowed(Collection<User> targets) {
|
||||
for (User target : targets) {
|
||||
if (target.getRole() == User.Role.ADMIN) {
|
||||
throw new AccessDeniedException("Admins cannot delete admin accounts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUniquePhone(String phone, Long currentUserId) {
|
||||
if (phone == null || phone.isBlank()) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.petshop.backend.util;
|
||||
|
||||
import com.petshop.backend.exception.BusinessException;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class ContentFilter {
|
||||
|
||||
private static final Pattern SCRIPT_PATTERN = Pattern.compile(
|
||||
"<script|javascript:|on\\w+\\s*=", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Set<String> PROFANITY = Set.of(
|
||||
"profanityOne", "profanityTwo", "profanityThree"
|
||||
);
|
||||
|
||||
public static void validate(String input) {
|
||||
if (input == null || input.isBlank()) return;
|
||||
if (SCRIPT_PATTERN.matcher(input).find()) {
|
||||
throw new BusinessException("Message contains prohibited content");
|
||||
}
|
||||
String lower = input.toLowerCase(Locale.ROOT);
|
||||
for (String word : PROFANITY) {
|
||||
if (lower.contains(word)) {
|
||||
throw new BusinessException("Message contains prohibited language");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ springdoc:
|
||||
app:
|
||||
upload:
|
||||
base-dir: ${UPLOAD_BASE_DIR:uploads}
|
||||
frontend-url: ${FRONTEND_URL:http://localhost:3000}
|
||||
|
||||
resend:
|
||||
api-key: ${RESEND_API_KEY:}
|
||||
from: ${RESEND_FROM:PetShop <onboarding@resend.dev>}
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
@@ -60,9 +65,11 @@ stripe:
|
||||
|
||||
openrouter:
|
||||
api-key: ${OPENROUTER_API_KEY:}
|
||||
model: ${OPENROUTER_MODEL:openai/gpt-oss-120b:free}
|
||||
model: ${OPENROUTER_MODEL:openrouter/free}
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: ${LOG_FILE:logs/app.log}
|
||||
level:
|
||||
com.petshop: ${LOG_LEVEL:INFO}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE appointment MODIFY petId BIGINT NULL;
|
||||
ALTER TABLE appointment DROP FOREIGN KEY fk_appointment_pet;
|
||||
ALTER TABLE appointment ADD CONSTRAINT fk_appointment_pet FOREIGN KEY (petId) REFERENCES pet(petId) ON DELETE SET NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE purchaseOrder DROP COLUMN status;
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.petshop.backend.service;
|
||||
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.dto.user.UserRequest;
|
||||
import com.petshop.backend.dto.user.UserResponse;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.ActivityLogRepository;
|
||||
import com.petshop.backend.repository.StoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
import com.petshop.backend.security.AppPrincipal;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private ActivityLogRepository activityLogRepository;
|
||||
@Mock private PasswordEncoder passwordEncoder;
|
||||
@Mock private StoreRepository storeRepository;
|
||||
|
||||
private UserService userService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userService = new UserService(userRepository, activityLogRepository, passwordEncoder, storeRepository);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserDeniesEditingAnotherAdmin() {
|
||||
User actor = user(1L, User.Role.ADMIN);
|
||||
User target = user(2L, User.Role.ADMIN);
|
||||
authenticate(actor);
|
||||
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
|
||||
|
||||
UserRequest request = request(User.Role.ADMIN);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request));
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserTreatsWrongScopedRoleAsNotFound() {
|
||||
User actor = user(1L, User.Role.ADMIN);
|
||||
User target = user(2L, User.Role.ADMIN);
|
||||
authenticate(actor);
|
||||
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
|
||||
|
||||
UserRequest request = request(User.Role.CUSTOMER);
|
||||
|
||||
assertThrows(ResourceNotFoundException.class, () -> userService.updateUser(2L, request, User.Role.CUSTOMER));
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserDeniesDeletingAnotherAdmin() {
|
||||
User actor = user(1L, User.Role.ADMIN);
|
||||
User target = user(2L, User.Role.ADMIN);
|
||||
authenticate(actor);
|
||||
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> userService.deleteUser(2L));
|
||||
verify(userRepository, never()).deleteById(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserDeniesDeletingSelfAdminAccount() {
|
||||
User actor = user(1L, User.Role.ADMIN);
|
||||
authenticate(actor);
|
||||
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> userService.deleteUser(1L));
|
||||
verify(userRepository, never()).deleteById(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkDeleteUsersDeniesMixedAdminTargets() {
|
||||
User targetAdmin = user(2L, User.Role.ADMIN);
|
||||
User customer = user(3L, User.Role.CUSTOMER);
|
||||
|
||||
when(userRepository.findAllById(eq(new HashSet<>(List.of(2L, 3L))))).thenReturn(List.of(targetAdmin, customer));
|
||||
|
||||
BulkDeleteRequest request = new BulkDeleteRequest();
|
||||
request.setIds(List.of(2L, 3L));
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> userService.bulkDeleteUsers(request));
|
||||
verify(userRepository, never()).deleteAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkDeleteUsersThrowsWhenAnyIdMissing() {
|
||||
User customer = user(2L, User.Role.CUSTOMER);
|
||||
|
||||
when(userRepository.findAllById(any())).thenReturn(List.of(customer));
|
||||
|
||||
BulkDeleteRequest request = new BulkDeleteRequest();
|
||||
request.setIds(List.of(2L, 3L));
|
||||
|
||||
assertThrows(ResourceNotFoundException.class, () -> userService.bulkDeleteUsers(request, User.Role.CUSTOMER));
|
||||
verify(userRepository, never()).deleteAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserDeniesWrongRoleForScopedRoute() {
|
||||
UserRequest request = request(User.Role.ADMIN);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> userService.createUser(request, User.Role.CUSTOMER));
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserAllowsEditingOwnAdminAccount() {
|
||||
User actor = user(1L, User.Role.ADMIN);
|
||||
authenticate(actor);
|
||||
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
|
||||
when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
UserRequest request = request(User.Role.ADMIN);
|
||||
request.setFirstName("Updated");
|
||||
|
||||
UserResponse response = userService.updateUser(1L, request);
|
||||
assertEquals("Updated", response.getFirstName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserDeniesPromotingAnotherUserToAdmin() {
|
||||
User actor = user(1L, User.Role.ADMIN);
|
||||
User target = user(2L, User.Role.CUSTOMER);
|
||||
authenticate(actor);
|
||||
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(actor));
|
||||
|
||||
UserRequest request = request(User.Role.ADMIN);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request));
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopedUpdateDeniesRoleEscalation() {
|
||||
User actor = user(1L, User.Role.ADMIN);
|
||||
User target = user(2L, User.Role.CUSTOMER);
|
||||
authenticate(actor);
|
||||
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(target));
|
||||
|
||||
UserRequest request = request(User.Role.ADMIN);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> userService.updateUser(2L, request, User.Role.CUSTOMER));
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
}
|
||||
|
||||
private void authenticate(User user) {
|
||||
AppPrincipal principal = new AppPrincipal(user.getId(), user.getUsername(), user.getRole(), user.getTokenVersion());
|
||||
var context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(new UsernamePasswordAuthenticationToken(principal, "n/a", principal.getAuthorities()));
|
||||
SecurityContextHolder.setContext(context);
|
||||
}
|
||||
|
||||
private User user(Long id, User.Role role) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername("user" + id);
|
||||
user.setFirstName("First");
|
||||
user.setLastName("Last");
|
||||
user.setEmail("user" + id + "@example.com");
|
||||
user.setRole(role);
|
||||
user.setActive(true);
|
||||
user.setTokenVersion(0);
|
||||
return user;
|
||||
}
|
||||
|
||||
private UserRequest request(User.Role role) {
|
||||
UserRequest request = new UserRequest();
|
||||
request.setUsername("updated-user");
|
||||
request.setFirstName("First");
|
||||
request.setLastName("Last");
|
||||
request.setEmail("updated@example.com");
|
||||
request.setRole(role);
|
||||
request.setActive(true);
|
||||
return request;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public class AppointmentDTO {
|
||||
private SimpleStringProperty appointmentTime;
|
||||
private SimpleStringProperty appointmentStatus;
|
||||
private SimpleStringProperty storeName;
|
||||
private Long storeId;
|
||||
|
||||
public AppointmentDTO(int appointmentId,
|
||||
int customerId, String customerName,
|
||||
@@ -32,7 +33,8 @@ public class AppointmentDTO {
|
||||
String appointmentDate,
|
||||
String appointmentTime,
|
||||
String appointmentStatus,
|
||||
String storeName) {
|
||||
String storeName,
|
||||
Long storeId) {
|
||||
|
||||
this.appointmentId = new SimpleIntegerProperty(appointmentId);
|
||||
this.customerId = new SimpleIntegerProperty(customerId);
|
||||
@@ -47,6 +49,7 @@ public class AppointmentDTO {
|
||||
this.appointmentTime = new SimpleStringProperty(appointmentTime);
|
||||
this.appointmentStatus = new SimpleStringProperty(appointmentStatus);
|
||||
this.storeName = new SimpleStringProperty(storeName != null ? storeName : "");
|
||||
this.storeId = storeId;
|
||||
}
|
||||
|
||||
public int getAppointmentId() { return appointmentId.get(); }
|
||||
@@ -66,4 +69,6 @@ public class AppointmentDTO {
|
||||
public String getAppointmentTime() { return appointmentTime.get(); }
|
||||
public String getAppointmentStatus() { return appointmentStatus.get(); }
|
||||
public String getStoreName() { return storeName.get(); }
|
||||
|
||||
public Long getStoreId() { return storeId; }
|
||||
}
|
||||
|
||||
@@ -3,17 +3,33 @@ package org.example.petshopdesktop;
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.util.DesktopNotificationService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
public class PetShopApplication extends Application {
|
||||
@Override
|
||||
public void start(Stage stage) throws IOException {
|
||||
FXMLLoader fxmlLoader = new FXMLLoader(PetShopApplication.class.getResource("login-view.fxml"));
|
||||
Scene scene = new Scene(fxmlLoader.load());
|
||||
stage.setTitle("Pet Shop Manager - Login");
|
||||
stage.setTitle("Leon's Pet Store - Login");
|
||||
|
||||
try {
|
||||
stage.getIcons().add(new Image(Objects.requireNonNull(
|
||||
getClass().getResourceAsStream("/org/example/petshopdesktop/images/leons-pet-store-badge.png")
|
||||
)));
|
||||
} catch (Exception e) {
|
||||
System.err.println("Could not load application icon: " + e.getMessage());
|
||||
}
|
||||
|
||||
stage.setScene(scene);
|
||||
stage.show();
|
||||
|
||||
DesktopNotificationService.getInstance().init(
|
||||
stage.getIcons().isEmpty() ? null : stage.getIcons().get(0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
@@ -142,6 +143,38 @@ public class ApiClient {
|
||||
return handleResponse(response, responseClass);
|
||||
}
|
||||
|
||||
public <T> T postMultipartWithText(String path, String filePartName, Path filePath,
|
||||
String textPartName, String textContent,
|
||||
Class<T> responseClass) throws Exception {
|
||||
String boundary = "----PetShopDesktop" + UUID.randomUUID();
|
||||
String mimeType = Files.probeContentType(filePath);
|
||||
if (mimeType == null || mimeType.isBlank()) mimeType = "application/octet-stream";
|
||||
|
||||
byte[] fileBytes = Files.readAllBytes(filePath);
|
||||
String fileName = filePath.getFileName().toString();
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + filePartName
|
||||
+ "\"; filename=\"" + fileName + "\"\r\nContent-Type: " + mimeType + "\r\n\r\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
out.write(fileBytes);
|
||||
out.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
if (textContent != null && !textContent.isBlank()) {
|
||||
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + textPartName
|
||||
+ "\"\r\n\r\n" + textContent + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
out.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + path))
|
||||
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()))
|
||||
.timeout(Duration.ofSeconds(30));
|
||||
addAuthHeader(builder);
|
||||
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
return handleResponse(response, responseClass);
|
||||
}
|
||||
|
||||
public <T> T put(String path, Object requestBody, Class<T> responseClass) throws Exception {
|
||||
String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.example.petshopdesktop.api.dto.chat.ConversationResponse;
|
||||
import org.example.petshopdesktop.api.dto.chat.MessageRequest;
|
||||
import org.example.petshopdesktop.api.dto.chat.MessageResponse;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.DesktopNotificationService;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -72,6 +73,16 @@ public class ChatRealtimeClient implements WebSocket.Listener {
|
||||
updateNotificationState();
|
||||
}
|
||||
|
||||
public void markConversationReplied(Long conversationId, Long senderId) {
|
||||
synchronized (lock) {
|
||||
ConversationResponse conv = globalConversations.get(conversationId);
|
||||
if (conv != null) {
|
||||
conv.setLastSenderId(senderId);
|
||||
}
|
||||
}
|
||||
updateNotificationState();
|
||||
}
|
||||
|
||||
public boolean hasActionableChats() {
|
||||
synchronized (lock) {
|
||||
UserSession session = UserSession.getInstance();
|
||||
@@ -334,6 +345,12 @@ public class ChatRealtimeClient implements WebSocket.Listener {
|
||||
messageListener.accept(message);
|
||||
}
|
||||
|
||||
Long currentUserId = UserSession.getInstance().getUserId();
|
||||
if (message.getSenderId() != null && !message.getSenderId().equals(currentUserId)) {
|
||||
DesktopNotificationService.getInstance()
|
||||
.notifyNewMessage(message.getSenderDisplayName(), message.getContent());
|
||||
}
|
||||
|
||||
// Also update globalConversation last sender if this is the active conversation
|
||||
synchronized (lock) {
|
||||
ConversationResponse conv = globalConversations.get(message.getConversationId());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.example.petshopdesktop.api.dto.adoption;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public class AdoptionRequest {
|
||||
@@ -9,7 +10,7 @@ public class AdoptionRequest {
|
||||
private Long sourceStoreId;
|
||||
private LocalDate adoptionDate;
|
||||
private String adoptionStatus;
|
||||
private String paymentMethod;
|
||||
private BigDecimal adoptionFee;
|
||||
|
||||
public AdoptionRequest() {
|
||||
}
|
||||
@@ -62,11 +63,11 @@ public class AdoptionRequest {
|
||||
this.adoptionStatus = adoptionStatus;
|
||||
}
|
||||
|
||||
public String getPaymentMethod() {
|
||||
return paymentMethod;
|
||||
public BigDecimal getAdoptionFee() {
|
||||
return adoptionFee;
|
||||
}
|
||||
|
||||
public void setPaymentMethod(String paymentMethod) {
|
||||
this.paymentMethod = paymentMethod;
|
||||
public void setAdoptionFee(BigDecimal adoptionFee) {
|
||||
this.adoptionFee = adoptionFee;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class AdoptionResponse {
|
||||
private LocalDate adoptionDate;
|
||||
private java.math.BigDecimal adoptionFee;
|
||||
private String adoptionStatus;
|
||||
private Long sourceStoreId;
|
||||
private String sourceStoreName;
|
||||
|
||||
public AdoptionResponse() {
|
||||
@@ -98,6 +99,14 @@ public class AdoptionResponse {
|
||||
this.adoptionStatus = adoptionStatus;
|
||||
}
|
||||
|
||||
public Long getSourceStoreId() {
|
||||
return sourceStoreId;
|
||||
}
|
||||
|
||||
public void setSourceStoreId(Long sourceStoreId) {
|
||||
this.sourceStoreId = sourceStoreId;
|
||||
}
|
||||
|
||||
public String getSourceStoreName() {
|
||||
return sourceStoreName;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public class MessageResponse {
|
||||
private String content;
|
||||
private LocalDateTime timestamp;
|
||||
private Boolean isRead;
|
||||
private String attachmentName;
|
||||
private String attachmentUrl;
|
||||
|
||||
public MessageResponse() {
|
||||
}
|
||||
@@ -87,4 +89,20 @@ public class MessageResponse {
|
||||
public void setIsRead(Boolean isRead) {
|
||||
this.isRead = isRead;
|
||||
}
|
||||
|
||||
public String getAttachmentName() {
|
||||
return attachmentName;
|
||||
}
|
||||
|
||||
public void setAttachmentName(String attachmentName) {
|
||||
this.attachmentName = attachmentName;
|
||||
}
|
||||
|
||||
public String getAttachmentUrl() {
|
||||
return attachmentUrl;
|
||||
}
|
||||
|
||||
public void setAttachmentUrl(String attachmentUrl) {
|
||||
this.attachmentUrl = attachmentUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ public class SaleRequest {
|
||||
private List<SaleItemRequest> items;
|
||||
private Boolean isRefund;
|
||||
private Long originalSaleId;
|
||||
private Long customerId;
|
||||
private Long couponId;
|
||||
private Integer pointsUsed;
|
||||
|
||||
public SaleRequest() {
|
||||
}
|
||||
@@ -51,4 +54,28 @@ public class SaleRequest {
|
||||
public void setOriginalSaleId(Long originalSaleId) {
|
||||
this.originalSaleId = originalSaleId;
|
||||
}
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public Long getCouponId() {
|
||||
return couponId;
|
||||
}
|
||||
|
||||
public void setCouponId(Long couponId) {
|
||||
this.couponId = couponId;
|
||||
}
|
||||
|
||||
public Integer getPointsUsed() {
|
||||
return pointsUsed;
|
||||
}
|
||||
|
||||
public void setPointsUsed(Integer pointsUsed) {
|
||||
this.pointsUsed = pointsUsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ public class SaleResponse {
|
||||
private Boolean isRefund;
|
||||
private Long originalSaleId;
|
||||
private List<SaleItemResponse> items;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private BigDecimal couponDiscountAmount;
|
||||
private BigDecimal loyaltyDiscountAmount;
|
||||
private Integer pointsUsed;
|
||||
private Integer pointsEarned;
|
||||
private BigDecimal subtotalAmount;
|
||||
|
||||
public SaleResponse() {
|
||||
}
|
||||
@@ -89,4 +96,60 @@ public class SaleResponse {
|
||||
public void setItems(List<SaleItemResponse> items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public void setCustomerId(Long customerId) {
|
||||
this.customerId = customerId;
|
||||
}
|
||||
|
||||
public String getCustomerName() {
|
||||
return customerName;
|
||||
}
|
||||
|
||||
public void setCustomerName(String customerName) {
|
||||
this.customerName = customerName;
|
||||
}
|
||||
|
||||
public BigDecimal getCouponDiscountAmount() {
|
||||
return couponDiscountAmount;
|
||||
}
|
||||
|
||||
public void setCouponDiscountAmount(BigDecimal couponDiscountAmount) {
|
||||
this.couponDiscountAmount = couponDiscountAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getLoyaltyDiscountAmount() {
|
||||
return loyaltyDiscountAmount;
|
||||
}
|
||||
|
||||
public void setLoyaltyDiscountAmount(BigDecimal loyaltyDiscountAmount) {
|
||||
this.loyaltyDiscountAmount = loyaltyDiscountAmount;
|
||||
}
|
||||
|
||||
public Integer getPointsUsed() {
|
||||
return pointsUsed;
|
||||
}
|
||||
|
||||
public void setPointsUsed(Integer pointsUsed) {
|
||||
this.pointsUsed = pointsUsed;
|
||||
}
|
||||
|
||||
public Integer getPointsEarned() {
|
||||
return pointsEarned;
|
||||
}
|
||||
|
||||
public void setPointsEarned(Integer pointsEarned) {
|
||||
this.pointsEarned = pointsEarned;
|
||||
}
|
||||
|
||||
public BigDecimal getSubtotalAmount() {
|
||||
return subtotalAmount;
|
||||
}
|
||||
|
||||
public void setSubtotalAmount(BigDecimal subtotalAmount) {
|
||||
this.subtotalAmount = subtotalAmount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package org.example.petshopdesktop.api.dto.user;
|
||||
public class UserRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String fullName;
|
||||
private String email;
|
||||
private String phone;
|
||||
private String role;
|
||||
private Boolean active;
|
||||
private Integer loyaltyPoints;
|
||||
|
||||
public UserRequest() {
|
||||
}
|
||||
@@ -28,6 +31,22 @@ public class UserRequest {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
@@ -67,4 +86,12 @@ public class UserRequest {
|
||||
public void setActive(Boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
public Integer getLoyaltyPoints() {
|
||||
return loyaltyPoints;
|
||||
}
|
||||
|
||||
public void setLoyaltyPoints(Integer loyaltyPoints) {
|
||||
this.loyaltyPoints = loyaltyPoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.example.petshopdesktop.api.ApiClient;
|
||||
import org.example.petshopdesktop.api.dto.activity.ActivityLogResponse;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public class ActivityLogApi {
|
||||
@@ -18,12 +19,18 @@ public class ActivityLogApi {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public List<ActivityLogResponse> getActivityLogs(int limit) throws Exception {
|
||||
String path = "/api/v1/activity-logs?limit=" + limit;
|
||||
String response = apiClient.getRawResponse(path);
|
||||
public List<ActivityLogResponse> getActivityLogs(int limit, LocalDate startDate, LocalDate endDate) throws Exception {
|
||||
StringBuilder path = new StringBuilder("/api/v1/activity-logs?limit=").append(limit);
|
||||
if (startDate != null) path.append("&startDate=").append(startDate);
|
||||
if (endDate != null) path.append("&endDate=").append(endDate);
|
||||
String response = apiClient.getRawResponse(path.toString());
|
||||
return apiClient.getObjectMapper().readValue(
|
||||
response,
|
||||
new TypeReference<List<ActivityLogResponse>>() {}
|
||||
);
|
||||
}
|
||||
|
||||
public List<ActivityLogResponse> getActivityLogs(int limit) throws Exception {
|
||||
return getActivityLogs(limit, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class AppointmentApi {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public List<AppointmentResponse> listAppointments(String query, Long storeId) throws Exception {
|
||||
public List<AppointmentResponse> listAppointments(String query, Long storeId, Long employeeId) throws Exception {
|
||||
String path = "/api/v1/appointments?page=0&size=1000";
|
||||
if (query != null && !query.isEmpty()) {
|
||||
path += "&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
@@ -31,6 +31,9 @@ public class AppointmentApi {
|
||||
if (storeId != null) {
|
||||
path += "&storeId=" + storeId;
|
||||
}
|
||||
if (employeeId != null) {
|
||||
path += "&employeeId=" + employeeId;
|
||||
}
|
||||
String response = apiClient.getRawResponse(path);
|
||||
PageResponse<AppointmentResponse> pageResponse = apiClient.getObjectMapper().readValue(
|
||||
response,
|
||||
|
||||
@@ -5,6 +5,8 @@ import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse;
|
||||
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class AuthApi {
|
||||
private static final AuthApi INSTANCE = new AuthApi();
|
||||
@@ -33,4 +35,10 @@ public class AuthApi {
|
||||
public void deleteAvatar() throws Exception {
|
||||
apiClient.delete("/api/v1/auth/me/avatar");
|
||||
}
|
||||
|
||||
public void forgotPassword(String usernameOrEmail) throws Exception {
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("usernameOrEmail", usernameOrEmail);
|
||||
apiClient.post("/api/v1/auth/forgot-password", body, Object.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.example.petshopdesktop.api.dto.chat.ConversationResponse;
|
||||
import org.example.petshopdesktop.api.dto.chat.MessageRequest;
|
||||
import org.example.petshopdesktop.api.dto.chat.MessageResponse;
|
||||
import org.example.petshopdesktop.api.dto.chat.UpdateConversationRequest;
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
public class ChatApi {
|
||||
@@ -43,6 +44,11 @@ public class ChatApi {
|
||||
return apiClient.post("/api/v1/chat/conversations/" + conversationId + "/messages", request, MessageResponse.class);
|
||||
}
|
||||
|
||||
public MessageResponse sendMessageWithAttachment(Long conversationId, File file, String content) throws Exception {
|
||||
String path = "/api/v1/chat/conversations/" + conversationId + "/attachments";
|
||||
return apiClient.postMultipartWithText(path, "file", file.toPath(), "content", content, MessageResponse.class);
|
||||
}
|
||||
|
||||
public ConversationResponse updateConversation(Long id, UpdateConversationRequest request) throws Exception {
|
||||
return apiClient.put("/api/v1/chat/conversations/" + id, request, ConversationResponse.class);
|
||||
}
|
||||
|
||||
@@ -39,4 +39,8 @@ public class CouponApi {
|
||||
public void deleteCoupon(Long id) throws Exception {
|
||||
apiClient.delete("/api/v1/coupons/" + id);
|
||||
}
|
||||
|
||||
public CouponResponse getCouponByCode(String code) throws Exception {
|
||||
return apiClient.get("/api/v1/coupons/code/" + code, CouponResponse.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,8 @@ public class CustomerApi {
|
||||
public UserResponse createCustomer(UserRequest request) throws Exception {
|
||||
return apiClient.post("/api/v1/customers", request, UserResponse.class);
|
||||
}
|
||||
|
||||
public UserResponse getCustomerById(Long id) throws Exception {
|
||||
return apiClient.get("/api/v1/customers/" + id, UserResponse.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ public class DropdownApi {
|
||||
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {});
|
||||
}
|
||||
|
||||
public List<DropdownOption> getAdoptionPets(Long storeId) throws Exception {
|
||||
String response = apiClient.getRawResponse("/api/v1/dropdowns/adoption-pets?storeId=" + storeId);
|
||||
if (response == null || response.isEmpty()) {
|
||||
throw new IllegalStateException("Empty response from adoption pets endpoint");
|
||||
}
|
||||
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {});
|
||||
}
|
||||
|
||||
public List<DropdownOption> getCustomerPets(Long customerId) throws Exception {
|
||||
String response = apiClient.getRawResponse("/api/v1/dropdowns/customers/" + customerId + "/pets");
|
||||
if (response == null || response.isEmpty()) {
|
||||
|
||||
@@ -57,6 +57,10 @@ public class PetApi {
|
||||
return listPets(query, null, null, null);
|
||||
}
|
||||
|
||||
public PetResponse getPetById(Long id) throws Exception {
|
||||
return apiClient.get("/api/v1/pets/" + id, PetResponse.class);
|
||||
}
|
||||
|
||||
public PetResponse createPet(PetRequest request) throws Exception {
|
||||
return apiClient.post("/api/v1/pets", request, PetResponse.class);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ package org.example.petshopdesktop.controllers;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.DatePicker;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
@@ -14,6 +17,7 @@ import org.example.petshopdesktop.api.endpoints.ActivityLogApi;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@@ -40,6 +44,15 @@ public class ActivityLogController {
|
||||
@FXML
|
||||
private Button btnRefresh;
|
||||
|
||||
@FXML
|
||||
private DatePicker dpStart;
|
||||
|
||||
@FXML
|
||||
private DatePicker dpEnd;
|
||||
|
||||
@FXML
|
||||
private CheckBox chkHideViewOnly;
|
||||
|
||||
@FXML
|
||||
private Label lblStatus;
|
||||
|
||||
@@ -47,6 +60,7 @@ public class ActivityLogController {
|
||||
private Label lblError;
|
||||
|
||||
private final ObservableList<ActivityLogResponse> activityLogs = FXCollections.observableArrayList();
|
||||
private FilteredList<ActivityLogResponse> filteredLogs;
|
||||
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final int DEFAULT_LIMIT = 2000;
|
||||
|
||||
@@ -71,7 +85,10 @@ public class ActivityLogController {
|
||||
colStore.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(displayStore(data.getValue())));
|
||||
colActivity.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(nullToBlank(data.getValue().getActivity())));
|
||||
|
||||
TableViewSupport.bindSortedItems(tvActivityLogs, new javafx.collections.transformation.FilteredList<>(activityLogs, a -> true));
|
||||
filteredLogs = new FilteredList<>(activityLogs, a -> true);
|
||||
TableViewSupport.bindSortedItems(tvActivityLogs, filteredLogs);
|
||||
dpStart.setValue(LocalDate.now().minusDays(30));
|
||||
dpEnd.setValue(LocalDate.now());
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
@@ -80,18 +97,46 @@ public class ActivityLogController {
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void onDateChanged(ActionEvent event) {
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void onHideViewOnlyChanged(ActionEvent event) {
|
||||
applyViewOnlyFilter();
|
||||
}
|
||||
|
||||
private void applyViewOnlyFilter() {
|
||||
boolean hide = chkHideViewOnly != null && chkHideViewOnly.isSelected();
|
||||
if (filteredLogs != null) {
|
||||
filteredLogs.setPredicate(hide
|
||||
? a -> {
|
||||
String act = a.getActivity();
|
||||
if (act == null) return true;
|
||||
String trimmed = act.stripLeading();
|
||||
return !trimmed.startsWith("View");
|
||||
}
|
||||
: a -> true);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadLogs() {
|
||||
lblError.setText("");
|
||||
tvActivityLogs.setDisable(true);
|
||||
btnRefresh.setDisable(true);
|
||||
|
||||
java.time.LocalDate startDate = dpStart != null ? dpStart.getValue() : null;
|
||||
java.time.LocalDate endDate = dpEnd != null ? dpEnd.getValue() : null;
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<ActivityLogResponse> content = ActivityLogApi.getInstance().getActivityLogs(DEFAULT_LIMIT);
|
||||
List<ActivityLogResponse> content = ActivityLogApi.getInstance().getActivityLogs(DEFAULT_LIMIT, startDate, endDate);
|
||||
List<ActivityLogResponse> safeContent = content != null ? content : List.of();
|
||||
|
||||
Platform.runLater(() -> {
|
||||
activityLogs.setAll(safeContent);
|
||||
applyViewOnlyFilter();
|
||||
tvActivityLogs.setDisable(false);
|
||||
btnRefresh.setDisable(false);
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
|
||||
@@ -4,6 +4,7 @@ import javafx.application.Platform;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.collections.transformation.FilteredList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
@@ -13,16 +14,21 @@ import javafx.scene.control.cell.PropertyValueFactory;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.adoption.AdoptionResponse;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.endpoints.AdoptionApi;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.controllers.dialogcontrollers.AdoptionDialogController;
|
||||
import org.example.petshopdesktop.models.Adoption;
|
||||
import org.example.petshopdesktop.ui.CalendarPane;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class AdoptionController {
|
||||
@@ -72,6 +78,19 @@ public class AdoptionController {
|
||||
@FXML
|
||||
private TextField txtSearch;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbStatusFilter;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbStoreFilter;
|
||||
|
||||
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
|
||||
|
||||
@FXML
|
||||
private CalendarPane calendarPane;
|
||||
private LocalDate selectedCalendarDate = null;
|
||||
private FilteredList<Adoption> filteredAdoptions;
|
||||
|
||||
private ObservableList<Adoption> data = FXCollections.observableArrayList();
|
||||
private String mode = null;
|
||||
|
||||
@@ -92,6 +111,20 @@ public class AdoptionController {
|
||||
colStoreName.setCellValueFactory(new PropertyValueFactory<>("storeName"));
|
||||
TableViewSupport.applyCurrencyColumn(colAdoptionFee);
|
||||
|
||||
filteredAdoptions = new FilteredList<>(data, a -> true);
|
||||
TableViewSupport.bindSortedItems(tvAdoptions, filteredAdoptions);
|
||||
|
||||
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Pending", "Completed", "Cancelled"));
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyFilterPredicate());
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
cbStoreFilter.setVisible(true);
|
||||
cbStoreFilter.setManaged(true);
|
||||
loadStoreFilter();
|
||||
}
|
||||
cbStoreFilter.valueProperty().addListener((obs, o, n) -> displayAdoptions());
|
||||
|
||||
displayAdoptions();
|
||||
TableViewSupport.installDoubleClickAction(tvAdoptions, selected -> openDialog(selected, "Edit"));
|
||||
|
||||
@@ -106,6 +139,11 @@ public class AdoptionController {
|
||||
displayFilteredAdoptions(newValue);
|
||||
});
|
||||
|
||||
calendarPane.setOnDateSelected(date -> {
|
||||
selectedCalendarDate = date;
|
||||
applyFilterPredicate();
|
||||
});
|
||||
|
||||
tvAdoptions.setOnKeyPressed(event -> {
|
||||
if (event.getCode() == javafx.scene.input.KeyCode.DELETE) {
|
||||
if (tvAdoptions.getSelectionModel().getSelectedItem() != null) {
|
||||
@@ -118,6 +156,10 @@ public class AdoptionController {
|
||||
@FXML
|
||||
void btnRefresh(ActionEvent event) {
|
||||
txtSearch.clear();
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
selectedCalendarDate = null;
|
||||
if (filteredAdoptions != null) filteredAdoptions.setPredicate(a -> true);
|
||||
tvAdoptions.getSortOrder().clear();
|
||||
displayAdoptions();
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
@@ -189,7 +231,7 @@ public class AdoptionController {
|
||||
} else {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
Long storeId = selectedStoreId();
|
||||
List<AdoptionResponse> adoptions = AdoptionApi.getInstance().listAdoptions(filter, storeId);
|
||||
List<Adoption> adoptionList = adoptions.stream()
|
||||
.map(this::mapToAdoption)
|
||||
@@ -198,7 +240,13 @@ public class AdoptionController {
|
||||
|
||||
Platform.runLater(() -> {
|
||||
data.setAll(adoptionList);
|
||||
tvAdoptions.setItems(data);
|
||||
Set<LocalDate> dates = adoptionList.stream()
|
||||
.filter(a -> a.getAdoptionDate() != null && !a.getAdoptionDate().isEmpty())
|
||||
.map(a -> { try { return LocalDate.parse(a.getAdoptionDate()); } catch (Exception ex) { return null; } })
|
||||
.filter(d -> d != null)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
calendarPane.setEventDates(dates);
|
||||
applyFilterPredicate();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
@@ -225,7 +273,15 @@ public class AdoptionController {
|
||||
|
||||
Platform.runLater(() -> {
|
||||
data.setAll(adoptionList);
|
||||
tvAdoptions.setItems(data);
|
||||
Set<LocalDate> dates = adoptionList.stream()
|
||||
.filter(a -> a.getAdoptionDate() != null && !a.getAdoptionDate().isEmpty())
|
||||
.map(a -> { try { return LocalDate.parse(a.getAdoptionDate()); } catch (Exception ex) { return null; } })
|
||||
.filter(d -> d != null)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
calendarPane.setEventDates(dates);
|
||||
if (selectedCalendarDate != null) {
|
||||
filteredAdoptions.setPredicate(a -> a.getAdoptionDate().equals(selectedCalendarDate.toString()));
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
@@ -272,6 +328,44 @@ public class AdoptionController {
|
||||
txtSearch.setText("");
|
||||
}
|
||||
|
||||
private void loadStoreFilter() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
DropdownOption allStores = new DropdownOption();
|
||||
allStores.setLabel("All Stores");
|
||||
Platform.runLater(() -> {
|
||||
storeOptions.clear();
|
||||
storeOptions.addAll(stores);
|
||||
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
|
||||
items.add(allStores);
|
||||
items.addAll(stores);
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("AdoptionController.loadStoreFilter", e, "Loading store filter");
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private Long selectedStoreId() {
|
||||
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
|
||||
DropdownOption selected = cbStoreFilter.getValue();
|
||||
return (selected != null && selected.getId() != null) ? selected.getId() : null;
|
||||
}
|
||||
|
||||
private void applyFilterPredicate() {
|
||||
String selectedStatus = cbStatusFilter.getValue();
|
||||
filteredAdoptions.setPredicate(a -> {
|
||||
boolean dateMatch = selectedCalendarDate == null
|
||||
|| a.getAdoptionDate().equals(selectedCalendarDate.toString());
|
||||
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|
||||
|| a.getAdoptionStatus().equalsIgnoreCase(selectedStatus);
|
||||
return dateMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
private Adoption mapToAdoption(AdoptionResponse response) {
|
||||
return new Adoption(
|
||||
response.getAdoptionId().intValue(),
|
||||
@@ -284,7 +378,8 @@ public class AdoptionController {
|
||||
response.getAdoptionDate() != null ? response.getAdoptionDate().toString() : "",
|
||||
response.getAdoptionFee() != null ? response.getAdoptionFee().doubleValue() : 0.0,
|
||||
response.getAdoptionStatus(),
|
||||
response.getSourceStoreName()
|
||||
response.getSourceStoreName(),
|
||||
response.getSourceStoreId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
package org.example.petshopdesktop.controllers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.chart.*;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.DatePicker;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TabPane;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.example.petshopdesktop.api.dto.analytics.DailySales;
|
||||
import org.example.petshopdesktop.api.dto.analytics.DashboardResponse;
|
||||
import org.example.petshopdesktop.api.dto.analytics.TopProduct;
|
||||
import org.example.petshopdesktop.api.dto.sale.SaleItemResponse;
|
||||
import org.example.petshopdesktop.api.dto.sale.SaleResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.AnalyticsApi;
|
||||
import org.example.petshopdesktop.api.endpoints.SaleApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
@@ -74,6 +80,46 @@ public class AnalyticsController {
|
||||
@FXML
|
||||
private TabPane tabPane;
|
||||
|
||||
@FXML
|
||||
private Button btnToggleFilters;
|
||||
|
||||
@FXML
|
||||
private Label lblFilterSummary;
|
||||
|
||||
@FXML
|
||||
private VBox vbFilterContent;
|
||||
|
||||
@FXML
|
||||
private DatePicker dpStartDate;
|
||||
|
||||
@FXML
|
||||
private DatePicker dpEndDate;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbPaymentFilter;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbTopN;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbStoreFilter;
|
||||
|
||||
@FXML
|
||||
private HBox hbViewToggle;
|
||||
|
||||
@FXML
|
||||
private ToggleButton tbnMyAnalytics;
|
||||
|
||||
@FXML
|
||||
private ToggleButton tbnStoreAnalytics;
|
||||
|
||||
private String viewMode = "store";
|
||||
|
||||
private List<SaleResponse> cachedSales = new ArrayList<>();
|
||||
private FilterState currentFilter = new FilterState();
|
||||
|
||||
private static final int[] TOP_N_VALUES = {5, 10, 15, 20};
|
||||
|
||||
private static final String SALES_COLOR = "#ff6b35";
|
||||
private static final String REVENUE_COLOR = "#4ecdc4";
|
||||
private static final String QUANTITY_COLOR = "#ff9f1c";
|
||||
@@ -88,13 +134,36 @@ public class AnalyticsController {
|
||||
@FXML
|
||||
public void initialize() {
|
||||
configureCharts();
|
||||
if (tabPane != null) {
|
||||
tabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> {
|
||||
if (oldTab != newTab && newTab != null) {
|
||||
loadAnalyticsData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cbTopN.setItems(FXCollections.observableArrayList("Top 5", "Top 10", "Top 15", "Top 20"));
|
||||
cbTopN.getSelectionModel().selectFirst();
|
||||
|
||||
cbPaymentFilter.setItems(FXCollections.observableArrayList("All"));
|
||||
cbPaymentFilter.getSelectionModel().selectFirst();
|
||||
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList("All Stores"));
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
|
||||
lblFilterSummary.setText("All time");
|
||||
|
||||
ToggleGroup tgViewMode = new ToggleGroup();
|
||||
tbnMyAnalytics.setToggleGroup(tgViewMode);
|
||||
tbnStoreAnalytics.setToggleGroup(tgViewMode);
|
||||
tbnStoreAnalytics.setSelected(true);
|
||||
tgViewMode.selectedToggleProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (newVal == null) {
|
||||
(viewMode.equals("mine") ? tbnMyAnalytics : tbnStoreAnalytics).setSelected(true);
|
||||
return;
|
||||
}
|
||||
viewMode = (newVal == tbnMyAnalytics) ? "mine" : "store";
|
||||
updateViewModeStyles();
|
||||
updateStoreFilterVisibility();
|
||||
applyCurrentFilter();
|
||||
});
|
||||
|
||||
hbViewToggle.setVisible(true);
|
||||
hbViewToggle.setManaged(true);
|
||||
|
||||
loadAnalyticsData();
|
||||
}
|
||||
|
||||
@@ -142,64 +211,161 @@ public class AnalyticsController {
|
||||
|
||||
private void loadAnalyticsData() {
|
||||
lblError.setVisible(false);
|
||||
if (lblStatus != null) {
|
||||
lblStatus.setVisible(false);
|
||||
}
|
||||
lblStatus.setVisible(false);
|
||||
btnRefresh.setDisable(true);
|
||||
new Thread(() -> {
|
||||
try {
|
||||
DashboardResponse dashboard = AnalyticsApi.getInstance().getDashboard(30, 10);
|
||||
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
List<SaleResponse> sales = SaleApi.getInstance().listAllSales(null, storeId);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
boolean isAdmin = UserSession.getInstance().isAdmin();
|
||||
loadSummaryData(dashboard);
|
||||
loadSalesOverTime(dashboard);
|
||||
loadTopProductsByRevenue(dashboard);
|
||||
loadTopProductsByQuantity(dashboard);
|
||||
loadPaymentMethodDistribution(dashboard);
|
||||
loadEmployeePerformance(dashboard, isAdmin);
|
||||
applyRoleVisibility(isAdmin);
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", e, "Loading analytics data");
|
||||
lblError.setText("Error loading analytics data. Please try again.");
|
||||
lblError.setVisible(true);
|
||||
}
|
||||
cachedSales = sales;
|
||||
derivePaymentMethods();
|
||||
deriveStores();
|
||||
updateStoreFilterVisibility();
|
||||
applyCurrentFilter();
|
||||
btnRefresh.setDisable(false);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
if (UserSession.getInstance().isStaff()) {
|
||||
try {
|
||||
DashboardResponse fallback = buildStaffFallbackDashboard();
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
loadSummaryData(fallback);
|
||||
loadSalesOverTime(fallback);
|
||||
loadTopProductsByRevenue(fallback);
|
||||
loadTopProductsByQuantity(fallback);
|
||||
loadPaymentMethodDistribution(fallback);
|
||||
loadEmployeePerformance(fallback, false);
|
||||
applyRoleVisibility(false);
|
||||
lblError.setVisible(false);
|
||||
} catch (Exception inner) {
|
||||
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", inner, "Rendering fallback analytics data");
|
||||
lblError.setText("Error loading analytics data. Please try again.");
|
||||
lblError.setVisible(true);
|
||||
}
|
||||
});
|
||||
return;
|
||||
} catch (Exception fallbackError) {
|
||||
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", fallbackError, "Loading fallback analytics data");
|
||||
}
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", e, "Loading analytics data");
|
||||
ActivityLogger.getInstance().logException("AnalyticsController.loadAnalyticsData", e, "Loading sales data for analytics");
|
||||
lblError.setText("Error loading analytics data. Please try again.");
|
||||
lblError.setVisible(true);
|
||||
btnRefresh.setDisable(false);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void updateViewModeStyles() {
|
||||
String selectedStyle = "-fx-background-color: #4ECDC4; -fx-text-fill: white; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;";
|
||||
String unselectedStyle = "-fx-background-color: #e2e8f0; -fx-text-fill: #475569; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;";
|
||||
if (viewMode.equals("mine")) {
|
||||
tbnMyAnalytics.setStyle(selectedStyle + " -fx-background-radius: 6 0 0 6;");
|
||||
tbnStoreAnalytics.setStyle(unselectedStyle + " -fx-background-radius: 0 6 6 0;");
|
||||
} else {
|
||||
tbnMyAnalytics.setStyle(unselectedStyle + " -fx-background-radius: 6 0 0 6;");
|
||||
tbnStoreAnalytics.setStyle(selectedStyle + " -fx-background-radius: 0 6 6 0;");
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCurrentFilter() {
|
||||
try {
|
||||
List<SaleResponse> salesForMode;
|
||||
if (viewMode.equals("mine")) {
|
||||
String myName = UserSession.getInstance().getEmployeeName();
|
||||
salesForMode = cachedSales.stream()
|
||||
.filter(s -> myName != null && myName.equalsIgnoreCase(s.getEmployeeName() != null ? s.getEmployeeName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
} else {
|
||||
salesForMode = cachedSales;
|
||||
}
|
||||
String storeFilter = currentFilter.storeFilter;
|
||||
if (!storeFilter.equals("All Stores") && !storeFilter.isBlank()) {
|
||||
salesForMode = salesForMode.stream()
|
||||
.filter(s -> storeFilter.equalsIgnoreCase(s.getStoreName() != null ? s.getStoreName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
List<SaleResponse> filtered = filterSales(salesForMode, currentFilter);
|
||||
String start = currentFilter.startDate.isEmpty() ? LocalDate.now().minusDays(6).toString() : currentFilter.startDate;
|
||||
String end = currentFilter.endDate.isEmpty() ? LocalDate.now().toString() : currentFilter.endDate;
|
||||
|
||||
DashboardResponse dashboard = new DashboardResponse();
|
||||
dashboard.setSalesSummary(buildSalesSummary(filtered));
|
||||
dashboard.setDailySales(buildDailySalesBetween(filtered, start, end));
|
||||
dashboard.setTopProducts(buildTopProducts(filtered, currentFilter.topN > 0 ? currentFilter.topN : 5));
|
||||
dashboard.setPaymentMethods(buildPaymentMethods(filtered));
|
||||
dashboard.setEmployeePerformance(buildEmployeePerformance(filtered));
|
||||
|
||||
boolean isAdmin = UserSession.getInstance().isAdmin();
|
||||
loadSummaryData(dashboard);
|
||||
loadSalesOverTime(dashboard);
|
||||
loadTopProductsByRevenue(dashboard);
|
||||
loadTopProductsByQuantity(dashboard);
|
||||
loadPaymentMethodDistribution(dashboard);
|
||||
loadEmployeePerformance(dashboard, isAdmin);
|
||||
applyRoleVisibility(isAdmin);
|
||||
updateFilterSummary();
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("AnalyticsController.applyCurrentFilter", e, "Computing analytics from filtered sales");
|
||||
lblError.setText("Error computing analytics. Please try again.");
|
||||
lblError.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
private List<SaleResponse> filterSales(List<SaleResponse> sales, FilterState filter) {
|
||||
return sales.stream().filter(sale -> {
|
||||
String date = sale.getSaleDate() != null ? sale.getSaleDate().toLocalDate().toString() : "";
|
||||
if (!filter.startDate.isEmpty() && !date.isEmpty() && date.compareTo(filter.startDate) < 0) return false;
|
||||
if (!filter.endDate.isEmpty() && !date.isEmpty() && date.compareTo(filter.endDate) > 0) return false;
|
||||
if (!filter.paymentMethod.equals("All") && !filter.paymentMethod.isEmpty()) {
|
||||
if (!filter.paymentMethod.equalsIgnoreCase(sale.getPaymentMethod())) return false;
|
||||
}
|
||||
return true;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private void derivePaymentMethods() {
|
||||
Set<String> methods = new TreeSet<>();
|
||||
for (SaleResponse s : cachedSales) {
|
||||
if (s.getPaymentMethod() != null && !s.getPaymentMethod().isEmpty()) {
|
||||
methods.add(s.getPaymentMethod());
|
||||
}
|
||||
}
|
||||
List<String> items = new ArrayList<>();
|
||||
items.add("All");
|
||||
items.addAll(methods);
|
||||
String current = cbPaymentFilter.getValue();
|
||||
cbPaymentFilter.setItems(FXCollections.observableArrayList(items));
|
||||
if (current != null && items.contains(current)) {
|
||||
cbPaymentFilter.setValue(current);
|
||||
} else {
|
||||
cbPaymentFilter.getSelectionModel().selectFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private void deriveStores() {
|
||||
Set<String> stores = new TreeSet<>();
|
||||
for (SaleResponse s : cachedSales) {
|
||||
if (s.getStoreName() != null && !s.getStoreName().isBlank()) {
|
||||
stores.add(s.getStoreName());
|
||||
}
|
||||
}
|
||||
List<String> items = new ArrayList<>();
|
||||
items.add("All Stores");
|
||||
items.addAll(stores);
|
||||
String current = cbStoreFilter.getValue();
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
|
||||
if (current != null && items.contains(current)) {
|
||||
cbStoreFilter.setValue(current);
|
||||
} else {
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStoreFilterVisibility() {
|
||||
boolean show = UserSession.getInstance().isAdmin() && viewMode.equals("store");
|
||||
cbStoreFilter.setVisible(show);
|
||||
cbStoreFilter.setManaged(show);
|
||||
}
|
||||
|
||||
private void updateFilterSummary() {
|
||||
String start = currentFilter.startDate;
|
||||
String end = currentFilter.endDate;
|
||||
if (start.isEmpty() && end.isEmpty()) {
|
||||
lblFilterSummary.setText("All time");
|
||||
} else if (start.isEmpty()) {
|
||||
lblFilterSummary.setText("Up to " + shortDate(end));
|
||||
} else if (end.isEmpty()) {
|
||||
lblFilterSummary.setText("From " + shortDate(start));
|
||||
} else {
|
||||
lblFilterSummary.setText(shortDate(start) + " – " + shortDate(end));
|
||||
}
|
||||
}
|
||||
|
||||
private String shortDate(String date) {
|
||||
return (date != null && date.length() >= 10) ? date.substring(5) : date;
|
||||
}
|
||||
|
||||
private void loadSummaryData(DashboardResponse dashboard) throws Exception {
|
||||
if (dashboard != null) {
|
||||
BigDecimal totalRevenue = BigDecimal.ZERO;
|
||||
@@ -318,34 +484,32 @@ public class AnalyticsController {
|
||||
}
|
||||
|
||||
private void applyRoleVisibility(boolean isAdmin) {
|
||||
chartEmployeePerformance.setVisible(isAdmin);
|
||||
chartEmployeePerformance.setManaged(isAdmin);
|
||||
boolean showEmpChart = isAdmin && viewMode.equals("store");
|
||||
chartEmployeePerformance.setVisible(showEmpChart);
|
||||
chartEmployeePerformance.setManaged(showEmpChart);
|
||||
if (chartEmployeePerformance.getParent() != null) {
|
||||
chartEmployeePerformance.getParent().setVisible(isAdmin);
|
||||
chartEmployeePerformance.getParent().setManaged(isAdmin);
|
||||
chartEmployeePerformance.getParent().setVisible(showEmpChart);
|
||||
chartEmployeePerformance.getParent().setManaged(showEmpChart);
|
||||
}
|
||||
}
|
||||
|
||||
private DashboardResponse buildStaffFallbackDashboard() throws Exception {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
List<SaleResponse> sales = SaleApi.getInstance().listSales(0, Integer.MAX_VALUE, null, storeId);
|
||||
String employeeName = UserSession.getInstance().getEmployeeName();
|
||||
if (employeeName == null || employeeName.isBlank()) {
|
||||
employeeName = UserSession.getInstance().getUsername();
|
||||
private List<DashboardResponse.EmployeePerformanceData> buildEmployeePerformance(List<SaleResponse> sales) {
|
||||
Map<String, BigDecimal> empRevenue = new LinkedHashMap<>();
|
||||
for (SaleResponse sale : sales) {
|
||||
if (Boolean.TRUE.equals(sale.getIsRefund())) continue;
|
||||
String emp = sale.getEmployeeName() != null ? sale.getEmployeeName() : "Unknown";
|
||||
BigDecimal amount = sale.getTotalAmount() != null ? sale.getTotalAmount() : BigDecimal.ZERO;
|
||||
empRevenue.merge(emp, amount, BigDecimal::add);
|
||||
}
|
||||
final String employeeNameFilter = employeeName;
|
||||
|
||||
List<SaleResponse> personalSales = sales.stream()
|
||||
.filter(sale -> sale.getEmployeeName() != null && sale.getEmployeeName().equalsIgnoreCase(employeeNameFilter))
|
||||
.toList();
|
||||
|
||||
DashboardResponse dashboard = new DashboardResponse();
|
||||
dashboard.setSalesSummary(buildSalesSummary(personalSales));
|
||||
dashboard.setDailySales(buildDailySales(personalSales, 30));
|
||||
dashboard.setTopProducts(buildTopProducts(personalSales, 10));
|
||||
dashboard.setPaymentMethods(buildPaymentMethods(personalSales));
|
||||
dashboard.setEmployeePerformance(List.of(new DashboardResponse.EmployeePerformanceData()));
|
||||
return dashboard;
|
||||
return empRevenue.entrySet().stream()
|
||||
.sorted((a, b) -> b.getValue().compareTo(a.getValue()))
|
||||
.map(e -> {
|
||||
DashboardResponse.EmployeePerformanceData d = new DashboardResponse.EmployeePerformanceData();
|
||||
d.setEmployeeName(e.getKey());
|
||||
d.setRevenue(e.getValue());
|
||||
return d;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private DashboardResponse.SalesSummary buildSalesSummary(List<SaleResponse> sales) {
|
||||
@@ -379,21 +543,26 @@ public class AnalyticsController {
|
||||
return summary;
|
||||
}
|
||||
|
||||
private List<DailySales> buildDailySales(List<SaleResponse> sales, int days) {
|
||||
private List<DailySales> buildDailySalesBetween(List<SaleResponse> sales, String startStr, String endStr) {
|
||||
Map<LocalDate, DailySales> daily = new LinkedHashMap<>();
|
||||
LocalDate start = LocalDate.now().minusDays(days - 1L);
|
||||
for (int i = 0; i < days; i++) {
|
||||
LocalDate date = start.plusDays(i);
|
||||
DailySales row = new DailySales();
|
||||
row.setDate(date.toString());
|
||||
row.setRevenue(BigDecimal.ZERO);
|
||||
row.setSalesCount(0L);
|
||||
daily.put(date, row);
|
||||
try {
|
||||
LocalDate start = LocalDate.parse(startStr);
|
||||
LocalDate end = LocalDate.parse(endStr);
|
||||
if (start.plusDays(60).isBefore(end)) {
|
||||
start = end.minusDays(59);
|
||||
}
|
||||
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
|
||||
DailySales row = new DailySales();
|
||||
row.setDate(date.toString());
|
||||
row.setRevenue(BigDecimal.ZERO);
|
||||
row.setSalesCount(0L);
|
||||
daily.put(date, row);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (SaleResponse sale : sales) {
|
||||
if (Boolean.TRUE.equals(sale.getIsRefund()) || sale.getSaleDate() == null) {
|
||||
continue;
|
||||
}
|
||||
if (Boolean.TRUE.equals(sale.getIsRefund()) || sale.getSaleDate() == null) continue;
|
||||
LocalDate date = sale.getSaleDate().toLocalDate();
|
||||
DailySales row = daily.get(date);
|
||||
if (row != null) {
|
||||
@@ -494,4 +663,77 @@ public class AnalyticsController {
|
||||
loadAnalyticsData();
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnToggleFilters(ActionEvent event) {
|
||||
boolean expanded = vbFilterContent.isVisible();
|
||||
vbFilterContent.setVisible(!expanded);
|
||||
vbFilterContent.setManaged(!expanded);
|
||||
btnToggleFilters.setText(expanded ? "▼ Filters" : "▲ Filters");
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnPresetToday(ActionEvent event) { applyPreset(0, 0); }
|
||||
|
||||
@FXML
|
||||
void btnPreset7D(ActionEvent event) { applyPreset(-6, 0); }
|
||||
|
||||
@FXML
|
||||
void btnPreset30D(ActionEvent event) { applyPreset(-29, 0); }
|
||||
|
||||
@FXML
|
||||
void btnPreset3M(ActionEvent event) { applyPreset(-89, 0); }
|
||||
|
||||
@FXML
|
||||
void btnPreset1Y(ActionEvent event) { applyPreset(-364, 0); }
|
||||
|
||||
@FXML
|
||||
void btnPresetAll(ActionEvent event) {
|
||||
dpStartDate.setValue(null);
|
||||
dpEndDate.setValue(null);
|
||||
applyFiltersFromUI();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnApplyFilter(ActionEvent event) {
|
||||
applyFiltersFromUI();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnResetFilter(ActionEvent event) {
|
||||
dpStartDate.setValue(null);
|
||||
dpEndDate.setValue(null);
|
||||
cbPaymentFilter.getSelectionModel().selectFirst();
|
||||
cbTopN.getSelectionModel().selectFirst();
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
currentFilter = new FilterState();
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
private void applyPreset(int startOffset, int endOffset) {
|
||||
dpStartDate.setValue(LocalDate.now().plusDays(startOffset));
|
||||
dpEndDate.setValue(LocalDate.now().plusDays(endOffset));
|
||||
applyFiltersFromUI();
|
||||
}
|
||||
|
||||
private void applyFiltersFromUI() {
|
||||
currentFilter = new FilterState();
|
||||
currentFilter.startDate = dpStartDate.getValue() != null ? dpStartDate.getValue().toString() : "";
|
||||
currentFilter.endDate = dpEndDate.getValue() != null ? dpEndDate.getValue().toString() : "";
|
||||
String pm = cbPaymentFilter.getValue();
|
||||
currentFilter.paymentMethod = pm != null ? pm : "All";
|
||||
int topNPos = cbTopN.getSelectionModel().getSelectedIndex();
|
||||
currentFilter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
|
||||
String sf = cbStoreFilter.getValue();
|
||||
currentFilter.storeFilter = (sf != null && !sf.isBlank()) ? sf : "All Stores";
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
private static class FilterState {
|
||||
String startDate = "";
|
||||
String endDate = "";
|
||||
String paymentMethod = "All";
|
||||
int topN = 5;
|
||||
String storeFilter = "All Stores";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,17 @@ import javafx.stage.Stage;
|
||||
|
||||
import org.example.petshopdesktop.DTOs.AppointmentDTO;
|
||||
import org.example.petshopdesktop.api.dto.appointment.AppointmentResponse;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.endpoints.AppointmentApi;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.controllers.dialogcontrollers.AppointmentDialogController;
|
||||
import org.example.petshopdesktop.ui.CalendarPane;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Comparator;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -43,11 +48,20 @@ public class AppointmentController {
|
||||
@FXML private Button btnEdit;
|
||||
@FXML private Button btnDelete;
|
||||
@FXML private Button btnRefresh;
|
||||
@FXML private javafx.scene.control.ToggleButton btnMyAppointments;
|
||||
|
||||
@FXML private Label lblStatus;
|
||||
|
||||
@FXML private TextField txtSearch;
|
||||
|
||||
@FXML private ComboBox<String> cbStatusFilter;
|
||||
@FXML private ComboBox<DropdownOption> cbStoreFilter;
|
||||
|
||||
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
|
||||
|
||||
@FXML private CalendarPane calendarPane;
|
||||
private LocalDate selectedCalendarDate = null;
|
||||
|
||||
private final ObservableList<AppointmentDTO> appointments = FXCollections.observableArrayList();
|
||||
private FilteredList<AppointmentDTO> filtered;
|
||||
|
||||
@@ -71,6 +85,22 @@ public class AppointmentController {
|
||||
TableViewSupport.bindSortedItems(tvAppointments, filtered);
|
||||
TableViewSupport.installDoubleClickAction(tvAppointments, selected -> openDialog(selected, "Edit"));
|
||||
|
||||
if (UserSession.getInstance().isStaff()) {
|
||||
btnMyAppointments.setVisible(true);
|
||||
btnMyAppointments.setManaged(true);
|
||||
}
|
||||
|
||||
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Booked", "Completed", "Missed", "Cancelled"));
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyFilterPredicate());
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
cbStoreFilter.setVisible(true);
|
||||
cbStoreFilter.setManaged(true);
|
||||
loadStoreFilter();
|
||||
}
|
||||
cbStoreFilter.valueProperty().addListener((obs, o, n) -> loadAppointments());
|
||||
|
||||
if (txtSearch != null) {
|
||||
txtSearch.textProperty().addListener((obs, o, n) -> applyFilter(n));
|
||||
}
|
||||
@@ -90,13 +120,24 @@ public class AppointmentController {
|
||||
});
|
||||
|
||||
loadAppointments();
|
||||
|
||||
calendarPane.setOnDateSelected(date -> {
|
||||
selectedCalendarDate = date;
|
||||
applyFilterPredicate();
|
||||
});
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnMyAppointmentsToggled(javafx.event.ActionEvent event) {
|
||||
loadAppointments();
|
||||
}
|
||||
|
||||
private void loadAppointments(){
|
||||
new Thread(() -> {
|
||||
try{
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(null, storeId);
|
||||
Long storeId = selectedStoreId();
|
||||
Long employeeId = btnMyAppointments.isSelected() ? UserSession.getInstance().getEmployeeId() : null;
|
||||
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(null, storeId, employeeId);
|
||||
List<AppointmentDTO> appointmentDTOs = responses.stream()
|
||||
.map(this::mapToAppointmentDTO)
|
||||
.sorted(Comparator.comparing((AppointmentDTO a) -> a.getAppointmentDate() + "T" + a.getAppointmentTime()).reversed())
|
||||
@@ -104,6 +145,13 @@ public class AppointmentController {
|
||||
|
||||
Platform.runLater(() -> {
|
||||
appointments.setAll(appointmentDTOs);
|
||||
Set<LocalDate> dates = appointmentDTOs.stream()
|
||||
.filter(a -> a.getAppointmentDate() != null && !a.getAppointmentDate().isEmpty())
|
||||
.map(a -> { try { return LocalDate.parse(a.getAppointmentDate()); } catch (Exception ex) { return null; } })
|
||||
.filter(d -> d != null)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
calendarPane.setEventDates(dates);
|
||||
applyFilterPredicate();
|
||||
});
|
||||
}catch(Exception e){
|
||||
Platform.runLater(() -> {
|
||||
@@ -121,8 +169,9 @@ public class AppointmentController {
|
||||
String query = text == null || text.trim().isEmpty() ? null : text.trim();
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(query, storeId);
|
||||
Long storeId = selectedStoreId();
|
||||
Long employeeId = btnMyAppointments.isSelected() ? UserSession.getInstance().getEmployeeId() : null;
|
||||
List<AppointmentResponse> responses = AppointmentApi.getInstance().listAppointments(query, storeId, employeeId);
|
||||
List<AppointmentDTO> appointmentDTOs = responses.stream()
|
||||
.map(this::mapToAppointmentDTO)
|
||||
.sorted(Comparator.comparing((AppointmentDTO a) -> a.getAppointmentDate() + "T" + a.getAppointmentTime()).reversed())
|
||||
@@ -130,6 +179,13 @@ public class AppointmentController {
|
||||
|
||||
Platform.runLater(() -> {
|
||||
appointments.setAll(appointmentDTOs);
|
||||
Set<LocalDate> dates = appointmentDTOs.stream()
|
||||
.filter(a -> a.getAppointmentDate() != null && !a.getAppointmentDate().isEmpty())
|
||||
.map(a -> { try { return LocalDate.parse(a.getAppointmentDate()); } catch (Exception ex) { return null; } })
|
||||
.filter(d -> d != null)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
calendarPane.setEventDates(dates);
|
||||
applyFilterPredicate();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
@@ -146,6 +202,10 @@ public class AppointmentController {
|
||||
@FXML
|
||||
void btnRefresh(ActionEvent event) {
|
||||
txtSearch.clear();
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
selectedCalendarDate = null;
|
||||
filtered.setPredicate(a -> true);
|
||||
tvAppointments.getSortOrder().clear();
|
||||
loadAppointments();
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
@@ -247,6 +307,44 @@ public class AppointmentController {
|
||||
}
|
||||
}
|
||||
|
||||
private void loadStoreFilter() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
DropdownOption allStores = new DropdownOption();
|
||||
allStores.setLabel("All Stores");
|
||||
Platform.runLater(() -> {
|
||||
storeOptions.clear();
|
||||
storeOptions.addAll(stores);
|
||||
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
|
||||
items.add(allStores);
|
||||
items.addAll(stores);
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("AppointmentController.loadStoreFilter", e, "Loading store filter");
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private Long selectedStoreId() {
|
||||
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
|
||||
DropdownOption selected = cbStoreFilter.getValue();
|
||||
return (selected != null && selected.getId() != null) ? selected.getId() : null;
|
||||
}
|
||||
|
||||
private void applyFilterPredicate() {
|
||||
String selectedStatus = cbStatusFilter.getValue();
|
||||
filtered.setPredicate(apt -> {
|
||||
boolean dateMatch = selectedCalendarDate == null
|
||||
|| apt.getAppointmentDate().equals(selectedCalendarDate.toString());
|
||||
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|
||||
|| apt.getAppointmentStatus().equalsIgnoreCase(selectedStatus);
|
||||
return dateMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
private void showAlert(String title, String msg){
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle(title);
|
||||
@@ -269,7 +367,8 @@ public class AppointmentController {
|
||||
response.getAppointmentDate() != null ? response.getAppointmentDate().toString() : "",
|
||||
response.getAppointmentTime() != null ? response.getAppointmentTime().toString() : "",
|
||||
normalizeAppointmentStatus(response.getAppointmentStatus()),
|
||||
response.getStoreName()
|
||||
response.getStoreName(),
|
||||
response.getStoreId()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.example.petshopdesktop.api.dto.chat.UpdateConversationRequest;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.endpoints.ChatApi;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.api.ApiClient;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
|
||||
@@ -176,33 +177,33 @@ public class ChatController {
|
||||
}
|
||||
|
||||
String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim();
|
||||
if (content.isEmpty()) {
|
||||
if (selectedAttachmentFile != null) {
|
||||
lblChatStatus.setText("Attachments are not available yet");
|
||||
}
|
||||
if (content.isEmpty() && selectedAttachmentFile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long convId = selectedConversation.getId();
|
||||
File attachmentFile = selectedAttachmentFile;
|
||||
|
||||
txtMessage.clear();
|
||||
btnSend.setDisable(true);
|
||||
|
||||
lblChatStatus.setText("Sending message...");
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
MessageRequest request = new MessageRequest(content);
|
||||
MessageResponse response = ChatApi.getInstance().sendMessage(convId, request);
|
||||
MessageResponse response;
|
||||
if (attachmentFile != null) {
|
||||
response = ChatApi.getInstance().sendMessageWithAttachment(convId, attachmentFile, content);
|
||||
} else {
|
||||
response = ChatApi.getInstance().sendMessage(convId, new MessageRequest(content));
|
||||
}
|
||||
Platform.runLater(() -> {
|
||||
btnSend.setDisable(false);
|
||||
realtimeClient.markConversationReplied(convId, UserSession.getInstance().getUserId());
|
||||
appendMessageIfSelected(response);
|
||||
if (selectedAttachmentFile != null) {
|
||||
if (attachmentFile != null) {
|
||||
clearLocalAttachment();
|
||||
lblChatStatus.setText("Message sent without attachment");
|
||||
} else {
|
||||
lblChatStatus.setText("Message sent");
|
||||
}
|
||||
lblChatStatus.setText("Message sent");
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
@@ -380,6 +381,7 @@ public class ChatController {
|
||||
List<ConversationResponse> response = ChatApi.getInstance().listConversations();
|
||||
response.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
Platform.runLater(() -> {
|
||||
realtimeClient.initializeState(response);
|
||||
conversations.setAll(response);
|
||||
refreshSections();
|
||||
restoreSelection();
|
||||
@@ -471,6 +473,7 @@ public class ChatController {
|
||||
.findFirst()
|
||||
.ifPresent(conversation -> {
|
||||
conversation.setLastMessage(message.getContent());
|
||||
conversation.setLastSenderId(message.getSenderId());
|
||||
conversation.setUpdatedAt(message.getTimestamp());
|
||||
conversations.sort(Comparator.comparing(ChatController::conversationSortTime, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
refreshSections();
|
||||
@@ -504,14 +507,16 @@ public class ChatController {
|
||||
Circle avatar = new Circle(16);
|
||||
avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1"));
|
||||
if (message.getSenderAvatarUrl() != null && !message.getSenderAvatarUrl().isBlank()) {
|
||||
try {
|
||||
String fullUrl = org.example.petshopdesktop.api.ApiConfig.getInstance().getBaseUrl() + message.getSenderAvatarUrl();
|
||||
Image img = new Image(fullUrl, true);
|
||||
img.errorProperty().addListener((obs, old, err) -> {
|
||||
if (err) avatar.setFill(javafx.scene.paint.Color.web(mine ? "#0f766e" : "#cbd5e1"));
|
||||
});
|
||||
avatar.setFill(new ImagePattern(img));
|
||||
} catch (Exception ignored) {}
|
||||
String relativeUrl = message.getSenderAvatarUrl();
|
||||
new Thread(() -> {
|
||||
try {
|
||||
byte[] bytes = ApiClient.getInstance().getBytes(relativeUrl);
|
||||
Image img = new Image(new java.io.ByteArrayInputStream(bytes));
|
||||
if (!img.isError()) {
|
||||
Platform.runLater(() -> avatar.setFill(new ImagePattern(img)));
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}).start();
|
||||
}
|
||||
|
||||
Label author = new Label(resolveAuthorLabel(message));
|
||||
@@ -529,6 +534,12 @@ public class ChatController {
|
||||
content.setStyle("-fx-text-fill: " + (mine ? "#ffffff" : "#1f2937") + ";");
|
||||
bubble.getChildren().add(content);
|
||||
}
|
||||
if (message.getAttachmentName() != null && !message.getAttachmentName().isBlank()) {
|
||||
Label attachmentLabel = new Label("\uD83D\uDCCE " + message.getAttachmentName());
|
||||
attachmentLabel.setStyle("-fx-text-fill: " + (mine ? "#dbeafe" : "#475569") + "; -fx-font-size: 12px;");
|
||||
attachmentLabel.setWrapText(true);
|
||||
bubble.getChildren().add(attachmentLabel);
|
||||
}
|
||||
|
||||
bubble.getChildren().add(timestamp);
|
||||
bubble.setMaxWidth(420);
|
||||
|
||||
@@ -7,13 +7,17 @@ import javafx.collections.transformation.FilteredList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.user.UserResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.CustomerApi;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.util.Comparator;
|
||||
@@ -25,6 +29,9 @@ public class CustomerAccountsController {
|
||||
@FXML
|
||||
private TableView<UserResponse> tvCustomers;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colCustomerAvatar;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colCustomerUsername;
|
||||
|
||||
@@ -49,6 +56,9 @@ public class CustomerAccountsController {
|
||||
@FXML
|
||||
private TextField txtSearchCustomer;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbStatusFilter;
|
||||
|
||||
@FXML
|
||||
private Button btnEditCustomer;
|
||||
|
||||
@@ -66,6 +76,13 @@ public class CustomerAccountsController {
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
colCustomerAvatar.setCellValueFactory(data -> {
|
||||
Long id = data.getValue().getId();
|
||||
if (id == null) return new javafx.beans.property.SimpleStringProperty("");
|
||||
return new javafx.beans.property.SimpleStringProperty("/api/v1/users/" + id + "/avatar/file");
|
||||
});
|
||||
configureAvatarColumn(colCustomerAvatar);
|
||||
|
||||
colCustomerUsername.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getUsername()));
|
||||
colCustomerName.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getFullName()));
|
||||
colCustomerEmail.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getEmail()));
|
||||
@@ -82,14 +99,40 @@ public class CustomerAccountsController {
|
||||
btnEditCustomer.setDisable(newVal == null));
|
||||
btnEditCustomer.setDisable(true);
|
||||
|
||||
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Active", "Inactive"));
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyCustomerFilter(txtSearchCustomer.getText()));
|
||||
|
||||
txtSearchCustomer.textProperty().addListener((obs, o, n) -> applyCustomerFilter(n));
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void configureAvatarColumn(TableColumn<UserResponse, String> column) {
|
||||
column.setCellFactory(col -> new TableCell<>() {
|
||||
private final ImageView imageView = new ImageView();
|
||||
private final StackPane container = new StackPane(imageView);
|
||||
{
|
||||
container.setAlignment(Pos.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null || item.isBlank()) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
DesktopImageSupport.loadImageInto(imageView, item, 48, 48);
|
||||
setGraphic(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnRefreshClicked(ActionEvent event) {
|
||||
txtSearchCustomer.clear();
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
TableViewSupport.clearSort(tvCustomers);
|
||||
refresh();
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
@@ -108,15 +151,15 @@ public class CustomerAccountsController {
|
||||
}
|
||||
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/example/petshopdesktop/dialogviews/staff-edit-dialog-view.fxml"));
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("/org/example/petshopdesktop/dialogviews/customer-edit-dialog-view.fxml"));
|
||||
Stage dialog = new Stage();
|
||||
dialog.initOwner(tvCustomers.getScene().getWindow());
|
||||
dialog.initModality(Modality.APPLICATION_MODAL);
|
||||
dialog.setTitle("Edit Customer Account");
|
||||
dialog.setScene(new Scene(loader.load()));
|
||||
dialog.setResizable(false);
|
||||
var controller = (org.example.petshopdesktop.controllers.dialogcontrollers.StaffEditDialogController) loader.getController();
|
||||
controller.setUser(selected);
|
||||
var controller = (org.example.petshopdesktop.controllers.dialogcontrollers.CustomerEditDialogController) loader.getController();
|
||||
controller.setCustomer(selected);
|
||||
dialog.showAndWait();
|
||||
refresh();
|
||||
} catch (Exception e) {
|
||||
@@ -154,16 +197,19 @@ public class CustomerAccountsController {
|
||||
|
||||
private void applyCustomerFilter(String text) {
|
||||
String q = text == null ? "" : text.trim().toLowerCase();
|
||||
if (q.isEmpty()) {
|
||||
filteredCustomers.setPredicate(a -> true);
|
||||
return;
|
||||
}
|
||||
filteredCustomers.setPredicate(a ->
|
||||
safe(a.getUsername()).contains(q)
|
||||
|| safe(a.getFullName()).contains(q)
|
||||
|| safe(a.getEmail()).contains(q)
|
||||
|| safe(a.getPhone()).contains(q)
|
||||
);
|
||||
String selectedStatus = cbStatusFilter.getValue();
|
||||
filteredCustomers.setPredicate(a -> {
|
||||
boolean textMatch = q.isEmpty()
|
||||
|| safe(a.getUsername()).contains(q)
|
||||
|| safe(a.getFullName()).contains(q)
|
||||
|| safe(a.getEmail()).contains(q)
|
||||
|| safe(a.getPhone()).contains(q);
|
||||
boolean active = Boolean.TRUE.equals(a.getActive());
|
||||
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|
||||
|| (selectedStatus.equals("Active") && active)
|
||||
|| (selectedStatus.equals("Inactive") && !active);
|
||||
return textMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
private static String safe(String v) {
|
||||
|
||||
@@ -12,7 +12,9 @@ import javafx.scene.control.cell.PropertyValueFactory;
|
||||
import javafx.stage.Modality;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.dto.inventory.InventoryResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.api.endpoints.InventoryApi;
|
||||
import org.example.petshopdesktop.controllers.dialogcontrollers.InventoryDialogController;
|
||||
import org.example.petshopdesktop.models.Inventory;
|
||||
@@ -63,6 +65,11 @@ public class InventoryController {
|
||||
@FXML
|
||||
private TextField txtSearch;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbStoreFilter;
|
||||
|
||||
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
|
||||
|
||||
private ObservableList<Inventory> data = FXCollections.observableArrayList();
|
||||
|
||||
//Determines if in add/edit mode
|
||||
@@ -81,6 +88,13 @@ public class InventoryController {
|
||||
colQuantity.setCellValueFactory(new PropertyValueFactory<>("quantity"));
|
||||
colStoreName.setCellValueFactory(new PropertyValueFactory<>("storeName"));
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
cbStoreFilter.setVisible(true);
|
||||
cbStoreFilter.setManaged(true);
|
||||
loadStoreFilter();
|
||||
}
|
||||
cbStoreFilter.valueProperty().addListener((obs, o, n) -> displayInventory());
|
||||
|
||||
displayInventory();
|
||||
TableViewSupport.installDoubleClickAction(tvInventory, selected -> openDialog(selected, "Edit"));
|
||||
|
||||
@@ -108,6 +122,7 @@ public class InventoryController {
|
||||
@FXML
|
||||
void btnRefresh(ActionEvent event) {
|
||||
txtSearch.clear();
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
tvInventory.getSortOrder().clear();
|
||||
displayInventory();
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
@@ -172,7 +187,7 @@ public class InventoryController {
|
||||
} else {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
Long storeId = selectedStoreId();
|
||||
List<InventoryResponse> inventories = InventoryApi.getInstance().listInventory(filter, storeId);
|
||||
List<Inventory> inventoryList = inventories.stream()
|
||||
.map(this::mapToInventory)
|
||||
@@ -198,7 +213,7 @@ public class InventoryController {
|
||||
private void displayInventory() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
Long storeId = selectedStoreId();
|
||||
List<InventoryResponse> inventories = InventoryApi.getInstance().listInventory(null, storeId);
|
||||
List<Inventory> inventoryList = inventories.stream()
|
||||
.map(this::mapToInventory)
|
||||
@@ -253,6 +268,33 @@ public class InventoryController {
|
||||
txtSearch.setText("");
|
||||
}
|
||||
|
||||
private void loadStoreFilter() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
DropdownOption allStores = new DropdownOption();
|
||||
allStores.setLabel("All Stores");
|
||||
Platform.runLater(() -> {
|
||||
storeOptions.clear();
|
||||
storeOptions.addAll(stores);
|
||||
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
|
||||
items.add(allStores);
|
||||
items.addAll(stores);
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("InventoryController.loadStoreFilter", e, "Loading store filter");
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private Long selectedStoreId() {
|
||||
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
|
||||
DropdownOption selected = cbStoreFilter.getValue();
|
||||
return (selected != null && selected.getId() != null) ? selected.getId() : null;
|
||||
}
|
||||
|
||||
private Inventory mapToInventory(InventoryResponse response) {
|
||||
return new Inventory(
|
||||
response.getInventoryId().intValue(),
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.example.petshopdesktop.controllers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.PasswordField;
|
||||
import javafx.scene.control.TextInputDialog;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Stage;
|
||||
@@ -14,6 +17,7 @@ import org.example.petshopdesktop.api.ApiClient;
|
||||
import org.example.petshopdesktop.api.dto.auth.LoginRequest;
|
||||
import org.example.petshopdesktop.api.dto.auth.LoginResponse;
|
||||
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.AuthApi;
|
||||
import org.example.petshopdesktop.auth.Role;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.ui.SvgWebViewFactory;
|
||||
@@ -105,6 +109,42 @@ public class LoginController {
|
||||
}
|
||||
}
|
||||
|
||||
@FXML
|
||||
void lnkForgotPasswordClicked(ActionEvent event) {
|
||||
TextInputDialog dialog = new TextInputDialog();
|
||||
dialog.setTitle("Forgot Password");
|
||||
dialog.setHeaderText("Reset your password");
|
||||
dialog.setContentText("Enter your username or email:");
|
||||
|
||||
dialog.showAndWait().ifPresent(input -> {
|
||||
if (input.trim().isEmpty()) return;
|
||||
new Thread(() -> {
|
||||
try {
|
||||
AuthApi.getInstance().forgotPassword(input.trim());
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle("Reset Link Sent");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText("If this account exists, a password reset link has been sent to the associated email.");
|
||||
alert.showAndWait();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"LoginController.lnkForgotPasswordClicked",
|
||||
e,
|
||||
"Forgot password request for: " + input.trim());
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle("Error");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText("Could not send reset link. Please try again.");
|
||||
alert.showAndWait();
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
});
|
||||
}
|
||||
|
||||
private void openMainLayout() {
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(
|
||||
|
||||
@@ -113,6 +113,9 @@ public class MainLayoutController {
|
||||
@FXML
|
||||
private Button btnRemoveAvatar;
|
||||
|
||||
@FXML
|
||||
private Button btnRefreshAvatar;
|
||||
|
||||
@FXML
|
||||
private Label lblUsername;
|
||||
|
||||
@@ -253,6 +256,29 @@ public class MainLayoutController {
|
||||
}
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnRefreshAvatarClicked(ActionEvent event) {
|
||||
btnRefreshAvatar.setDisable(true);
|
||||
new Thread(() -> {
|
||||
try {
|
||||
UserInfoResponse userInfo = AuthApi.getInstance().getCurrentUser();
|
||||
String displayName = userInfo.getFullName() == null || userInfo.getFullName().isBlank()
|
||||
? UserSession.getInstance().getUsername()
|
||||
: userInfo.getFullName();
|
||||
Image avatarImage = loadAvatarImage(userInfo.getAvatarUrl());
|
||||
Platform.runLater(() -> {
|
||||
UserSession.getInstance().setAvatarUrl(userInfo.getAvatarUrl());
|
||||
renderAvatar(displayName, avatarImage);
|
||||
btnRemoveAvatar.setDisable(userInfo.getAvatarUrl() == null || userInfo.getAvatarUrl().isBlank());
|
||||
btnRefreshAvatar.setDisable(false);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> btnRefreshAvatar.setDisable(false));
|
||||
ActivityLogger.getInstance().logException("MainLayoutController.btnRefreshAvatarClicked", e, "Refreshing avatar");
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnRemoveAvatarClicked(ActionEvent event) {
|
||||
try {
|
||||
|
||||
@@ -86,18 +86,20 @@ public class PetController {
|
||||
@FXML
|
||||
private ComboBox<String> cbStatusFilter;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbStoreFilter;
|
||||
|
||||
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
|
||||
|
||||
@FXML
|
||||
private TextField txtSearch;
|
||||
|
||||
@FXML
|
||||
void btnRefresh(ActionEvent event) {
|
||||
txtSearch.clear();
|
||||
if (cbSpeciesFilter != null) {
|
||||
cbSpeciesFilter.getSelectionModel().selectFirst();
|
||||
}
|
||||
if (cbStatusFilter != null) {
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
}
|
||||
if (cbSpeciesFilter != null) cbSpeciesFilter.getSelectionModel().selectFirst();
|
||||
if (cbStatusFilter != null) cbStatusFilter.getSelectionModel().selectFirst();
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
tvPets.getSortOrder().clear();
|
||||
displayPets();
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
@@ -194,6 +196,13 @@ public class PetController {
|
||||
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Available", "Adopted", "Owned", "Pending"));
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
cbStoreFilter.setVisible(true);
|
||||
cbStoreFilter.setManaged(true);
|
||||
loadStoreFilter();
|
||||
}
|
||||
cbStoreFilter.valueProperty().addListener((obs, o, n) -> applyFilters());
|
||||
|
||||
displayPets();
|
||||
TableViewSupport.installDoubleClickAction(tvPets, selected -> openDialog(selected, "Edit"));
|
||||
|
||||
@@ -229,7 +238,7 @@ public class PetController {
|
||||
} else {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
Long storeId = selectedStoreId();
|
||||
List<PetResponse> pets = PetApi.getInstance().listPets(filter, species, status, storeId);
|
||||
List<Pet> petList = pets.stream()
|
||||
.map(this::mapToPet)
|
||||
@@ -255,7 +264,7 @@ public class PetController {
|
||||
private void displayPets() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
Long storeId = selectedStoreId();
|
||||
List<PetResponse> pets = PetApi.getInstance().listPets(null, selectedSpecies(), selectedStatus(), storeId);
|
||||
List<Pet> petList = pets.stream()
|
||||
.map(this::mapToPet)
|
||||
@@ -281,6 +290,33 @@ public class PetController {
|
||||
displayFilteredPet(txtSearch.getText());
|
||||
}
|
||||
|
||||
private void loadStoreFilter() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
DropdownOption allStores = new DropdownOption();
|
||||
allStores.setLabel("All Stores");
|
||||
Platform.runLater(() -> {
|
||||
storeOptions.clear();
|
||||
storeOptions.addAll(stores);
|
||||
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
|
||||
items.add(allStores);
|
||||
items.addAll(stores);
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("PetController.loadStoreFilter", e, "Loading store filter");
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private Long selectedStoreId() {
|
||||
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
|
||||
DropdownOption selected = cbStoreFilter.getValue();
|
||||
return (selected != null && selected.getId() != null) ? selected.getId() : null;
|
||||
}
|
||||
|
||||
private void loadSpeciesFilter() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ import javafx.application.Platform;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.CheckBox;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
@@ -19,18 +20,25 @@ import javafx.scene.control.TableColumn;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.cell.PropertyValueFactory;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import javafx.concurrent.Task;
|
||||
import org.example.petshopdesktop.api.endpoints.CustomerApi;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.api.endpoints.ProductApi;
|
||||
import org.example.petshopdesktop.api.endpoints.SaleApi;
|
||||
import org.example.petshopdesktop.api.endpoints.CouponApi;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.dto.coupon.CouponResponse;
|
||||
import org.example.petshopdesktop.api.dto.product.ProductResponse;
|
||||
import org.example.petshopdesktop.api.dto.sale.SaleItemRequest;
|
||||
import org.example.petshopdesktop.api.dto.sale.SaleItemResponse;
|
||||
import org.example.petshopdesktop.api.dto.sale.SaleRequest;
|
||||
import org.example.petshopdesktop.api.dto.sale.SaleResponse;
|
||||
import org.example.petshopdesktop.api.dto.user.UserResponse;
|
||||
import org.example.petshopdesktop.models.Product;
|
||||
import org.example.petshopdesktop.models.SaleCartItem;
|
||||
import org.example.petshopdesktop.models.SaleDetail;
|
||||
@@ -111,6 +119,9 @@ public class SaleController {
|
||||
@FXML
|
||||
private TableColumn<SaleLineItem, String> colEmployeeName;
|
||||
|
||||
@FXML
|
||||
private TableColumn<SaleLineItem, String> colCustomerName;
|
||||
|
||||
@FXML
|
||||
private TableColumn<SaleLineItem, String> colServiceProduct;
|
||||
|
||||
@@ -135,11 +146,73 @@ public class SaleController {
|
||||
@FXML
|
||||
private TextField txtSearch;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbFilterPayment;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbFilterRefundStatus;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbFilterCustomer;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbStoreFilter;
|
||||
|
||||
private final java.util.ArrayList<DropdownOption> storeOptions = new java.util.ArrayList<>();
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbCustomer;
|
||||
|
||||
@FXML
|
||||
private CheckBox chkUseLoyaltyPoints;
|
||||
|
||||
@FXML
|
||||
private Label lblLoyaltyPoints;
|
||||
|
||||
@FXML
|
||||
private TextField txtCouponCode;
|
||||
|
||||
@FXML
|
||||
private Button btnApplyCoupon;
|
||||
|
||||
@FXML
|
||||
private Button btnClearCoupon;
|
||||
|
||||
@FXML
|
||||
private Label lblCouponStatus;
|
||||
|
||||
@FXML
|
||||
private Label lblSubtotal;
|
||||
|
||||
@FXML
|
||||
private HBox hbCouponDiscount;
|
||||
|
||||
@FXML
|
||||
private Label lblCouponDiscount;
|
||||
|
||||
@FXML
|
||||
private HBox hbLoyaltyDiscount;
|
||||
|
||||
@FXML
|
||||
private Label lblLoyaltyDiscountLabel;
|
||||
|
||||
@FXML
|
||||
private Label lblLoyaltyDiscount;
|
||||
|
||||
@FXML
|
||||
private HBox hbPointsToEarn;
|
||||
|
||||
@FXML
|
||||
private Label lblPointsToEarn;
|
||||
|
||||
private final ObservableList<SaleCartItem> cartItems = FXCollections.observableArrayList();
|
||||
private final ObservableList<SaleLineItem> saleItems = FXCollections.observableArrayList();
|
||||
private FilteredList<SaleLineItem> filteredSales;
|
||||
private boolean saleSaveInProgress;
|
||||
|
||||
private CouponResponse appliedCoupon = null;
|
||||
private UserResponse selectedCustomerData = null;
|
||||
|
||||
private final NumberFormat currency = NumberFormat.getCurrencyInstance(Locale.CANADA);
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
@@ -173,6 +246,8 @@ public class SaleController {
|
||||
colSaleId.setCellValueFactory(new PropertyValueFactory<>("saleId"));
|
||||
colSaleDate.setCellValueFactory(new PropertyValueFactory<>("saleDate"));
|
||||
colEmployeeName.setCellValueFactory(new PropertyValueFactory<>("employeeName"));
|
||||
colCustomerName.setCellValueFactory(new PropertyValueFactory<>("customerName"));
|
||||
colCustomerName.setMinWidth(120);
|
||||
colServiceProduct.setCellValueFactory(new PropertyValueFactory<>("itemName"));
|
||||
colSaleQuantity.setCellValueFactory(new PropertyValueFactory<>("quantity"));
|
||||
colSaleUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
|
||||
@@ -194,6 +269,24 @@ public class SaleController {
|
||||
TableViewSupport.bindSortedItems(tvSales, filteredSales);
|
||||
TableViewSupport.installDoubleClickAction(tvSales, selected -> openSaleDetailDialog(selected.getSaleId()));
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
cbStoreFilter.setVisible(true);
|
||||
cbStoreFilter.setManaged(true);
|
||||
loadStoreFilter();
|
||||
}
|
||||
cbStoreFilter.valueProperty().addListener((obs, o, n) -> refreshSales(false));
|
||||
|
||||
cbFilterPayment.setItems(FXCollections.observableArrayList("All Payments", "Cash", "Card"));
|
||||
cbFilterPayment.getSelectionModel().selectFirst();
|
||||
cbFilterPayment.valueProperty().addListener((obs, o, n) -> applySalesFilter(txtSearch.getText()));
|
||||
|
||||
cbFilterRefundStatus.setItems(FXCollections.observableArrayList("All Status", "Sales Only", "Refunds Only"));
|
||||
cbFilterRefundStatus.getSelectionModel().selectFirst();
|
||||
cbFilterRefundStatus.valueProperty().addListener((obs, o, n) -> applySalesFilter(txtSearch.getText()));
|
||||
|
||||
loadCustomerFilter();
|
||||
cbFilterCustomer.valueProperty().addListener((obs, o, n) -> applySalesFilter(txtSearch.getText()));
|
||||
|
||||
txtSearch.textProperty().addListener((obs, oldVal, newVal) -> applySalesFilter(newVal));
|
||||
tvSales.widthProperty().addListener((obs, oldWidth, newWidth) -> updateSalesColumnWidths(newWidth.doubleValue()));
|
||||
tvCart.widthProperty().addListener((obs, oldWidth, newWidth) -> updateCartColumnWidths(newWidth.doubleValue()));
|
||||
@@ -214,7 +307,23 @@ public class SaleController {
|
||||
|
||||
setCreateSaleControlsDisabled(true);
|
||||
|
||||
Task<ObservableList<Product>> task = new Task<>() {
|
||||
cbCustomer.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
loadCustomerDetails(newVal.getId());
|
||||
} else {
|
||||
selectedCustomerData = null;
|
||||
lblLoyaltyPoints.setVisible(false);
|
||||
lblLoyaltyPoints.setManaged(false);
|
||||
chkUseLoyaltyPoints.setVisible(false);
|
||||
chkUseLoyaltyPoints.setManaged(false);
|
||||
chkUseLoyaltyPoints.setSelected(false);
|
||||
updateCartTotal();
|
||||
}
|
||||
});
|
||||
|
||||
chkUseLoyaltyPoints.selectedProperty().addListener((obs, oldVal, newVal) -> updateCartTotal());
|
||||
|
||||
Task<ObservableList<Product>> productsTask = new Task<>() {
|
||||
@Override
|
||||
protected ObservableList<Product> call() throws Exception {
|
||||
List<ProductResponse> productResponses = ProductApi.getInstance().listProducts(null);
|
||||
@@ -232,13 +341,13 @@ public class SaleController {
|
||||
}
|
||||
};
|
||||
|
||||
task.setOnSucceeded(event -> {
|
||||
cbProduct.setItems(task.getValue());
|
||||
productsTask.setOnSucceeded(event -> {
|
||||
cbProduct.setItems(productsTask.getValue());
|
||||
setCreateSaleControlsDisabled(false);
|
||||
});
|
||||
|
||||
task.setOnFailed(event -> {
|
||||
Throwable e = task.getException();
|
||||
productsTask.setOnFailed(event -> {
|
||||
Throwable e = productsTask.getException();
|
||||
ActivityLogger.getInstance().logException(
|
||||
"SaleController.setupCreateSale",
|
||||
e instanceof Exception ? (Exception) e : new RuntimeException(e),
|
||||
@@ -248,6 +357,73 @@ public class SaleController {
|
||||
showError("Sales", "Could not load products. Check the backend connection and refresh the view.");
|
||||
});
|
||||
|
||||
new Thread(productsTask).start();
|
||||
|
||||
Task<List<DropdownOption>> customersTask = new Task<>() {
|
||||
@Override
|
||||
protected List<DropdownOption> call() throws Exception {
|
||||
return DropdownApi.getInstance().getCustomers();
|
||||
}
|
||||
};
|
||||
|
||||
customersTask.setOnSucceeded(event -> {
|
||||
List<DropdownOption> customers = customersTask.getValue();
|
||||
ObservableList<DropdownOption> customerOptions = FXCollections.observableArrayList();
|
||||
customerOptions.addAll(customers);
|
||||
cbCustomer.setItems(customerOptions);
|
||||
});
|
||||
|
||||
customersTask.setOnFailed(event -> {
|
||||
Throwable e = customersTask.getException();
|
||||
ActivityLogger.getInstance().logException(
|
||||
"SaleController.setupCreateSale",
|
||||
e instanceof Exception ? (Exception) e : new RuntimeException(e),
|
||||
"Loading customers"
|
||||
);
|
||||
});
|
||||
|
||||
new Thread(customersTask).start();
|
||||
}
|
||||
|
||||
private void loadCustomerDetails(Long customerId) {
|
||||
Task<UserResponse> task = new Task<>() {
|
||||
@Override
|
||||
protected UserResponse call() throws Exception {
|
||||
return CustomerApi.getInstance().getCustomerById(customerId);
|
||||
}
|
||||
};
|
||||
|
||||
task.setOnSucceeded(event -> {
|
||||
selectedCustomerData = task.getValue();
|
||||
if (selectedCustomerData != null) {
|
||||
int pts = selectedCustomerData.getLoyaltyPoints() != null ? selectedCustomerData.getLoyaltyPoints() : 0;
|
||||
lblLoyaltyPoints.setText(pts + " pts available");
|
||||
lblLoyaltyPoints.setVisible(true);
|
||||
lblLoyaltyPoints.setManaged(true);
|
||||
boolean canRedeem = pts >= 20;
|
||||
chkUseLoyaltyPoints.setVisible(canRedeem);
|
||||
chkUseLoyaltyPoints.setManaged(canRedeem);
|
||||
if (!canRedeem) chkUseLoyaltyPoints.setSelected(false);
|
||||
} else {
|
||||
lblLoyaltyPoints.setVisible(false);
|
||||
lblLoyaltyPoints.setManaged(false);
|
||||
chkUseLoyaltyPoints.setVisible(false);
|
||||
chkUseLoyaltyPoints.setManaged(false);
|
||||
chkUseLoyaltyPoints.setSelected(false);
|
||||
}
|
||||
updateCartTotal();
|
||||
});
|
||||
|
||||
task.setOnFailed(event -> {
|
||||
selectedCustomerData = null;
|
||||
Throwable e = task.getException();
|
||||
ActivityLogger.getInstance().logException(
|
||||
"SaleController.loadCustomerDetails",
|
||||
e instanceof Exception ? (Exception) e : new RuntimeException(e),
|
||||
"Loading customer details"
|
||||
);
|
||||
});
|
||||
|
||||
new Thread(task).start();
|
||||
}
|
||||
|
||||
@@ -269,8 +445,7 @@ public class SaleController {
|
||||
Task<List<SaleLineItem>> task = new Task<List<SaleLineItem>>() {
|
||||
@Override
|
||||
protected List<SaleLineItem> call() throws Exception {
|
||||
Long storeId = UserSession.getInstance().isAdmin() ? null : UserSession.getInstance().getStoreId();
|
||||
List<SaleResponse> sales = SaleApi.getInstance().listAllSales(null, storeId);
|
||||
List<SaleResponse> sales = SaleApi.getInstance().listAllSales(null, selectedStoreId());
|
||||
sales.sort(Comparator.comparing(SaleResponse::getSaleDate, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(SaleResponse::getSaleId, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
List<SaleLineItem> lineItems = new ArrayList<>();
|
||||
@@ -281,6 +456,9 @@ public class SaleController {
|
||||
: "";
|
||||
|
||||
if (sale.getItems() != null && !sale.getItems().isEmpty()) {
|
||||
double saleSubtotal = sale.getSubtotalAmount() != null ? Math.abs(sale.getSubtotalAmount().doubleValue()) : 0;
|
||||
double saleActualTotal = sale.getTotalAmount() != null ? Math.abs(sale.getTotalAmount().doubleValue()) : 0;
|
||||
double discountRatio = saleSubtotal > 0 ? saleActualTotal / saleSubtotal : 1.0;
|
||||
for (SaleItemResponse item : sale.getItems()) {
|
||||
boolean isRefund = sale.getIsRefund() != null && sale.getIsRefund();
|
||||
double unitPrice = item.getUnitPrice() != null ? item.getUnitPrice().doubleValue() : 0.0;
|
||||
@@ -288,7 +466,7 @@ public class SaleController {
|
||||
if (isRefund && quantity > 0) {
|
||||
quantity = -quantity;
|
||||
}
|
||||
double lineTotal = unitPrice * quantity;
|
||||
double lineTotal = unitPrice * quantity * discountRatio;
|
||||
lineItems.add(new SaleLineItem(
|
||||
sale.getSaleId().intValue(),
|
||||
saleDate,
|
||||
@@ -299,7 +477,8 @@ public class SaleController {
|
||||
lineTotal,
|
||||
sale.getPaymentMethod(),
|
||||
isRefund,
|
||||
sale.getStoreName()
|
||||
sale.getStoreName(),
|
||||
sale.getCustomerName()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -326,6 +505,10 @@ public class SaleController {
|
||||
@FXML
|
||||
void btnRefresh(ActionEvent event) {
|
||||
txtSearch.clear();
|
||||
cbFilterPayment.getSelectionModel().selectFirst();
|
||||
cbFilterRefundStatus.getSelectionModel().selectFirst();
|
||||
cbFilterCustomer.getSelectionModel().selectFirst();
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
TableViewSupport.clearSort(tvSales);
|
||||
refreshSales(false);
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
@@ -379,6 +562,73 @@ public class SaleController {
|
||||
updateCartTotal();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnApplyCoupon(ActionEvent event) {
|
||||
String code = txtCouponCode.getText().trim();
|
||||
if (code.isEmpty()) {
|
||||
lblCouponStatus.setText("Enter a coupon code.");
|
||||
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
|
||||
return;
|
||||
}
|
||||
|
||||
BigDecimal subtotal = BigDecimal.valueOf(cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum());
|
||||
|
||||
Task<CouponResponse> task = new Task<>() {
|
||||
@Override
|
||||
protected CouponResponse call() throws Exception {
|
||||
return CouponApi.getInstance().getCouponByCode(code);
|
||||
}
|
||||
};
|
||||
|
||||
task.setOnSucceeded(evt -> {
|
||||
CouponResponse coupon = task.getValue();
|
||||
if (coupon == null) {
|
||||
lblCouponStatus.setText("Coupon not found.");
|
||||
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
|
||||
return;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(coupon.getActive())) {
|
||||
lblCouponStatus.setText("Coupon is not active.");
|
||||
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
|
||||
return;
|
||||
}
|
||||
if (coupon.getMinOrderAmount() != null && subtotal.compareTo(coupon.getMinOrderAmount()) < 0) {
|
||||
lblCouponStatus.setText("Order minimum not met ($" + coupon.getMinOrderAmount() + ").");
|
||||
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
|
||||
return;
|
||||
}
|
||||
appliedCoupon = coupon;
|
||||
txtCouponCode.setDisable(true);
|
||||
lblCouponStatus.setText("Coupon applied: " + coupon.getCouponCode());
|
||||
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#27ae60"));
|
||||
updateCartTotal();
|
||||
});
|
||||
|
||||
task.setOnFailed(evt -> {
|
||||
Throwable e = task.getException();
|
||||
lblCouponStatus.setText("Invalid or not found.");
|
||||
lblCouponStatus.setTextFill(javafx.scene.paint.Color.web("#e74c3c"));
|
||||
ActivityLogger.getInstance().logException(
|
||||
"SaleController.btnApplyCoupon",
|
||||
e instanceof Exception ? (Exception) e : new RuntimeException(e),
|
||||
"Applying coupon"
|
||||
);
|
||||
});
|
||||
|
||||
new Thread(task).start();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnClearCoupon(ActionEvent event) {
|
||||
appliedCoupon = null;
|
||||
txtCouponCode.clear();
|
||||
txtCouponCode.setDisable(false);
|
||||
lblCouponStatus.setText("");
|
||||
hbCouponDiscount.setVisible(false);
|
||||
hbCouponDiscount.setManaged(false);
|
||||
updateCartTotal();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnSaveSale(ActionEvent event) {
|
||||
if (saleSaveInProgress) {
|
||||
@@ -409,6 +659,7 @@ public class SaleController {
|
||||
SaleRequest request = new SaleRequest();
|
||||
request.setStoreId(storeId);
|
||||
request.setPaymentMethod(payment);
|
||||
request.setIsRefund(false);
|
||||
|
||||
List<SaleItemRequest> itemRequests = new ArrayList<>();
|
||||
for (SaleCartItem cartItem : cartItems) {
|
||||
@@ -419,6 +670,20 @@ public class SaleController {
|
||||
}
|
||||
request.setItems(itemRequests);
|
||||
|
||||
DropdownOption customerOption = cbCustomer.getValue();
|
||||
if (customerOption != null) {
|
||||
request.setCustomerId(customerOption.getId());
|
||||
}
|
||||
if (appliedCoupon != null) {
|
||||
request.setCouponId(appliedCoupon.getCouponId());
|
||||
}
|
||||
if (chkUseLoyaltyPoints.isSelected() && selectedCustomerData != null) {
|
||||
BigDecimal subtotal = BigDecimal.valueOf(cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum());
|
||||
BigDecimal couponDiscount = calculateCouponDiscount(subtotal);
|
||||
BigDecimal subtotalAfterCoupon = subtotal.subtract(couponDiscount);
|
||||
request.setPointsUsed(calculatePointsToUse(subtotalAfterCoupon));
|
||||
}
|
||||
|
||||
saleSaveInProgress = true;
|
||||
setCreateSaleControlsDisabled(true);
|
||||
btnRefund.setDisable(true);
|
||||
@@ -437,6 +702,23 @@ public class SaleController {
|
||||
SaleResponse response = task.getValue();
|
||||
showInfo("Sale saved", "Sale ID " + response.getSaleId() + " was created.");
|
||||
cartItems.clear();
|
||||
appliedCoupon = null;
|
||||
txtCouponCode.clear();
|
||||
txtCouponCode.setDisable(false);
|
||||
lblCouponStatus.setText("");
|
||||
hbCouponDiscount.setVisible(false);
|
||||
hbCouponDiscount.setManaged(false);
|
||||
hbLoyaltyDiscount.setVisible(false);
|
||||
hbLoyaltyDiscount.setManaged(false);
|
||||
hbPointsToEarn.setVisible(false);
|
||||
hbPointsToEarn.setManaged(false);
|
||||
cbCustomer.setValue(null);
|
||||
selectedCustomerData = null;
|
||||
lblLoyaltyPoints.setVisible(false);
|
||||
lblLoyaltyPoints.setManaged(false);
|
||||
chkUseLoyaltyPoints.setVisible(false);
|
||||
chkUseLoyaltyPoints.setManaged(false);
|
||||
chkUseLoyaltyPoints.setSelected(false);
|
||||
updateCartTotal();
|
||||
refreshSales(true);
|
||||
});
|
||||
@@ -536,6 +818,9 @@ public class SaleController {
|
||||
|
||||
private SaleDetail mapToSaleDetail(SaleResponse sale) {
|
||||
ObservableList<SaleDetail.SaleDetailItem> items = FXCollections.observableArrayList();
|
||||
double saleSubtotal = sale.getSubtotalAmount() != null ? Math.abs(sale.getSubtotalAmount().doubleValue()) : 0;
|
||||
double saleActualTotal = sale.getTotalAmount() != null ? Math.abs(sale.getTotalAmount().doubleValue()) : 0;
|
||||
double discountRatio = saleSubtotal > 0 ? saleActualTotal / saleSubtotal : 1.0;
|
||||
if (sale.getItems() != null) {
|
||||
boolean isRefund = sale.getIsRefund() != null && sale.getIsRefund();
|
||||
for (SaleItemResponse item : sale.getItems()) {
|
||||
@@ -549,10 +834,13 @@ public class SaleController {
|
||||
item.getProductName(),
|
||||
quantity,
|
||||
unitPrice,
|
||||
unitPrice * quantity
|
||||
unitPrice * quantity * discountRatio
|
||||
));
|
||||
}
|
||||
}
|
||||
double subtotal = sale.getSubtotalAmount() != null ? sale.getSubtotalAmount().doubleValue() : 0.0;
|
||||
double couponDiscount = sale.getCouponDiscountAmount() != null ? sale.getCouponDiscountAmount().doubleValue() : 0.0;
|
||||
double loyaltyDiscount = sale.getLoyaltyDiscountAmount() != null ? sale.getLoyaltyDiscountAmount().doubleValue() : 0.0;
|
||||
return new SaleDetail(
|
||||
sale.getSaleId().intValue(),
|
||||
sale.getSaleDate(),
|
||||
@@ -560,18 +848,86 @@ public class SaleController {
|
||||
sale.getPaymentMethod(),
|
||||
sale.getEmployeeName(),
|
||||
Boolean.TRUE.equals(sale.getIsRefund()),
|
||||
items
|
||||
items,
|
||||
sale.getCustomerName(),
|
||||
subtotal,
|
||||
couponDiscount,
|
||||
loyaltyDiscount
|
||||
);
|
||||
}
|
||||
|
||||
private void updateCartTotal() {
|
||||
double total = cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum();
|
||||
lblCartTotal.setText(currency.format(total));
|
||||
BigDecimal subtotal = BigDecimal.valueOf(cartItems.stream().mapToDouble(SaleCartItem::getTotal).sum());
|
||||
BigDecimal couponDiscount = calculateCouponDiscount(subtotal);
|
||||
BigDecimal subtotalAfterCoupon = subtotal.subtract(couponDiscount);
|
||||
BigDecimal loyaltyDiscount = calculateLoyaltyDiscount(subtotalAfterCoupon);
|
||||
BigDecimal total = subtotalAfterCoupon.subtract(loyaltyDiscount);
|
||||
|
||||
lblSubtotal.setText(currency.format(subtotal.doubleValue()));
|
||||
|
||||
if (couponDiscount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
lblCouponDiscount.setText("-" + currency.format(couponDiscount.doubleValue()));
|
||||
hbCouponDiscount.setVisible(true);
|
||||
hbCouponDiscount.setManaged(true);
|
||||
} else {
|
||||
hbCouponDiscount.setVisible(false);
|
||||
hbCouponDiscount.setManaged(false);
|
||||
}
|
||||
|
||||
if (loyaltyDiscount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
int pointsToUse = calculatePointsToUse(subtotalAfterCoupon);
|
||||
lblLoyaltyDiscountLabel.setText("Loyalty Discount (" + pointsToUse + " pts):");
|
||||
lblLoyaltyDiscount.setText("-" + currency.format(loyaltyDiscount.doubleValue()));
|
||||
hbLoyaltyDiscount.setVisible(true);
|
||||
hbLoyaltyDiscount.setManaged(true);
|
||||
} else {
|
||||
hbLoyaltyDiscount.setVisible(false);
|
||||
hbLoyaltyDiscount.setManaged(false);
|
||||
}
|
||||
|
||||
lblCartTotal.setText(currency.format(Math.max(0, total.doubleValue())));
|
||||
|
||||
if (selectedCustomerData != null) {
|
||||
int pointsToEarn = (int) Math.max(0, total.doubleValue());
|
||||
lblPointsToEarn.setText("+" + pointsToEarn + " pts");
|
||||
hbPointsToEarn.setVisible(true);
|
||||
hbPointsToEarn.setManaged(true);
|
||||
} else {
|
||||
hbPointsToEarn.setVisible(false);
|
||||
hbPointsToEarn.setManaged(false);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal calculateCouponDiscount(BigDecimal subtotal) {
|
||||
if (appliedCoupon == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if ("PERCENTAGE".equalsIgnoreCase(appliedCoupon.getDiscountType())) {
|
||||
return subtotal.multiply(appliedCoupon.getDiscountValue()).divide(BigDecimal.valueOf(100));
|
||||
} else {
|
||||
return appliedCoupon.getDiscountValue().min(subtotal);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal calculateLoyaltyDiscount(BigDecimal subtotalAfterCoupon) {
|
||||
if (!chkUseLoyaltyPoints.isSelected() || selectedCustomerData == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
int pointsToUse = calculatePointsToUse(subtotalAfterCoupon);
|
||||
return BigDecimal.valueOf(pointsToUse).multiply(BigDecimal.valueOf(0.05));
|
||||
}
|
||||
|
||||
private int calculatePointsToUse(BigDecimal subtotalAfterCoupon) {
|
||||
if (selectedCustomerData == null || selectedCustomerData.getLoyaltyPoints() == null) {
|
||||
return 0;
|
||||
}
|
||||
int maxPointsNeeded = subtotalAfterCoupon.multiply(BigDecimal.valueOf(20)).intValue();
|
||||
return Math.min(selectedCustomerData.getLoyaltyPoints(), maxPointsNeeded);
|
||||
}
|
||||
|
||||
private void updateSalesColumnWidths(double tableWidth) {
|
||||
double available = Math.max(tableWidth - 28.0, 0.0);
|
||||
double baseWidth = 1255.0;
|
||||
double baseWidth = 1395.0;
|
||||
if (available <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -580,6 +936,7 @@ public class SaleController {
|
||||
colSaleId.setPrefWidth(60.0);
|
||||
colSaleDate.setPrefWidth(170.0);
|
||||
colEmployeeName.setPrefWidth(160.0);
|
||||
colCustomerName.setPrefWidth(140.0);
|
||||
colServiceProduct.setPrefWidth(320.0);
|
||||
colSaleQuantity.setPrefWidth(70.0);
|
||||
colSaleUnitPrice.setPrefWidth(115.0);
|
||||
@@ -591,14 +948,15 @@ public class SaleController {
|
||||
|
||||
double extra = available - baseWidth;
|
||||
colSaleId.setPrefWidth(60.0);
|
||||
colSaleDate.setPrefWidth(170.0 + extra * 0.16);
|
||||
colEmployeeName.setPrefWidth(160.0 + extra * 0.16);
|
||||
colServiceProduct.setPrefWidth(320.0 + extra * 0.38);
|
||||
colSaleDate.setPrefWidth(170.0 + extra * 0.14);
|
||||
colEmployeeName.setPrefWidth(160.0 + extra * 0.14);
|
||||
colCustomerName.setPrefWidth(140.0 + extra * 0.12);
|
||||
colServiceProduct.setPrefWidth(320.0 + extra * 0.34);
|
||||
colSaleQuantity.setPrefWidth(70.0);
|
||||
colSaleUnitPrice.setPrefWidth(115.0 + extra * 0.08);
|
||||
colSaleTotal.setPrefWidth(120.0 + extra * 0.08);
|
||||
colSalePaymentType.setPrefWidth(110.0 + extra * 0.06);
|
||||
colStoreName.setPrefWidth(130.0 + extra * 0.08);
|
||||
colStoreName.setPrefWidth(130.0 + extra * 0.04);
|
||||
}
|
||||
|
||||
private void updateCartColumnWidths(double tableWidth) {
|
||||
@@ -631,28 +989,92 @@ public class SaleController {
|
||||
cbPaymentMethod.setDisable(disabled);
|
||||
btnClearCart.setDisable(disabled);
|
||||
btnSaveSale.setDisable(disabled);
|
||||
cbCustomer.setDisable(disabled);
|
||||
txtCouponCode.setDisable(disabled);
|
||||
btnApplyCoupon.setDisable(disabled);
|
||||
btnClearCoupon.setDisable(disabled);
|
||||
chkUseLoyaltyPoints.setDisable(disabled);
|
||||
}
|
||||
|
||||
private void applySalesFilter(String filter) {
|
||||
String f = filter == null ? "" : filter.trim().toLowerCase();
|
||||
if (f.isEmpty()) {
|
||||
filteredSales.setPredicate(s -> true);
|
||||
return;
|
||||
}
|
||||
String selectedPayment = cbFilterPayment.getValue();
|
||||
String selectedStatus = cbFilterRefundStatus.getValue();
|
||||
DropdownOption selectedCustomer = cbFilterCustomer.getValue();
|
||||
|
||||
filteredSales.setPredicate(s ->
|
||||
String.valueOf(s.getSaleId()).contains(f)
|
||||
filteredSales.setPredicate(s -> {
|
||||
boolean textMatch = f.isEmpty()
|
||||
|| String.valueOf(s.getSaleId()).contains(f)
|
||||
|| safe(s.getSaleDate()).contains(f)
|
||||
|| safe(s.getEmployeeName()).contains(f)
|
||||
|| safe(s.getCustomerName()).contains(f)
|
||||
|| safe(s.getItemName()).contains(f)
|
||||
|| safe(s.getPaymentMethod()).contains(f)
|
||||
);
|
||||
|| safe(s.getPaymentMethod()).contains(f);
|
||||
|
||||
boolean paymentMatch = selectedPayment == null || selectedPayment.equals("All Payments")
|
||||
|| safe(s.getPaymentMethod()).equals(selectedPayment.toLowerCase());
|
||||
|
||||
boolean refundMatch = selectedStatus == null || selectedStatus.equals("All Status")
|
||||
|| (selectedStatus.equals("Sales Only") && !s.isRefund())
|
||||
|| (selectedStatus.equals("Refunds Only") && s.isRefund());
|
||||
|
||||
boolean customerMatch = selectedCustomer == null || selectedCustomer.getId() == null
|
||||
|| safe(s.getCustomerName()).equals(safe(selectedCustomer.getLabel()));
|
||||
|
||||
return textMatch && paymentMatch && refundMatch && customerMatch;
|
||||
});
|
||||
}
|
||||
|
||||
private static String safe(String v) {
|
||||
return v == null ? "" : v.toLowerCase();
|
||||
}
|
||||
|
||||
private void loadCustomerFilter() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
|
||||
DropdownOption allCustomers = new DropdownOption();
|
||||
allCustomers.setLabel("All Customers");
|
||||
Platform.runLater(() -> {
|
||||
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
|
||||
items.add(allCustomers);
|
||||
items.addAll(customers);
|
||||
cbFilterCustomer.setItems(FXCollections.observableArrayList(items));
|
||||
cbFilterCustomer.getSelectionModel().selectFirst();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("SaleController.loadCustomerFilter", e, "Loading customer filter");
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadStoreFilter() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
DropdownOption allStores = new DropdownOption();
|
||||
allStores.setLabel("All Stores");
|
||||
Platform.runLater(() -> {
|
||||
storeOptions.clear();
|
||||
storeOptions.addAll(stores);
|
||||
java.util.List<DropdownOption> items = new java.util.ArrayList<>();
|
||||
items.add(allStores);
|
||||
items.addAll(stores);
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("SaleController.loadStoreFilter", e, "Loading store filter");
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private Long selectedStoreId() {
|
||||
if (!UserSession.getInstance().isAdmin()) return UserSession.getInstance().getStoreId();
|
||||
DropdownOption selected = cbStoreFilter.getValue();
|
||||
return (selected != null && selected.getId() != null) ? selected.getId() : null;
|
||||
}
|
||||
|
||||
private void showError(String title, String message) {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle(title);
|
||||
|
||||
@@ -7,15 +7,19 @@ import javafx.collections.transformation.FilteredList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.user.UserResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.UserApi;
|
||||
import org.example.petshopdesktop.api.dto.employee.EmployeeResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.EmployeeApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.util.Comparator;
|
||||
@@ -24,62 +28,48 @@ import java.util.stream.Collectors;
|
||||
|
||||
public class StaffAccountsController {
|
||||
|
||||
@FXML
|
||||
private VBox staffSection;
|
||||
@FXML private VBox staffSection;
|
||||
@FXML private TableView<EmployeeResponse> tvStaff;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colStaffAvatar;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colUsername;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colName;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colEmail;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colPhone;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colRole;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colStatus;
|
||||
@FXML private TableColumn<EmployeeResponse, Object> colCreated;
|
||||
@FXML private TextField txtSearch;
|
||||
@FXML private ComboBox<String> cbRoleFilter;
|
||||
@FXML private ComboBox<String> cbStatusFilter;
|
||||
@FXML private Button btnRefresh;
|
||||
@FXML private Button btnCreateAccount;
|
||||
@FXML private Button btnEditAccount;
|
||||
@FXML private Label lblError;
|
||||
@FXML private Label lblStatus;
|
||||
|
||||
@FXML
|
||||
private TableView<UserResponse> tvStaff;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colUsername;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colName;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colEmail;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colPhone;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colRole;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colStatus;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, Object> colCreated;
|
||||
|
||||
@FXML
|
||||
private TextField txtSearch;
|
||||
|
||||
@FXML
|
||||
private Button btnRefresh;
|
||||
|
||||
@FXML
|
||||
private Button btnCreateAccount;
|
||||
|
||||
@FXML
|
||||
private Button btnEditAccount;
|
||||
|
||||
@FXML
|
||||
private Label lblError;
|
||||
|
||||
@FXML
|
||||
private Label lblStatus;
|
||||
|
||||
private final ObservableList<UserResponse> staffAccounts = FXCollections.observableArrayList();
|
||||
private FilteredList<UserResponse> filteredStaff;
|
||||
private final ObservableList<EmployeeResponse> staffAccounts = FXCollections.observableArrayList();
|
||||
private FilteredList<EmployeeResponse> filteredStaff;
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
colStaffAvatar.setCellValueFactory(data -> {
|
||||
Long id = data.getValue().getId();
|
||||
if (id == null) return new javafx.beans.property.SimpleStringProperty("");
|
||||
return new javafx.beans.property.SimpleStringProperty("/api/v1/users/" + id + "/avatar/file");
|
||||
});
|
||||
configureAvatarColumn(colStaffAvatar);
|
||||
|
||||
colUsername.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getUsername()));
|
||||
colName.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getFullName()));
|
||||
colEmail.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getEmail()));
|
||||
colPhone.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getPhone()));
|
||||
colRole.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getRole()));
|
||||
colStatus.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getActive() != null && data.getValue().getActive() ? "Active" : "Inactive"));
|
||||
colRole.setCellValueFactory(data -> {
|
||||
String role = data.getValue().getRole() != null ? data.getValue().getRole() : "";
|
||||
String staffRole = data.getValue().getStaffRole() != null ? " (" + data.getValue().getStaffRole() + ")" : "";
|
||||
return new javafx.beans.property.SimpleStringProperty(role + staffRole);
|
||||
});
|
||||
colStatus.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(
|
||||
Boolean.TRUE.equals(data.getValue().getActive()) ? "Active" : "Inactive"));
|
||||
colCreated.setCellValueFactory(data -> new javafx.beans.property.SimpleObjectProperty<>(data.getValue().getCreatedAt()));
|
||||
|
||||
filteredStaff = new FilteredList<>(staffAccounts, a -> true);
|
||||
@@ -90,14 +80,45 @@ public class StaffAccountsController {
|
||||
btnEditAccount.setDisable(newVal == null));
|
||||
btnEditAccount.setDisable(true);
|
||||
|
||||
cbRoleFilter.setItems(FXCollections.observableArrayList("All Roles", "Admin", "Staff"));
|
||||
cbRoleFilter.getSelectionModel().selectFirst();
|
||||
cbRoleFilter.valueProperty().addListener((obs, o, n) -> applyStaffFilter(txtSearch.getText()));
|
||||
|
||||
cbStatusFilter.setItems(FXCollections.observableArrayList("All Statuses", "Active", "Inactive"));
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
cbStatusFilter.valueProperty().addListener((obs, o, n) -> applyStaffFilter(txtSearch.getText()));
|
||||
|
||||
txtSearch.textProperty().addListener((obs, o, n) -> applyStaffFilter(n));
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void configureAvatarColumn(TableColumn<EmployeeResponse, String> column) {
|
||||
column.setCellFactory(col -> new TableCell<>() {
|
||||
private final ImageView imageView = new ImageView();
|
||||
private final StackPane container = new StackPane(imageView);
|
||||
{
|
||||
container.setAlignment(Pos.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null || item.isBlank()) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
DesktopImageSupport.loadImageInto(imageView, item, 48, 48);
|
||||
setGraphic(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnRefreshClicked(ActionEvent event) {
|
||||
txtSearch.clear();
|
||||
cbRoleFilter.getSelectionModel().selectFirst();
|
||||
cbStatusFilter.getSelectionModel().selectFirst();
|
||||
TableViewSupport.clearSort(tvStaff);
|
||||
refresh();
|
||||
TableViewSupport.flashStatus(lblStatus, "Refreshed");
|
||||
@@ -128,7 +149,7 @@ public class StaffAccountsController {
|
||||
openEditDialog(tvStaff.getSelectionModel().getSelectedItem());
|
||||
}
|
||||
|
||||
private void openEditDialog(UserResponse selected) {
|
||||
private void openEditDialog(EmployeeResponse selected) {
|
||||
if (selected == null) {
|
||||
lblError.setText("Select a user account to edit.");
|
||||
return;
|
||||
@@ -152,12 +173,12 @@ public class StaffAccountsController {
|
||||
dialog.setScene(new Scene(loader.load()));
|
||||
dialog.setResizable(false);
|
||||
var controller = (org.example.petshopdesktop.controllers.dialogcontrollers.StaffEditDialogController) loader.getController();
|
||||
controller.setUser(selected);
|
||||
controller.setEmployee(selected);
|
||||
dialog.showAndWait();
|
||||
refresh();
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("StaffAccountsController.openEditDialog", e, "Opening user edit dialog");
|
||||
lblError.setText("Could not open user account editor.");
|
||||
ActivityLogger.getInstance().logException("StaffAccountsController.openEditDialog", e, "Opening employee edit dialog");
|
||||
lblError.setText("Could not open employee account editor.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,11 +188,10 @@ public class StaffAccountsController {
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Comparator<UserResponse> byCreated = Comparator.comparing(
|
||||
UserResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
|
||||
Comparator<EmployeeResponse> byCreated = Comparator.comparing(
|
||||
EmployeeResponse::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
|
||||
|
||||
List<UserResponse> staff = UserApi.getInstance().listUsers(null).stream()
|
||||
.filter(u -> !"CUSTOMER".equalsIgnoreCase(u.getRole()))
|
||||
List<EmployeeResponse> staff = EmployeeApi.getInstance().listEmployees(null).stream()
|
||||
.sorted(byCreated)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -180,7 +200,7 @@ public class StaffAccountsController {
|
||||
tvStaff.setDisable(false);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("StaffAccountsController.refresh", e, "Loading staff accounts");
|
||||
ActivityLogger.getInstance().logException("StaffAccountsController.refresh", e, "Loading employee accounts");
|
||||
Platform.runLater(() -> {
|
||||
lblError.setText("Could not load staff accounts.");
|
||||
tvStaff.setDisable(false);
|
||||
@@ -191,17 +211,24 @@ public class StaffAccountsController {
|
||||
|
||||
private void applyStaffFilter(String text) {
|
||||
String q = text == null ? "" : text.trim().toLowerCase();
|
||||
if (q.isEmpty()) {
|
||||
filteredStaff.setPredicate(a -> true);
|
||||
return;
|
||||
}
|
||||
filteredStaff.setPredicate(a ->
|
||||
safe(a.getUsername()).contains(q)
|
||||
|| safe(a.getFullName()).contains(q)
|
||||
|| safe(a.getEmail()).contains(q)
|
||||
|| safe(a.getPhone()).contains(q)
|
||||
|| safe(a.getRole()).contains(q)
|
||||
);
|
||||
String selectedRole = cbRoleFilter.getValue();
|
||||
String selectedStatus = cbStatusFilter.getValue();
|
||||
filteredStaff.setPredicate(a -> {
|
||||
boolean textMatch = q.isEmpty()
|
||||
|| safe(a.getUsername()).contains(q)
|
||||
|| safe(a.getFullName()).contains(q)
|
||||
|| safe(a.getEmail()).contains(q)
|
||||
|| safe(a.getPhone()).contains(q)
|
||||
|| safe(a.getRole()).contains(q)
|
||||
|| safe(a.getStaffRole()).contains(q);
|
||||
boolean active = Boolean.TRUE.equals(a.getActive());
|
||||
boolean statusMatch = selectedStatus == null || selectedStatus.equals("All Statuses")
|
||||
|| (selectedStatus.equals("Active") && active)
|
||||
|| (selectedStatus.equals("Inactive") && !active);
|
||||
boolean roleMatch = selectedRole == null || selectedRole.equals("All Roles")
|
||||
|| safe(a.getRole()).equals(selectedRole.toLowerCase());
|
||||
return textMatch && statusMatch && roleMatch;
|
||||
});
|
||||
}
|
||||
|
||||
private static String safe(String v) {
|
||||
|
||||
@@ -8,164 +8,98 @@ import javafx.fxml.FXML;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ChoiceDialog;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.DatePicker;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.adoption.AdoptionRequest;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.endpoints.AdoptionApi;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.api.endpoints.PetApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.models.Adoption;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class AdoptionDialogController {
|
||||
|
||||
@FXML
|
||||
private Button btnCancel;
|
||||
|
||||
@FXML
|
||||
private Button btnSave;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbAdoptionStatus;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbCustomer;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbEmployee;
|
||||
|
||||
@FXML
|
||||
private ComboBox<DropdownOption> cbPet;
|
||||
|
||||
@FXML
|
||||
private DatePicker dpAdoptionDate;
|
||||
|
||||
@FXML
|
||||
private Label lblAdoptionId;
|
||||
|
||||
@FXML
|
||||
private Label lblMode;
|
||||
@FXML private Button btnCancel;
|
||||
@FXML private Button btnSave;
|
||||
@FXML private ComboBox<String> cbAdoptionStatus;
|
||||
@FXML private ComboBox<DropdownOption> cbCustomer;
|
||||
@FXML private ComboBox<DropdownOption> cbEmployee;
|
||||
@FXML private ComboBox<DropdownOption> cbPet;
|
||||
@FXML private ComboBox<DropdownOption> cbStore;
|
||||
@FXML private VBox vbStore;
|
||||
@FXML private DatePicker dpAdoptionDate;
|
||||
@FXML private TextField txtAdoptionFee;
|
||||
@FXML private Label lblAdoptionId;
|
||||
@FXML private Label lblMode;
|
||||
|
||||
private String mode = null;
|
||||
private Adoption selectedAdoption = null;
|
||||
private String selectedPaymentMethod = null;
|
||||
private boolean suppressPaymentDialog = false;
|
||||
private boolean suppressStatusListener = false;
|
||||
private Long pendingStoreId = null;
|
||||
private boolean isEditing = false;
|
||||
|
||||
private ObservableList<String> statusList = FXCollections.observableArrayList(
|
||||
private final ObservableList<String> statusList = FXCollections.observableArrayList(
|
||||
"Pending", "Completed", "Missed", "Cancelled"
|
||||
);
|
||||
|
||||
@FXML
|
||||
void initialize() {
|
||||
|
||||
cbAdoptionStatus.setItems(statusList);
|
||||
cbAdoptionStatus.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if ("Completed".equals(newVal) && !"Completed".equals(oldVal) && !suppressPaymentDialog) {
|
||||
ChoiceDialog<String> dialog = new ChoiceDialog<>("Cash", "Cash", "Credit Card", "Debit Card", "E-Transfer");
|
||||
dialog.setTitle("Payment Method");
|
||||
dialog.setHeaderText("Confirm payment received");
|
||||
dialog.setContentText("Select payment method:");
|
||||
dialog.showAndWait().ifPresentOrElse(
|
||||
method -> selectedPaymentMethod = method,
|
||||
() -> {
|
||||
selectedPaymentMethod = null;
|
||||
cbAdoptionStatus.setValue(oldVal);
|
||||
}
|
||||
);
|
||||
} else if (!"Completed".equals(newVal)) {
|
||||
selectedPaymentMethod = null;
|
||||
if (!suppressStatusListener && newVal != null) {
|
||||
applyStatusFieldRules(newVal);
|
||||
}
|
||||
});
|
||||
cbEmployee.setPromptText("Select an employee");
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> pets = DropdownApi.getInstance().getAdoptionPets();
|
||||
Platform.runLater(() -> {
|
||||
if (pets != null) {
|
||||
ObservableList<DropdownOption> petsObs = FXCollections.observableArrayList(pets);
|
||||
ensureSelectedPetOption(petsObs);
|
||||
cbPet.setItems(petsObs);
|
||||
applySelectedPet();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.initialize",
|
||||
e,
|
||||
"Loading pets for combo box");
|
||||
System.out.println("Error loading pets: " + e.getMessage());
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
txtAdoptionFee.setDisable(true);
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> employees = DropdownApi.getInstance().getEmployees();
|
||||
Platform.runLater(() -> {
|
||||
ObservableList<DropdownOption> employeesObs = FXCollections.observableArrayList(employees);
|
||||
ensureSelectedEmployeeOption(employeesObs);
|
||||
cbEmployee.setItems(employeesObs);
|
||||
applySelectedEmployee();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.initialize",
|
||||
e,
|
||||
"Loading employees for combo box");
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Unable to load employees");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
|
||||
cbEmployee.setCellFactory(param -> new ListCell<>() {
|
||||
LocalDate today = LocalDate.now();
|
||||
dpAdoptionDate.setDayCellFactory(picker -> new javafx.scene.control.DateCell() {
|
||||
@Override
|
||||
protected void updateItem(DropdownOption option, boolean empty) {
|
||||
super.updateItem(option, empty);
|
||||
setText(empty || option == null ? null : option.getLabel());
|
||||
}
|
||||
});
|
||||
cbEmployee.setButtonCell(new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(DropdownOption option, boolean empty) {
|
||||
super.updateItem(option, empty);
|
||||
setText(empty || option == null ? null : option.getLabel());
|
||||
public void updateItem(LocalDate item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
setDisable(empty || item.isBefore(today));
|
||||
}
|
||||
});
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
|
||||
Platform.runLater(() -> {
|
||||
if (customers != null) {
|
||||
ObservableList<DropdownOption> customersObs = FXCollections.observableArrayList(customers);
|
||||
cbCustomer.setItems(customersObs);
|
||||
applySelectedCustomer();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.initialize",
|
||||
e,
|
||||
"Loading customers for combo box");
|
||||
System.out.println("Error loading customers: " + e.getMessage());
|
||||
});
|
||||
setupDropdownCellFactory(cbEmployee);
|
||||
setupDropdownCellFactory(cbPet);
|
||||
setupDropdownCellFactory(cbCustomer);
|
||||
setupDropdownCellFactory(cbStore);
|
||||
|
||||
cbPet.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (newVal != null) {
|
||||
loadPetPrice(newVal.getId());
|
||||
} else {
|
||||
txtAdoptionFee.setText("0");
|
||||
}
|
||||
}).start();
|
||||
});
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
vbStore.setVisible(true);
|
||||
vbStore.setManaged(true);
|
||||
cbPet.setDisable(true);
|
||||
cbPet.setPromptText("Select a store first");
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Select a store first");
|
||||
} else {
|
||||
cbEmployee.setPromptText("Select an employee");
|
||||
}
|
||||
|
||||
loadDropdownsAsync();
|
||||
|
||||
btnSave.setOnMouseClicked(new EventHandler<MouseEvent>() {
|
||||
@Override
|
||||
@@ -182,32 +116,236 @@ public class AdoptionDialogController {
|
||||
});
|
||||
}
|
||||
|
||||
private void setupDropdownCellFactory(ComboBox<DropdownOption> cb) {
|
||||
cb.setCellFactory(param -> new ListCell<>() {
|
||||
@Override protected void updateItem(DropdownOption o, boolean empty) {
|
||||
super.updateItem(o, empty);
|
||||
setText(empty || o == null ? null : o.getLabel());
|
||||
}
|
||||
});
|
||||
cb.setButtonCell(new ListCell<>() {
|
||||
@Override protected void updateItem(DropdownOption o, boolean empty) {
|
||||
super.updateItem(o, empty);
|
||||
setText(empty || o == null ? null : o.getLabel());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadDropdownsAsync() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
|
||||
Platform.runLater(() -> {
|
||||
if (customers != null) {
|
||||
cbCustomer.setItems(FXCollections.observableArrayList(customers));
|
||||
applySelectedCustomer();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.loadDropdownsAsync", e, "Loading customers"));
|
||||
}
|
||||
}).start();
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
Platform.runLater(() -> {
|
||||
if (stores != null) {
|
||||
cbStore.setItems(FXCollections.observableArrayList(stores));
|
||||
cbStore.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||
Long sid = newVal != null ? newVal.getId() : null;
|
||||
cbEmployee.setValue(null);
|
||||
cbEmployee.setItems(FXCollections.observableArrayList());
|
||||
if (sid != null) {
|
||||
String status = cbAdoptionStatus.getValue();
|
||||
boolean locked = "Cancelled".equalsIgnoreCase(status)
|
||||
|| "Completed".equalsIgnoreCase(status)
|
||||
|| "Missed".equalsIgnoreCase(status);
|
||||
if (!locked) {
|
||||
cbEmployee.setDisable(false);
|
||||
cbEmployee.setPromptText("Select an employee");
|
||||
}
|
||||
loadEmployeesForStore(sid);
|
||||
if (!isEditing) {
|
||||
cbPet.setValue(null);
|
||||
cbPet.setItems(FXCollections.observableArrayList());
|
||||
cbPet.setDisable(true);
|
||||
cbPet.setPromptText("Select a pet");
|
||||
loadAdoptionPetsForStore(sid);
|
||||
}
|
||||
} else {
|
||||
String status = cbAdoptionStatus.getValue();
|
||||
boolean locked = "Cancelled".equalsIgnoreCase(status)
|
||||
|| "Completed".equalsIgnoreCase(status)
|
||||
|| "Missed".equalsIgnoreCase(status);
|
||||
if (!locked) {
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Select a store first");
|
||||
}
|
||||
if (!isEditing) {
|
||||
cbPet.setValue(null);
|
||||
cbPet.setItems(FXCollections.observableArrayList());
|
||||
cbPet.setDisable(true);
|
||||
cbPet.setPromptText("Select a store first");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (pendingStoreId != null) {
|
||||
for (DropdownOption store : cbStore.getItems()) {
|
||||
if (pendingStoreId.equals(store.getId())) {
|
||||
cbStore.setValue(store);
|
||||
break;
|
||||
}
|
||||
}
|
||||
pendingStoreId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.loadDropdownsAsync", e, "Loading stores"));
|
||||
}
|
||||
}).start();
|
||||
} else {
|
||||
Long storeId = UserSession.getInstance().getStoreId();
|
||||
if (storeId != null && storeId > 0) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> employees = DropdownApi.getInstance().getStoreEmployees(storeId);
|
||||
Platform.runLater(() -> {
|
||||
ObservableList<DropdownOption> employeesObs = FXCollections.observableArrayList(employees != null ? employees : List.of());
|
||||
ensureSelectedEmployeeOption(employeesObs);
|
||||
cbEmployee.setItems(employeesObs);
|
||||
applySelectedEmployee();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.loadDropdownsAsync", e, "Loading employees");
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Unable to load employees");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> pets = DropdownApi.getInstance().getAdoptionPets(storeId);
|
||||
Platform.runLater(() -> {
|
||||
if (!isEditing && pets != null) {
|
||||
ObservableList<DropdownOption> petsObs = FXCollections.observableArrayList(pets);
|
||||
cbPet.setItems(petsObs);
|
||||
if (petsObs.isEmpty()) {
|
||||
cbPet.setDisable(true);
|
||||
cbPet.setPromptText("No available pets for this store");
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.loadDropdownsAsync", e, "Loading pets"));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void loadEmployeesForStore(Long storeId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> employees = DropdownApi.getInstance().getStoreEmployees(storeId);
|
||||
Platform.runLater(() -> {
|
||||
ObservableList<DropdownOption> employeesObs = FXCollections.observableArrayList(employees != null ? employees : List.of());
|
||||
ensureSelectedEmployeeOption(employeesObs);
|
||||
cbEmployee.setItems(employeesObs);
|
||||
applySelectedEmployee();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.loadEmployeesForStore", e, "Loading employees for store");
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Unable to load employees");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadAdoptionPetsForStore(Long storeId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> pets = DropdownApi.getInstance().getAdoptionPets(storeId);
|
||||
Platform.runLater(() -> {
|
||||
if (pets != null) {
|
||||
ObservableList<DropdownOption> petsObs = FXCollections.observableArrayList(pets);
|
||||
cbPet.setItems(petsObs);
|
||||
if (petsObs.isEmpty()) {
|
||||
cbPet.setDisable(true);
|
||||
cbPet.setPromptText("No available pets for this store");
|
||||
} else {
|
||||
cbPet.setDisable(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.loadAdoptionPetsForStore", e, "Loading adoption pets for store");
|
||||
cbPet.setDisable(true);
|
||||
cbPet.setPromptText("Unable to load pets");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void applyStatusFieldRules(String status) {
|
||||
if ("Cancelled".equalsIgnoreCase(status) || "Completed".equalsIgnoreCase(status) || "Missed".equalsIgnoreCase(status)) {
|
||||
cbEmployee.setDisable(true);
|
||||
dpAdoptionDate.setDisable(true);
|
||||
cbStore.setDisable(true);
|
||||
} else {
|
||||
cbEmployee.setDisable(false);
|
||||
dpAdoptionDate.setDisable(false);
|
||||
if (UserSession.getInstance().isAdmin() && !isEditing) cbStore.setDisable(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void buttonSaveClicked(MouseEvent mouseEvent) {
|
||||
String errorMsg = "";
|
||||
|
||||
if (cbPet.getSelectionModel().getSelectedItem() == null) {
|
||||
errorMsg += "Pet is required.\n";
|
||||
}
|
||||
if (cbPet.getSelectionModel().getSelectedItem() == null) errorMsg += "Pet is required.\n";
|
||||
if (cbCustomer.getSelectionModel().getSelectedItem() == null) errorMsg += "Customer is required.\n";
|
||||
if (cbEmployee.getSelectionModel().getSelectedItem() == null) errorMsg += "Employee is required.\n";
|
||||
if (dpAdoptionDate.getValue() == null) errorMsg += "Adoption Date is required.\n";
|
||||
if (cbAdoptionStatus.getSelectionModel().getSelectedItem() == null) errorMsg += "Status is required.\n";
|
||||
|
||||
if (cbCustomer.getSelectionModel().getSelectedItem() == null) {
|
||||
errorMsg += "Customer is required.\n";
|
||||
}
|
||||
|
||||
if (cbEmployee.getSelectionModel().getSelectedItem() == null) {
|
||||
errorMsg += "Employee is required.\n";
|
||||
}
|
||||
|
||||
if (dpAdoptionDate.getValue() == null) {
|
||||
errorMsg += "Adoption Date is required.\n";
|
||||
}
|
||||
|
||||
if (cbAdoptionStatus.getSelectionModel().getSelectedItem() == null) {
|
||||
errorMsg += "Status is required.\n";
|
||||
BigDecimal adoptionFee = BigDecimal.ZERO;
|
||||
String feeText = txtAdoptionFee.getText() == null ? "" : txtAdoptionFee.getText().trim();
|
||||
if (!feeText.isEmpty()) {
|
||||
try {
|
||||
adoptionFee = new BigDecimal(feeText);
|
||||
} catch (NumberFormatException e) {
|
||||
adoptionFee = BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg.isEmpty()) {
|
||||
try {
|
||||
Long storeId = UserSession.getInstance().getStoreId();
|
||||
Long storeId;
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
if (cbStore.getSelectionModel().getSelectedItem() == null) {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setHeaderText("Input Error");
|
||||
alert.setContentText("Store is required.");
|
||||
alert.showAndWait();
|
||||
return;
|
||||
}
|
||||
storeId = cbStore.getSelectionModel().getSelectedItem().getId();
|
||||
} else {
|
||||
storeId = UserSession.getInstance().getStoreId();
|
||||
}
|
||||
if (storeId == null || storeId <= 0) {
|
||||
throw new IllegalStateException("Store is not set for this account");
|
||||
}
|
||||
@@ -219,15 +357,13 @@ public class AdoptionDialogController {
|
||||
request.setSourceStoreId(storeId);
|
||||
request.setAdoptionDate(dpAdoptionDate.getValue());
|
||||
request.setAdoptionStatus(cbAdoptionStatus.getValue());
|
||||
request.setPaymentMethod(selectedPaymentMethod);
|
||||
request.setAdoptionFee(adoptionFee);
|
||||
|
||||
if (mode.equals("Add")) {
|
||||
AdoptionApi.getInstance().createAdoption(request);
|
||||
} else {
|
||||
String[] parts = lblAdoptionId.getText().split(": ");
|
||||
if (parts.length < 2) {
|
||||
throw new IllegalStateException("Invalid adoption ID format");
|
||||
}
|
||||
if (parts.length < 2) throw new IllegalStateException("Invalid adoption ID format");
|
||||
Long adoptionId = Long.parseLong(parts[1]);
|
||||
AdoptionApi.getInstance().updateAdoption(adoptionId, request);
|
||||
}
|
||||
@@ -239,9 +375,7 @@ public class AdoptionDialogController {
|
||||
closeStage(mouseEvent);
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.buttonSaveClicked",
|
||||
e,
|
||||
mode + " adoption");
|
||||
"AdoptionDialogController.buttonSaveClicked", e, mode + " adoption");
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setHeaderText("Database Operation Error");
|
||||
alert.setContentText(e.getMessage());
|
||||
@@ -262,36 +396,46 @@ public class AdoptionDialogController {
|
||||
}
|
||||
|
||||
public void displayAdoptionDetails(Adoption adoption) {
|
||||
if (adoption != null) {
|
||||
selectedAdoption = adoption;
|
||||
lblAdoptionId.setText("ID: " + adoption.getAdoptionId());
|
||||
ensureSelectedEmployeeOption(cbEmployee.getItems());
|
||||
applySelectedPet();
|
||||
applySelectedCustomer();
|
||||
applySelectedEmployee();
|
||||
if (adoption == null) return;
|
||||
selectedAdoption = adoption;
|
||||
isEditing = true;
|
||||
lblAdoptionId.setText("ID: " + adoption.getAdoptionId());
|
||||
pendingStoreId = adoption.getStoreId();
|
||||
|
||||
if (adoption.getAdoptionDate() != null && !adoption.getAdoptionDate().isEmpty()) {
|
||||
try {
|
||||
dpAdoptionDate.setValue(LocalDate.parse(adoption.getAdoptionDate()));
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.displayAdoptionDetails",
|
||||
e,
|
||||
"Parsing adoption date");
|
||||
}
|
||||
}
|
||||
|
||||
suppressPaymentDialog = true;
|
||||
cbAdoptionStatus.setItems(statusList);
|
||||
for (String status : cbAdoptionStatus.getItems()) {
|
||||
if (status.equals(adoption.getAdoptionStatus())) {
|
||||
cbAdoptionStatus.getSelectionModel().select(status);
|
||||
break;
|
||||
}
|
||||
}
|
||||
suppressPaymentDialog = false;
|
||||
applyEditModeLock();
|
||||
if (adoption.getPetId() > 0) {
|
||||
DropdownOption petOption = new DropdownOption();
|
||||
petOption.setId((long) adoption.getPetId());
|
||||
petOption.setLabel(adoption.getPetName() != null && !adoption.getPetName().isBlank()
|
||||
? adoption.getPetName() : "Pet #" + adoption.getPetId());
|
||||
cbPet.setItems(FXCollections.observableArrayList(petOption));
|
||||
cbPet.setValue(petOption);
|
||||
}
|
||||
|
||||
applySelectedCustomer();
|
||||
applySelectedEmployee();
|
||||
|
||||
if (adoption.getAdoptionDate() != null && !adoption.getAdoptionDate().isEmpty()) {
|
||||
try {
|
||||
dpAdoptionDate.setValue(LocalDate.parse(adoption.getAdoptionDate()));
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.displayAdoptionDetails", e, "Parsing adoption date");
|
||||
}
|
||||
}
|
||||
|
||||
txtAdoptionFee.setText(adoption.getAdoptionFee() > 0
|
||||
? String.format("%.2f", adoption.getAdoptionFee()) : "0.00");
|
||||
|
||||
suppressStatusListener = true;
|
||||
cbAdoptionStatus.setItems(statusList);
|
||||
for (String status : cbAdoptionStatus.getItems()) {
|
||||
if (status.equals(adoption.getAdoptionStatus())) {
|
||||
cbAdoptionStatus.getSelectionModel().select(status);
|
||||
break;
|
||||
}
|
||||
}
|
||||
suppressStatusListener = false;
|
||||
applyEditModeLock();
|
||||
}
|
||||
|
||||
private void applyEditModeLock() {
|
||||
@@ -304,6 +448,7 @@ public class AdoptionDialogController {
|
||||
dpAdoptionDate.setDisable(true);
|
||||
cbAdoptionStatus.setDisable(true);
|
||||
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Cancelled"));
|
||||
cbStore.setDisable(true);
|
||||
btnSave.setDisable(true);
|
||||
return;
|
||||
}
|
||||
@@ -311,23 +456,33 @@ public class AdoptionDialogController {
|
||||
LocalDate adoptionDate = dpAdoptionDate.getValue();
|
||||
boolean isPast = adoptionDate != null && adoptionDate.isBefore(LocalDate.now());
|
||||
|
||||
cbPet.setDisable(true);
|
||||
cbCustomer.setDisable(true);
|
||||
dpAdoptionDate.setDisable(false);
|
||||
cbEmployee.setDisable(false);
|
||||
cbAdoptionStatus.setDisable(false);
|
||||
|
||||
suppressPaymentDialog = true;
|
||||
if (isPast) {
|
||||
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Completed", "Missed"));
|
||||
cbPet.setDisable(true);
|
||||
cbCustomer.setDisable(true);
|
||||
cbEmployee.setDisable(true);
|
||||
dpAdoptionDate.setDisable(true);
|
||||
cbStore.setDisable(true);
|
||||
cbAdoptionStatus.setDisable(false);
|
||||
suppressStatusListener = true;
|
||||
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Completed", "Missed"));
|
||||
if (!cbAdoptionStatus.getItems().contains(cbAdoptionStatus.getValue())) {
|
||||
cbAdoptionStatus.getSelectionModel().selectFirst();
|
||||
}
|
||||
suppressStatusListener = false;
|
||||
} else {
|
||||
cbPet.setDisable(true);
|
||||
cbCustomer.setDisable(true);
|
||||
dpAdoptionDate.setDisable(false);
|
||||
cbEmployee.setDisable(false);
|
||||
cbAdoptionStatus.setDisable(false);
|
||||
cbStore.setDisable(true);
|
||||
suppressStatusListener = true;
|
||||
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Pending", "Cancelled"));
|
||||
if (!cbAdoptionStatus.getItems().contains(cbAdoptionStatus.getValue())) {
|
||||
cbAdoptionStatus.getSelectionModel().selectFirst();
|
||||
}
|
||||
suppressStatusListener = false;
|
||||
}
|
||||
if (!cbAdoptionStatus.getItems().contains(cbAdoptionStatus.getValue())) {
|
||||
cbAdoptionStatus.getSelectionModel().selectFirst();
|
||||
}
|
||||
suppressPaymentDialog = false;
|
||||
}
|
||||
|
||||
public void setMode(String mode) {
|
||||
@@ -335,60 +490,64 @@ public class AdoptionDialogController {
|
||||
lblMode.setText(mode + " Adoption");
|
||||
lblAdoptionId.setVisible(mode.equals("Edit"));
|
||||
if (mode.equals("Add")) {
|
||||
suppressPaymentDialog = true;
|
||||
suppressStatusListener = true;
|
||||
cbAdoptionStatus.setItems(FXCollections.observableArrayList("Pending"));
|
||||
cbAdoptionStatus.setValue("Pending");
|
||||
cbAdoptionStatus.setDisable(true);
|
||||
suppressPaymentDialog = false;
|
||||
suppressStatusListener = false;
|
||||
txtAdoptionFee.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
private void applySelectedPet() {
|
||||
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0) return;
|
||||
DropdownOption selected = findOptionById(cbPet.getItems(), (long) selectedAdoption.getPetId());
|
||||
if (selected != null && !Objects.equals(cbPet.getValue(), selected)) {
|
||||
cbPet.setValue(selected);
|
||||
}
|
||||
if (selected != null && !Objects.equals(cbPet.getValue(), selected)) cbPet.setValue(selected);
|
||||
}
|
||||
|
||||
private void applySelectedCustomer() {
|
||||
if (selectedAdoption == null || selectedAdoption.getCustomerId() <= 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedAdoption == null || selectedAdoption.getCustomerId() <= 0) return;
|
||||
DropdownOption selected = findOptionById(cbCustomer.getItems(), (long) selectedAdoption.getCustomerId());
|
||||
if (selected != null && !Objects.equals(cbCustomer.getValue(), selected)) {
|
||||
cbCustomer.setValue(selected);
|
||||
}
|
||||
if (selected != null && !Objects.equals(cbCustomer.getValue(), selected)) cbCustomer.setValue(selected);
|
||||
}
|
||||
|
||||
private void applySelectedEmployee() {
|
||||
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0) return;
|
||||
DropdownOption selected = findOptionById(cbEmployee.getItems(), (long) selectedAdoption.getEmployeeId());
|
||||
if (selected != null && !Objects.equals(cbEmployee.getValue(), selected)) {
|
||||
cbEmployee.setValue(selected);
|
||||
}
|
||||
if (selected != null && !Objects.equals(cbEmployee.getValue(), selected)) cbEmployee.setValue(selected);
|
||||
}
|
||||
|
||||
private DropdownOption findOptionById(List<DropdownOption> options, Long id) {
|
||||
if (id == null || options == null) {
|
||||
return null;
|
||||
}
|
||||
if (id == null || options == null) return null;
|
||||
for (DropdownOption option : options) {
|
||||
if (option.getId() != null && option.getId().equals(id)) {
|
||||
return option;
|
||||
}
|
||||
if (option.getId() != null && option.getId().equals(id)) return option;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void loadPetPrice(Long petId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
var pet = PetApi.getInstance().getPetById(petId);
|
||||
Platform.runLater(() -> {
|
||||
if (pet != null && pet.getPetPrice() != null) {
|
||||
txtAdoptionFee.setText(String.format("%.2f", pet.getPetPrice()));
|
||||
} else {
|
||||
txtAdoptionFee.setText("0.00");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
txtAdoptionFee.setText("0.00");
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AdoptionDialogController.loadPetPrice", e, "Loading pet price");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void ensureSelectedEmployeeOption(ObservableList<DropdownOption> options) {
|
||||
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0 || options == null) {
|
||||
return;
|
||||
}
|
||||
if (selectedAdoption == null || selectedAdoption.getEmployeeId() <= 0 || options == null) return;
|
||||
DropdownOption existing = findOptionById(options, (long) selectedAdoption.getEmployeeId());
|
||||
if (existing == null) {
|
||||
DropdownOption option = new DropdownOption();
|
||||
@@ -399,9 +558,7 @@ public class AdoptionDialogController {
|
||||
}
|
||||
|
||||
private void ensureSelectedPetOption(ObservableList<DropdownOption> options) {
|
||||
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0 || options == null) {
|
||||
return;
|
||||
}
|
||||
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0 || options == null) return;
|
||||
DropdownOption existing = findOptionById(options, (long) selectedAdoption.getPetId());
|
||||
if (existing == null) {
|
||||
DropdownOption option = new DropdownOption();
|
||||
|
||||
@@ -7,6 +7,7 @@ import javafx.fxml.FXML;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.scene.control.ListCell;
|
||||
|
||||
@@ -18,7 +19,6 @@ import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
@@ -33,6 +33,8 @@ public class AppointmentDialogController {
|
||||
@FXML private ComboBox<DropdownOption> cbCustomer;
|
||||
@FXML private ComboBox<DropdownOption> cbPet;
|
||||
@FXML private ComboBox<DropdownOption> cbEmployee;
|
||||
@FXML private ComboBox<DropdownOption> cbStore;
|
||||
@FXML private VBox vbStore;
|
||||
|
||||
@FXML private ComboBox<Integer> cbHour;
|
||||
@FXML private ComboBox<Integer> cbMinute;
|
||||
@@ -46,8 +48,10 @@ public class AppointmentDialogController {
|
||||
private String mode = null;
|
||||
private AppointmentDTO selectedAppointment = null;
|
||||
private Long pendingPetSelectionId = null;
|
||||
private Long pendingStoreId = null;
|
||||
private boolean isOriginallyCancel = false;
|
||||
private boolean isOriginallyCompletedOrMissed = false;
|
||||
private boolean isEditing = false;
|
||||
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
@@ -63,18 +67,39 @@ public class AppointmentDialogController {
|
||||
@FXML
|
||||
public void initialize() {
|
||||
cbAppointmentStatus.setItems(FXCollections.observableArrayList("Booked", "Completed", "Missed", "Cancelled"));
|
||||
cbAppointmentStatus.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (newVal == null) return;
|
||||
boolean lockAll = "Cancelled".equalsIgnoreCase(newVal)
|
||||
|| "Completed".equalsIgnoreCase(newVal)
|
||||
|| "Missed".equalsIgnoreCase(newVal);
|
||||
if (lockAll) {
|
||||
cbEmployee.setDisable(true);
|
||||
cbHour.setDisable(true);
|
||||
cbMinute.setDisable(true);
|
||||
dpAppointmentDate.setDisable(true);
|
||||
cbStore.setDisable(true);
|
||||
} else {
|
||||
if (!isOriginallyCancel && !isOriginallyCompletedOrMissed) {
|
||||
cbEmployee.setDisable(false);
|
||||
cbHour.setDisable(false);
|
||||
cbMinute.setDisable(false);
|
||||
dpAppointmentDate.setDisable(false);
|
||||
if (UserSession.getInstance().isAdmin() && !isEditing) cbStore.setDisable(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
cbPet.setDisable(true);
|
||||
cbEmployee.setPromptText("Select an employee");
|
||||
cbPet.setPromptText("Select a customer first");
|
||||
cbCustomer.setPromptText("Select a customer");
|
||||
cbService.setPromptText("Select a service");
|
||||
LocalDate minDate = minAppointmentDate();
|
||||
dpAppointmentDate.setValue(minDate);
|
||||
LocalDate today = LocalDate.now();
|
||||
dpAppointmentDate.setValue(today);
|
||||
dpAppointmentDate.setDayCellFactory(picker -> new javafx.scene.control.DateCell() {
|
||||
@Override
|
||||
public void updateItem(LocalDate item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
setDisable(empty || item.isBefore(minDate));
|
||||
setDisable(empty || item.isBefore(today));
|
||||
}
|
||||
});
|
||||
cbAppointmentStatus.setValue("Booked");
|
||||
@@ -179,7 +204,7 @@ public class AppointmentDialogController {
|
||||
Long customerId = newValue != null ? newValue.getId() : null;
|
||||
cbPet.setValue(null);
|
||||
cbPet.setItems(FXCollections.observableArrayList());
|
||||
cbPet.setDisable(customerId == null);
|
||||
cbPet.setDisable(customerId == null || isOriginallyCancel || isOriginallyCompletedOrMissed || isEditing);
|
||||
if (customerId != null) {
|
||||
cbPet.setPromptText("Loading customer pets...");
|
||||
loadCustomerPets(customerId);
|
||||
@@ -189,19 +214,47 @@ public class AppointmentDialogController {
|
||||
}
|
||||
});
|
||||
|
||||
cbStore.setCellFactory(param -> new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(DropdownOption option, boolean empty) {
|
||||
super.updateItem(option, empty);
|
||||
setText(empty || option == null ? null : option.getLabel());
|
||||
}
|
||||
});
|
||||
cbStore.setButtonCell(new ListCell<>() {
|
||||
@Override
|
||||
protected void updateItem(DropdownOption option, boolean empty) {
|
||||
super.updateItem(option, empty);
|
||||
setText(empty || option == null ? null : option.getLabel());
|
||||
}
|
||||
});
|
||||
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
vbStore.setVisible(true);
|
||||
vbStore.setManaged(true);
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Select a store first");
|
||||
}
|
||||
|
||||
btnSave.setOnMouseClicked(this::buttonSaveClicked);
|
||||
btnCancel.setOnMouseClicked(this::closeStage);
|
||||
|
||||
loadServices();
|
||||
loadAppointmentCustomers();
|
||||
loadEmployees();
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
loadStores();
|
||||
} else {
|
||||
loadEmployees();
|
||||
}
|
||||
}
|
||||
|
||||
public void displayAppointmentDetails(AppointmentDTO appt) {
|
||||
|
||||
selectedAppointment = appt;
|
||||
isEditing = true;
|
||||
lblAppointmentId.setText("ID: " + appt.getAppointmentId());
|
||||
pendingPetSelectionId = appt.getPetId() > 0 ? (long) appt.getPetId() : null;
|
||||
pendingStoreId = appt.getStoreId();
|
||||
|
||||
try {
|
||||
dpAppointmentDate.setValue(
|
||||
@@ -248,6 +301,7 @@ public class AppointmentDialogController {
|
||||
dpAppointmentDate.setDisable(true);
|
||||
cbAppointmentStatus.setDisable(true);
|
||||
cbAppointmentStatus.setItems(FXCollections.observableArrayList("Cancelled"));
|
||||
cbStore.setDisable(true);
|
||||
btnSave.setDisable(true);
|
||||
} else if (isOriginallyCompletedOrMissed) {
|
||||
cbService.setDisable(true);
|
||||
@@ -257,12 +311,14 @@ public class AppointmentDialogController {
|
||||
cbHour.setDisable(true);
|
||||
cbMinute.setDisable(true);
|
||||
dpAppointmentDate.setDisable(true);
|
||||
cbStore.setDisable(true);
|
||||
cbAppointmentStatus.setDisable(false);
|
||||
cbAppointmentStatus.setItems(FXCollections.observableArrayList("Completed", "Missed"));
|
||||
} else {
|
||||
cbService.setDisable(true);
|
||||
cbCustomer.setDisable(true);
|
||||
cbPet.setDisable(true);
|
||||
cbStore.setDisable(true);
|
||||
cbEmployee.setDisable(false);
|
||||
cbHour.setDisable(false);
|
||||
cbMinute.setDisable(false);
|
||||
@@ -288,7 +344,16 @@ public class AppointmentDialogController {
|
||||
}
|
||||
|
||||
LocalTime appointmentTime = LocalTime.of(cbHour.getValue(), cbMinute.getValue());
|
||||
Long storeId = UserSession.getInstance().getStoreId();
|
||||
Long storeId;
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
if (cbStore.getSelectionModel().getSelectedItem() == null) {
|
||||
showError("Store is required.");
|
||||
return;
|
||||
}
|
||||
storeId = cbStore.getSelectionModel().getSelectedItem().getId();
|
||||
} else {
|
||||
storeId = UserSession.getInstance().getStoreId();
|
||||
}
|
||||
if (storeId == null || storeId <= 0) {
|
||||
showError("Store is not set for this account");
|
||||
return;
|
||||
@@ -408,7 +473,7 @@ public class AppointmentDialogController {
|
||||
}
|
||||
}
|
||||
cbPet.setItems(petOptions);
|
||||
cbPet.setDisable(petOptions.isEmpty());
|
||||
cbPet.setDisable(petOptions.isEmpty() || isOriginallyCancel || isOriginallyCompletedOrMissed || isEditing);
|
||||
cbPet.setPromptText(petOptions.isEmpty() ? "No pets for selected customer" : "Select a pet");
|
||||
if (pendingPetSelectionId != null) {
|
||||
for (DropdownOption pet : cbPet.getItems()) {
|
||||
@@ -462,7 +527,7 @@ public class AppointmentDialogController {
|
||||
Platform.runLater(() -> {
|
||||
cbCustomer.setItems(FXCollections.observableArrayList(customers));
|
||||
boolean hasCustomers = customers != null && !customers.isEmpty();
|
||||
cbCustomer.setDisable(!hasCustomers);
|
||||
cbCustomer.setDisable(!hasCustomers || isOriginallyCancel || isOriginallyCompletedOrMissed || isEditing);
|
||||
cbPet.setDisable(true);
|
||||
cbPet.setItems(FXCollections.observableArrayList());
|
||||
cbCustomer.setPromptText(hasCustomers ? "Select a customer" : "No customers with pets yet");
|
||||
@@ -484,17 +549,70 @@ public class AppointmentDialogController {
|
||||
}).start();
|
||||
}
|
||||
|
||||
private LocalDate minAppointmentDate() {
|
||||
LocalDate date = LocalDate.now();
|
||||
int businessDaysAdded = 0;
|
||||
while (businessDaysAdded < 2) {
|
||||
date = date.plusDays(1);
|
||||
DayOfWeek dow = date.getDayOfWeek();
|
||||
if (dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY) {
|
||||
businessDaysAdded++;
|
||||
|
||||
private void loadStores() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
Platform.runLater(() -> {
|
||||
if (stores != null) {
|
||||
cbStore.setItems(FXCollections.observableArrayList(stores));
|
||||
cbStore.valueProperty().addListener((obs, oldVal, newVal) -> {
|
||||
Long sid = newVal != null ? newVal.getId() : null;
|
||||
cbEmployee.setValue(null);
|
||||
cbEmployee.setItems(FXCollections.observableArrayList());
|
||||
if (sid != null) {
|
||||
if (!isOriginallyCancel && !isOriginallyCompletedOrMissed) {
|
||||
cbEmployee.setDisable(false);
|
||||
cbEmployee.setPromptText("Select an employee");
|
||||
}
|
||||
loadEmployeesForStore(sid);
|
||||
} else {
|
||||
if (!isOriginallyCancel && !isOriginallyCompletedOrMissed) {
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Select a store first");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (pendingStoreId != null) {
|
||||
for (DropdownOption store : cbStore.getItems()) {
|
||||
if (pendingStoreId.equals(store.getId())) {
|
||||
cbStore.setValue(store);
|
||||
break;
|
||||
}
|
||||
}
|
||||
pendingStoreId = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> ActivityLogger.getInstance().logException(
|
||||
"AppointmentDialogController.loadStores",
|
||||
e,
|
||||
"Loading stores for appointment dialog"));
|
||||
}
|
||||
}
|
||||
return date;
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadEmployeesForStore(Long storeId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> employees = DropdownApi.getInstance().getStoreEmployees(storeId);
|
||||
Platform.runLater(() -> {
|
||||
cbEmployee.setItems(FXCollections.observableArrayList(employees));
|
||||
applySelectedEmployee();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Platform.runLater(() -> {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"AppointmentDialogController.loadEmployeesForStore",
|
||||
e,
|
||||
"Loading employees for store");
|
||||
cbEmployee.setDisable(true);
|
||||
cbEmployee.setPromptText("Unable to load employees");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void loadEmployees() {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.example.petshopdesktop.controllers.dialogcontrollers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.PasswordField;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.Validator;
|
||||
import org.example.petshopdesktop.api.dto.user.UserRequest;
|
||||
import org.example.petshopdesktop.api.dto.user.UserResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.CustomerApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.TextFieldFormatSupport;
|
||||
|
||||
public class CustomerEditDialogController {
|
||||
|
||||
@FXML private TextField txtFirstName;
|
||||
@FXML private TextField txtLastName;
|
||||
@FXML private TextField txtEmail;
|
||||
@FXML private TextField txtPhone;
|
||||
@FXML private TextField txtUsername;
|
||||
@FXML private PasswordField txtPassword;
|
||||
@FXML private PasswordField txtPasswordConfirm;
|
||||
@FXML private ComboBox<String> cbActive;
|
||||
@FXML private TextField txtLoyaltyPoints;
|
||||
@FXML private Label lblError;
|
||||
@FXML private Button btnSave;
|
||||
|
||||
private UserResponse customer;
|
||||
|
||||
@FXML
|
||||
void initialize() {
|
||||
TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone);
|
||||
cbActive.setItems(FXCollections.observableArrayList("Active", "Inactive"));
|
||||
|
||||
boolean isAdmin = UserSession.getInstance().isAdmin();
|
||||
txtLoyaltyPoints.setDisable(!isAdmin);
|
||||
}
|
||||
|
||||
public void setCustomer(UserResponse user) {
|
||||
this.customer = user;
|
||||
String fullName = user.getFullName() == null ? "" : user.getFullName();
|
||||
String[] names = splitFullName(fullName);
|
||||
txtFirstName.setText(names[0]);
|
||||
txtLastName.setText(names[1]);
|
||||
txtEmail.setText(user.getEmail());
|
||||
txtPhone.setText(user.getPhone());
|
||||
txtUsername.setText(user.getUsername());
|
||||
cbActive.setValue(Boolean.TRUE.equals(user.getActive()) ? "Active" : "Inactive");
|
||||
int pts = user.getLoyaltyPoints() != null ? user.getLoyaltyPoints() : 0;
|
||||
txtLoyaltyPoints.setText(String.valueOf(pts));
|
||||
}
|
||||
|
||||
private String[] splitFullName(String fullName) {
|
||||
if (fullName == null || fullName.trim().isEmpty()) return new String[]{"", ""};
|
||||
String[] parts = fullName.trim().split("\\s+", 2);
|
||||
return new String[]{parts.length > 0 ? parts[0] : "", parts.length > 1 ? parts[1] : ""};
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnSaveClicked(ActionEvent event) {
|
||||
lblError.setText("");
|
||||
if (customer == null) {
|
||||
lblError.setText("No customer selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
String firstName = value(txtFirstName);
|
||||
String lastName = value(txtLastName);
|
||||
String email = value(txtEmail);
|
||||
String phone = value(txtPhone);
|
||||
String username = value(txtUsername);
|
||||
String password = txtPassword.getText() == null ? "" : txtPassword.getText().trim();
|
||||
String confirm = txtPasswordConfirm.getText() == null ? "" : txtPasswordConfirm.getText().trim();
|
||||
String loyaltyPointsText = value(txtLoyaltyPoints);
|
||||
|
||||
if (firstName.isBlank() || lastName.isBlank()) {
|
||||
lblError.setText("First name and last name are required.");
|
||||
return;
|
||||
}
|
||||
if (email.isBlank()) {
|
||||
lblError.setText("Email is required.");
|
||||
return;
|
||||
}
|
||||
if (phone.isBlank()) {
|
||||
lblError.setText("Phone is required.");
|
||||
return;
|
||||
}
|
||||
String phoneError = Validator.isValidPhoneNumber(phone, "Phone");
|
||||
if (!phoneError.isEmpty()) {
|
||||
lblError.setText(phoneError.trim());
|
||||
return;
|
||||
}
|
||||
if (username.isBlank()) {
|
||||
lblError.setText("Username is required.");
|
||||
return;
|
||||
}
|
||||
if (!password.isEmpty() && password.length() < 6) {
|
||||
lblError.setText("Password must be at least 6 characters.");
|
||||
return;
|
||||
}
|
||||
if (!password.equals(confirm)) {
|
||||
lblError.setText("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
Integer loyaltyPoints = null;
|
||||
if (UserSession.getInstance().isAdmin()) {
|
||||
if (loyaltyPointsText.isBlank()) {
|
||||
lblError.setText("Loyalty points is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loyaltyPoints = Integer.parseInt(loyaltyPointsText);
|
||||
if (loyaltyPoints < 0) {
|
||||
lblError.setText("Loyalty points cannot be negative.");
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
lblError.setText("Loyalty points must be a valid integer.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
btnSave.setDisable(true);
|
||||
|
||||
boolean active = "Active".equals(cbActive.getValue());
|
||||
Integer finalLoyaltyPoints = loyaltyPoints;
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
UserRequest request = new UserRequest();
|
||||
request.setUsername(username);
|
||||
request.setPassword(password.isEmpty() ? null : password);
|
||||
request.setFirstName(firstName);
|
||||
request.setLastName(lastName);
|
||||
request.setFullName(firstName + " " + lastName);
|
||||
request.setEmail(email);
|
||||
request.setPhone(phone);
|
||||
request.setRole(customer.getRole());
|
||||
request.setActive(active);
|
||||
if (finalLoyaltyPoints != null) request.setLoyaltyPoints(finalLoyaltyPoints);
|
||||
|
||||
CustomerApi.getInstance().updateCustomer(customer.getId(), request);
|
||||
|
||||
Platform.runLater(this::close);
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("CustomerEditDialogController.btnSaveClicked", e, "Updating customer");
|
||||
String msg = e.getMessage() == null ? "Could not update customer." : e.getMessage();
|
||||
Platform.runLater(() -> {
|
||||
lblError.setText(msg);
|
||||
btnSave.setDisable(false);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnCancelClicked(ActionEvent event) {
|
||||
close();
|
||||
}
|
||||
|
||||
private void close() {
|
||||
Stage stage = (Stage) btnSave.getScene().getWindow();
|
||||
stage.close();
|
||||
}
|
||||
|
||||
private static String value(TextField tf) {
|
||||
return tf.getText() == null ? "" : tf.getText().trim();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.example.petshopdesktop.controllers.dialogcontrollers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.event.EventHandler;
|
||||
@@ -9,66 +10,65 @@ import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.StringConverter;
|
||||
import org.example.petshopdesktop.models.Inventory;
|
||||
import org.example.petshopdesktop.Validator;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.dto.inventory.InventoryRequest;
|
||||
import org.example.petshopdesktop.api.dto.inventory.InventoryResponse;
|
||||
import org.example.petshopdesktop.api.dto.product.ProductResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.api.endpoints.InventoryApi;
|
||||
import org.example.petshopdesktop.api.endpoints.ProductApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.models.Product;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class InventoryDialogController {
|
||||
|
||||
//FXML elements
|
||||
@FXML
|
||||
private Button btnCancel;
|
||||
@FXML private Button btnCancel;
|
||||
@FXML private Button btnSave;
|
||||
@FXML private ComboBox<Product> cbProduct;
|
||||
@FXML private ComboBox<DropdownOption> cbStore;
|
||||
@FXML private VBox vbStoreField;
|
||||
@FXML private Label lblInventoryId;
|
||||
@FXML private Label lblMode;
|
||||
@FXML private TextField txtQuantity;
|
||||
|
||||
@FXML
|
||||
private Button btnSave;
|
||||
|
||||
@FXML
|
||||
private ComboBox<Product> cbProduct;
|
||||
|
||||
@FXML
|
||||
private Label lblInventoryId;
|
||||
|
||||
@FXML
|
||||
private Label lblMode;
|
||||
|
||||
@FXML
|
||||
private TextField txtQuantity;
|
||||
|
||||
//Determines if the mode is add or edit
|
||||
private String mode = null;
|
||||
private Long pendingStoreId = null;
|
||||
|
||||
//Loads upon .FXML boot
|
||||
@FXML
|
||||
void initialize() {
|
||||
cbProduct.setConverter(new StringConverter<Product>() {
|
||||
|
||||
//Displays product in combobox (prodID + name)
|
||||
@Override
|
||||
public String toString(Product product) {
|
||||
return product == null ? "" : product.getProdId() + ": " + product.getProdName();
|
||||
}
|
||||
|
||||
//Not needed
|
||||
@Override
|
||||
public Product fromString(String string) { return null; }
|
||||
});
|
||||
|
||||
//Load product list from API into combobox
|
||||
cbStore.setCellFactory(param -> new ListCell<>() {
|
||||
@Override protected void updateItem(DropdownOption o, boolean empty) {
|
||||
super.updateItem(o, empty);
|
||||
setText(empty || o == null ? null : o.getLabel());
|
||||
}
|
||||
});
|
||||
cbStore.setButtonCell(new ListCell<>() {
|
||||
@Override protected void updateItem(DropdownOption o, boolean empty) {
|
||||
super.updateItem(o, empty);
|
||||
setText(empty || o == null ? null : o.getLabel());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
List<ProductResponse> productResponses = ProductApi.getInstance().listProducts(null);
|
||||
if (productResponses != null) {
|
||||
@@ -89,10 +89,16 @@ public class InventoryDialogController {
|
||||
"InventoryDialogController.initialize",
|
||||
e,
|
||||
"Loading products for combo box");
|
||||
System.out.println("Error loading products: " + e.getMessage());
|
||||
}
|
||||
|
||||
//Save button handler
|
||||
boolean isAdmin = UserSession.getInstance().isAdmin();
|
||||
if (isAdmin) {
|
||||
loadStores();
|
||||
} else {
|
||||
vbStoreField.setManaged(false);
|
||||
vbStoreField.setVisible(false);
|
||||
}
|
||||
|
||||
btnSave.setOnMouseClicked(new EventHandler<MouseEvent>() {
|
||||
@Override
|
||||
public void handle(MouseEvent mouseEvent) {
|
||||
@@ -100,7 +106,6 @@ public class InventoryDialogController {
|
||||
}
|
||||
});
|
||||
|
||||
//Cancel button handler
|
||||
btnCancel.setOnMouseClicked(new EventHandler<MouseEvent>() {
|
||||
@Override
|
||||
public void handle(MouseEvent mouseEvent) {
|
||||
@@ -109,29 +114,66 @@ public class InventoryDialogController {
|
||||
});
|
||||
}
|
||||
|
||||
//Handles save button click event
|
||||
private void loadStores() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
Platform.runLater(() -> {
|
||||
cbStore.setItems(FXCollections.observableArrayList(stores));
|
||||
applyPendingStore();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("InventoryDialogController.loadStores", e, "Loading stores");
|
||||
Platform.runLater(() -> {
|
||||
cbStore.setDisable(true);
|
||||
cbStore.setPromptText("Unable to load stores");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void applyPendingStore() {
|
||||
if (pendingStoreId == null) return;
|
||||
for (DropdownOption opt : cbStore.getItems()) {
|
||||
if (opt.getId() != null && opt.getId().equals(pendingStoreId)) {
|
||||
cbStore.setValue(opt);
|
||||
pendingStoreId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void buttonSaveClicked(MouseEvent mouseEvent) {
|
||||
int numRow = 0;
|
||||
String errorMsg = "";
|
||||
|
||||
if (cbProduct.getSelectionModel().getSelectedItem() == null) {
|
||||
errorMsg += "Product is required.\n";
|
||||
}
|
||||
|
||||
//Validate inputs
|
||||
errorMsg += Validator.isPresent(txtQuantity.getText(), "Quantity");
|
||||
errorMsg += Validator.isLessThanVarChars(txtQuantity.getText(), "Quantity", 11);
|
||||
errorMsg += Validator.isPositiveInteger(txtQuantity.getText(), "Quantity");
|
||||
|
||||
//Operation only occurs if there are no errors
|
||||
boolean isAdmin = UserSession.getInstance().isAdmin();
|
||||
Long storeId;
|
||||
if (isAdmin) {
|
||||
if (cbStore.getValue() == null) {
|
||||
errorMsg += "Store is required.\n";
|
||||
storeId = null;
|
||||
} else {
|
||||
storeId = cbStore.getValue().getId();
|
||||
}
|
||||
} else {
|
||||
storeId = UserSession.getInstance().getStoreId();
|
||||
if (storeId == null || storeId <= 0) {
|
||||
errorMsg += "Store is not set for this account.\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg.isEmpty()) {
|
||||
try {
|
||||
InventoryRequest request = new InventoryRequest();
|
||||
Product selectedProduct = cbProduct.getSelectionModel().getSelectedItem();
|
||||
Long storeId = UserSession.getInstance().getStoreId();
|
||||
if (storeId == null || storeId <= 0) {
|
||||
throw new IllegalStateException("Store is not set for this account");
|
||||
}
|
||||
request.setProdId((long) selectedProduct.getProdId());
|
||||
int quantity;
|
||||
try {
|
||||
@@ -168,10 +210,7 @@ public class InventoryDialogController {
|
||||
alert.setContentText(e.getMessage());
|
||||
alert.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
//Displays validation errors
|
||||
else {
|
||||
} else {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setHeaderText("Input Error");
|
||||
alert.setContentText(errorMsg);
|
||||
@@ -179,22 +218,16 @@ public class InventoryDialogController {
|
||||
}
|
||||
}
|
||||
|
||||
//Close dialog view
|
||||
private void closeStage(MouseEvent mouseEvent) {
|
||||
Node node = (Node) mouseEvent.getSource();
|
||||
Stage stage = (Stage) node.getScene().getWindow();
|
||||
stage.close();
|
||||
}
|
||||
|
||||
//Editing
|
||||
//Displays fields with existing inventory data
|
||||
public void displayInventoryDetails(Inventory inventory) {
|
||||
if (inventory != null) {
|
||||
|
||||
//Displays inventory ID
|
||||
lblInventoryId.setText("ID: " + inventory.getInventoryId());
|
||||
|
||||
//Selecting matching product in combobox
|
||||
for (Product product : cbProduct.getItems()) {
|
||||
if (product.getProdId() == inventory.getProdId()) {
|
||||
cbProduct.getSelectionModel().select(product);
|
||||
@@ -203,10 +236,15 @@ public class InventoryDialogController {
|
||||
}
|
||||
|
||||
txtQuantity.setText(String.valueOf(inventory.getQuantity()));
|
||||
|
||||
boolean isAdmin = UserSession.getInstance().isAdmin();
|
||||
if (isAdmin && inventory.getStoreId() > 0) {
|
||||
pendingStoreId = (long) inventory.getStoreId();
|
||||
applyPendingStore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Sets dialog view to add/edit. Updates UI labels
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
lblMode.setText(mode + " Inventory");
|
||||
|
||||
@@ -9,6 +9,7 @@ import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.ReadOnlyStringWrapper;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.control.cell.PropertyValueFactory;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.application.Platform;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.sale.SaleItemRequest;
|
||||
@@ -80,6 +81,21 @@ public class RefundDialogController {
|
||||
@FXML
|
||||
private ComboBox<String> cbPaymentMethod;
|
||||
|
||||
@FXML
|
||||
private Label lblRefundSubtotal;
|
||||
|
||||
@FXML
|
||||
private HBox hbRefundCouponDiscount;
|
||||
|
||||
@FXML
|
||||
private Label lblRefundCouponDiscount;
|
||||
|
||||
@FXML
|
||||
private HBox hbRefundLoyaltyDiscount;
|
||||
|
||||
@FXML
|
||||
private Label lblRefundLoyaltyDiscount;
|
||||
|
||||
@FXML
|
||||
private Label lblRefundTotal;
|
||||
|
||||
@@ -448,8 +464,68 @@ public class RefundDialogController {
|
||||
}
|
||||
|
||||
private void updateRefundTotal() {
|
||||
double total = refundItems.stream().mapToDouble(RefundItem::getTotal).sum();
|
||||
lblRefundTotal.setText(currency.format(total));
|
||||
if (currentSale == null || refundItems.isEmpty()) {
|
||||
lblRefundSubtotal.setText(currency.format(0.0));
|
||||
lblRefundTotal.setText(currency.format(0.0));
|
||||
hbRefundCouponDiscount.setVisible(false);
|
||||
hbRefundCouponDiscount.setManaged(false);
|
||||
hbRefundLoyaltyDiscount.setVisible(false);
|
||||
hbRefundLoyaltyDiscount.setManaged(false);
|
||||
return;
|
||||
}
|
||||
|
||||
double originalSubtotal = currentSale.getSubtotalAmount() != null
|
||||
? currentSale.getSubtotalAmount().doubleValue() : 0;
|
||||
if (originalSubtotal == 0 && currentSale.getItems() != null) {
|
||||
for (var item : currentSale.getItems()) {
|
||||
if (item.getUnitPrice() != null && item.getQuantity() != null) {
|
||||
originalSubtotal += item.getUnitPrice().doubleValue() * Math.abs(item.getQuantity());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double cartSubtotal = refundItems.stream().mapToDouble(RefundItem::getTotal).sum();
|
||||
double originalTotal = currentSale.getTotalAmount() != null
|
||||
? Math.abs(currentSale.getTotalAmount().doubleValue()) : 0;
|
||||
|
||||
double refundTotal;
|
||||
double couponDiscountRefunded = 0;
|
||||
double loyaltyDiscountRefunded = 0;
|
||||
|
||||
if (originalSubtotal > 0) {
|
||||
double ratio = cartSubtotal / originalSubtotal;
|
||||
refundTotal = originalTotal * ratio;
|
||||
if (currentSale.getCouponDiscountAmount() != null) {
|
||||
couponDiscountRefunded = currentSale.getCouponDiscountAmount().doubleValue() * ratio;
|
||||
}
|
||||
if (currentSale.getLoyaltyDiscountAmount() != null) {
|
||||
loyaltyDiscountRefunded = currentSale.getLoyaltyDiscountAmount().doubleValue() * ratio;
|
||||
}
|
||||
} else {
|
||||
refundTotal = cartSubtotal;
|
||||
}
|
||||
|
||||
lblRefundSubtotal.setText(currency.format(cartSubtotal));
|
||||
|
||||
if (couponDiscountRefunded > 0.001) {
|
||||
lblRefundCouponDiscount.setText("-" + currency.format(couponDiscountRefunded));
|
||||
hbRefundCouponDiscount.setVisible(true);
|
||||
hbRefundCouponDiscount.setManaged(true);
|
||||
} else {
|
||||
hbRefundCouponDiscount.setVisible(false);
|
||||
hbRefundCouponDiscount.setManaged(false);
|
||||
}
|
||||
|
||||
if (loyaltyDiscountRefunded > 0.001) {
|
||||
lblRefundLoyaltyDiscount.setText("-" + currency.format(loyaltyDiscountRefunded));
|
||||
hbRefundLoyaltyDiscount.setVisible(true);
|
||||
hbRefundLoyaltyDiscount.setManaged(true);
|
||||
} else {
|
||||
hbRefundLoyaltyDiscount.setVisible(false);
|
||||
hbRefundLoyaltyDiscount.setManaged(false);
|
||||
}
|
||||
|
||||
lblRefundTotal.setText(currency.format(refundTotal));
|
||||
}
|
||||
|
||||
private void closeDialog() {
|
||||
|
||||
@@ -27,6 +27,13 @@ public class SaleDetailDialogController {
|
||||
@FXML private Label lblSaleDate;
|
||||
@FXML private Label lblEmployee;
|
||||
@FXML private Label lblPayment;
|
||||
@FXML private Label lblCustomer;
|
||||
@FXML private javafx.scene.layout.HBox hbDetailSubtotal;
|
||||
@FXML private Label lblDetailSubtotal;
|
||||
@FXML private javafx.scene.layout.HBox hbDetailCouponDiscount;
|
||||
@FXML private Label lblDetailCouponDiscount;
|
||||
@FXML private javafx.scene.layout.HBox hbDetailLoyaltyDiscount;
|
||||
@FXML private Label lblDetailLoyaltyDiscount;
|
||||
@FXML private Label lblTotal;
|
||||
@FXML private Button btnRefund;
|
||||
@FXML private TableView<SaleDetail.SaleDetailItem> tvItems;
|
||||
@@ -61,6 +68,39 @@ public class SaleDetailDialogController {
|
||||
lblSaleDate.setText(sale.getSaleDate() != null ? sale.getSaleDate().format(DATE_FORMATTER) : "");
|
||||
lblEmployee.setText(sale.getEmployeeName() != null ? sale.getEmployeeName() : "");
|
||||
lblPayment.setText(sale.getPaymentMethod() != null ? sale.getPaymentMethod() : "");
|
||||
lblCustomer.setText(sale.getCustomerName() != null && !sale.getCustomerName().isEmpty() ? sale.getCustomerName() : "-");
|
||||
|
||||
if (!sale.isRefund() && sale.getSubtotalAmount() > 0) {
|
||||
hbDetailSubtotal.setVisible(true);
|
||||
hbDetailSubtotal.setManaged(true);
|
||||
lblDetailSubtotal.setText(currency.format(sale.getSubtotalAmount()));
|
||||
|
||||
if (sale.getCouponDiscountAmount() > 0.001) {
|
||||
lblDetailCouponDiscount.setText("-" + currency.format(sale.getCouponDiscountAmount()));
|
||||
hbDetailCouponDiscount.setVisible(true);
|
||||
hbDetailCouponDiscount.setManaged(true);
|
||||
} else {
|
||||
hbDetailCouponDiscount.setVisible(false);
|
||||
hbDetailCouponDiscount.setManaged(false);
|
||||
}
|
||||
|
||||
if (sale.getLoyaltyDiscountAmount() > 0.001) {
|
||||
lblDetailLoyaltyDiscount.setText("-" + currency.format(sale.getLoyaltyDiscountAmount()));
|
||||
hbDetailLoyaltyDiscount.setVisible(true);
|
||||
hbDetailLoyaltyDiscount.setManaged(true);
|
||||
} else {
|
||||
hbDetailLoyaltyDiscount.setVisible(false);
|
||||
hbDetailLoyaltyDiscount.setManaged(false);
|
||||
}
|
||||
} else {
|
||||
hbDetailSubtotal.setVisible(false);
|
||||
hbDetailSubtotal.setManaged(false);
|
||||
hbDetailCouponDiscount.setVisible(false);
|
||||
hbDetailCouponDiscount.setManaged(false);
|
||||
hbDetailLoyaltyDiscount.setVisible(false);
|
||||
hbDetailLoyaltyDiscount.setManaged(false);
|
||||
}
|
||||
|
||||
lblTotal.setText(currency.format(sale.getTotalAmount()));
|
||||
tvItems.setItems(sale.getItems());
|
||||
if (btnRefund != null) {
|
||||
|
||||
@@ -1,84 +1,128 @@
|
||||
package org.example.petshopdesktop.controllers.dialogcontrollers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.PasswordField;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.Validator;
|
||||
import org.example.petshopdesktop.api.dto.user.UserRequest;
|
||||
import org.example.petshopdesktop.api.dto.user.UserResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.UserApi;
|
||||
import org.example.petshopdesktop.api.endpoints.CustomerApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.api.dto.common.DropdownOption;
|
||||
import org.example.petshopdesktop.api.dto.employee.EmployeeRequest;
|
||||
import org.example.petshopdesktop.api.dto.employee.EmployeeResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.DropdownApi;
|
||||
import org.example.petshopdesktop.api.endpoints.EmployeeApi;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.TextFieldFormatSupport;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class StaffEditDialogController {
|
||||
|
||||
@FXML
|
||||
private TextField txtFirstName;
|
||||
@FXML private TextField txtFirstName;
|
||||
@FXML private TextField txtLastName;
|
||||
@FXML private TextField txtEmail;
|
||||
@FXML private TextField txtPhone;
|
||||
@FXML private TextField txtUsername;
|
||||
@FXML private PasswordField txtPassword;
|
||||
@FXML private PasswordField txtPasswordConfirm;
|
||||
@FXML private ComboBox<String> cbRole;
|
||||
@FXML private ComboBox<String> cbActive;
|
||||
@FXML private ComboBox<DropdownOption> cbStore;
|
||||
@FXML private Label lblError;
|
||||
@FXML private Button btnSave;
|
||||
|
||||
@FXML
|
||||
private TextField txtLastName;
|
||||
|
||||
@FXML
|
||||
private TextField txtEmail;
|
||||
|
||||
@FXML
|
||||
private TextField txtPhone;
|
||||
|
||||
@FXML
|
||||
private TextField txtUsername;
|
||||
|
||||
@FXML
|
||||
private PasswordField txtPassword;
|
||||
|
||||
@FXML
|
||||
private PasswordField txtPasswordConfirm;
|
||||
|
||||
@FXML
|
||||
private Label lblError;
|
||||
|
||||
@FXML
|
||||
private Button btnSave;
|
||||
|
||||
private UserResponse user;
|
||||
private EmployeeResponse employee;
|
||||
private Long pendingStoreId = null;
|
||||
|
||||
@FXML
|
||||
void initialize() {
|
||||
TextFieldFormatSupport.applyPhoneNumberFormat(txtPhone);
|
||||
|
||||
cbRole.setItems(FXCollections.observableArrayList("STAFF", "ADMIN"));
|
||||
cbActive.setItems(FXCollections.observableArrayList("Active", "Inactive"));
|
||||
|
||||
cbStore.setCellFactory(param -> new ListCell<>() {
|
||||
@Override protected void updateItem(DropdownOption o, boolean empty) {
|
||||
super.updateItem(o, empty);
|
||||
setText(empty || o == null ? null : o.getLabel());
|
||||
}
|
||||
});
|
||||
cbStore.setButtonCell(new ListCell<>() {
|
||||
@Override protected void updateItem(DropdownOption o, boolean empty) {
|
||||
super.updateItem(o, empty);
|
||||
setText(empty || o == null ? null : o.getLabel());
|
||||
}
|
||||
});
|
||||
|
||||
loadStores();
|
||||
}
|
||||
|
||||
public void setUser(UserResponse user) {
|
||||
this.user = user;
|
||||
String fullName = user.getFullName() == null ? "" : user.getFullName();
|
||||
private void loadStores() {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<DropdownOption> stores = DropdownApi.getInstance().getStores();
|
||||
Platform.runLater(() -> {
|
||||
cbStore.setItems(FXCollections.observableArrayList(stores));
|
||||
applyPendingStore();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("StaffEditDialogController.loadStores", e, "Loading stores");
|
||||
Platform.runLater(() -> {
|
||||
cbStore.setDisable(true);
|
||||
cbStore.setPromptText("Unable to load stores");
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void applyPendingStore() {
|
||||
if (pendingStoreId == null) return;
|
||||
for (DropdownOption opt : cbStore.getItems()) {
|
||||
if (opt.getId() != null && opt.getId().equals(pendingStoreId)) {
|
||||
cbStore.setValue(opt);
|
||||
pendingStoreId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setEmployee(EmployeeResponse emp) {
|
||||
this.employee = emp;
|
||||
String fullName = emp.getFullName() != null ? emp.getFullName() : "";
|
||||
if (fullName.isEmpty() && emp.getFirstName() != null) {
|
||||
fullName = emp.getFirstName() + (emp.getLastName() != null ? " " + emp.getLastName() : "");
|
||||
}
|
||||
String[] names = splitFullName(fullName);
|
||||
txtFirstName.setText(names[0]);
|
||||
txtLastName.setText(names[1]);
|
||||
txtEmail.setText(user.getEmail());
|
||||
txtPhone.setText(user.getPhone());
|
||||
txtUsername.setText(user.getUsername());
|
||||
txtEmail.setText(emp.getEmail());
|
||||
txtPhone.setText(emp.getPhone());
|
||||
txtUsername.setText(emp.getUsername());
|
||||
|
||||
if (emp.getRole() != null) cbRole.setValue(emp.getRole());
|
||||
cbActive.setValue(Boolean.TRUE.equals(emp.getActive()) ? "Active" : "Inactive");
|
||||
|
||||
pendingStoreId = emp.getPrimaryStoreId();
|
||||
applyPendingStore();
|
||||
}
|
||||
|
||||
private String[] splitFullName(String fullName) {
|
||||
if (fullName == null || fullName.trim().isEmpty()) {
|
||||
return new String[]{"", ""};
|
||||
}
|
||||
if (fullName == null || fullName.trim().isEmpty()) return new String[]{"", ""};
|
||||
String[] parts = fullName.trim().split("\\s+", 2);
|
||||
String firstName = parts.length > 0 ? parts[0] : "";
|
||||
String lastName = parts.length > 1 ? parts[1] : "";
|
||||
return new String[]{firstName, lastName};
|
||||
return new String[]{parts.length > 0 ? parts[0] : "", parts.length > 1 ? parts[1] : ""};
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnSaveClicked(ActionEvent event) {
|
||||
lblError.setText("");
|
||||
if (user == null) {
|
||||
lblError.setText("No user selected.");
|
||||
if (employee == null) {
|
||||
lblError.setText("No employee selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,31 +163,41 @@ public class StaffEditDialogController {
|
||||
lblError.setText("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
if (cbRole.getValue() == null) {
|
||||
lblError.setText("Role is required.");
|
||||
return;
|
||||
}
|
||||
if (cbStore.getValue() == null) {
|
||||
lblError.setText("Primary store is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
btnSave.setDisable(true);
|
||||
|
||||
String role = cbRole.getValue();
|
||||
boolean active = "Active".equals(cbActive.getValue());
|
||||
Long storeId = cbStore.getValue().getId();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
UserRequest request = new UserRequest();
|
||||
EmployeeRequest request = new EmployeeRequest();
|
||||
request.setUsername(username);
|
||||
request.setPassword(password.isEmpty() ? null : password);
|
||||
request.setFirstName(firstName);
|
||||
request.setLastName(lastName);
|
||||
request.setFullName(firstName + " " + lastName);
|
||||
request.setEmail(email);
|
||||
request.setPhone(phone);
|
||||
request.setRole(user.getRole());
|
||||
request.setActive(user.getActive());
|
||||
request.setRole(role);
|
||||
request.setActive(active);
|
||||
request.setPrimaryStoreId(storeId);
|
||||
|
||||
UserSession session = UserSession.getInstance();
|
||||
if (session.isAdmin()) {
|
||||
UserApi.getInstance().updateUser(user.getId(), request);
|
||||
} else {
|
||||
CustomerApi.getInstance().updateCustomer(user.getId(), request);
|
||||
}
|
||||
EmployeeApi.getInstance().updateEmployee(employee.getId(), request);
|
||||
|
||||
Platform.runLater(this::close);
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException("StaffEditDialogController.btnSaveClicked", e, "Updating user");
|
||||
String msg = e.getMessage() == null ? "Could not update user." : e.getMessage();
|
||||
ActivityLogger.getInstance().logException("StaffEditDialogController.btnSaveClicked", e, "Updating employee");
|
||||
String msg = e.getMessage() == null ? "Could not update employee." : e.getMessage();
|
||||
Platform.runLater(() -> {
|
||||
lblError.setText(msg);
|
||||
btnSave.setDisable(false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user