added Analytics filter

This commit is contained in:
Alex
2026-04-10 05:03:36 -06:00
parent 3a78021b98
commit 79261274f6
8 changed files with 730 additions and 103 deletions

View File

@@ -2,8 +2,7 @@ package com.example.petstoremobile.dtos;
public class EmployeeDTO {
private long EmployeeId;
private Long userId;
private Long id;
private String username;
private String firstName;
private String lastName;
@@ -11,16 +10,18 @@ public class EmployeeDTO {
private String email;
private String phone;
private String role;
private String staffRole;
private Boolean active;
private String createAt;
private Integer loyaltyPoints;
private Long primaryStoreId;
private String createdAt;
private String updatedAt;
private String password;
// Constructor for create and update the employee
public EmployeeDTO() {}
public EmployeeDTO(String username, String password, String firstName, String lastName,
String email, String phone, String role, boolean active) {
String email, String phone, String role, String staffRole, boolean active, Long primaryStoreId) {
this.username = username;
this.password = password;
this.firstName = firstName;
@@ -28,75 +29,128 @@ public class EmployeeDTO {
this.email = email;
this.phone = phone;
this.role = role;
this.staffRole = staffRole;
this.active = active;
}
// password field for request only
private String password;
public long getEmployeeId() {
return EmployeeId;
this.primaryStoreId = primaryStoreId;
}
public Long getUserId() {
public Long getId() {
return id;
}
return userId;
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public String getFirstName() {
public void setUsername(String username) {
this.username = username;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public String getFullName() {
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getFullName() {
return fullName;
}
public String getEmail() {
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getEmail() {
return email;
}
public String getPhone() {
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public String getRole() {
public void setPhone(String phone) {
this.phone = phone;
}
public String getRole() {
return role;
}
public Boolean getActive() {
public void setRole(String role) {
this.role = role;
}
public String getStaffRole() {
return staffRole;
}
public void setStaffRole(String staffRole) {
this.staffRole = staffRole;
}
public Boolean getActive() {
return active;
}
public String getCreateAt() {
public void setActive(Boolean active) {
this.active = active;
}
return createAt;
public Integer getLoyaltyPoints() {
return loyaltyPoints;
}
public void setLoyaltyPoints(Integer loyaltyPoints) {
this.loyaltyPoints = loyaltyPoints;
}
public Long getPrimaryStoreId() {
return primaryStoreId;
}
public void setPrimaryStoreId(Long primaryStoreId) {
this.primaryStoreId = primaryStoreId;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
public String getPassword() {
public void setUpdatedAt(String updatedAt) {
this.updatedAt = updatedAt;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -8,6 +8,7 @@ import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
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;
@@ -20,6 +21,10 @@ public class AnalyticsFragment extends Fragment {
private FragmentAnalyticsBinding binding;
private AnalyticsViewModel viewModel;
private boolean filtersExpanded = false;
private static final String[] TOP_N_OPTIONS = {"5", "10", "15", "20"};
private static final int[] TOP_N_VALUES = { 5, 10, 15, 20 };
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
@@ -27,19 +32,114 @@ public class AnalyticsFragment extends Fragment {
binding = FragmentAnalyticsBinding.inflate(inflater, container, false);
viewModel = new ViewModelProvider(this).get(AnalyticsViewModel.class);
setupFilterPanel();
observeViewModel();
viewModel.loadAnalytics();
binding.btnRefreshAnalytics.setOnClickListener(v -> viewModel.loadAnalytics());
UIUtils.setupHamburgerMenu(binding.btnHamburgerAnalytics, this);
return binding.getRoot();
}
// Filter Panel
private void setupFilterPanel() {
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerTopN, TOP_N_OPTIONS);
// Toggle expand/collapse
binding.rowFilterHeader.setOnClickListener(v -> toggleFilters());
// Date pickers
binding.etFilterStartDate.setOnClickListener(v ->
UIUtils.showDatePicker(requireContext(), binding.etFilterStartDate, this::updateFilterSummary));
binding.etFilterEndDate.setOnClickListener(v ->
UIUtils.showDatePicker(requireContext(), binding.etFilterEndDate, this::updateFilterSummary));
// Quick presets
binding.btnPresetToday.setOnClickListener(v -> applyPreset(0, 0));
binding.btnPreset7D.setOnClickListener(v -> applyPreset(-6, 0));
binding.btnPreset30D.setOnClickListener(v -> applyPreset(-29, 0));
binding.btnPreset3M.setOnClickListener(v -> applyPreset(-89, 0));
binding.btnPreset1Y.setOnClickListener(v -> applyPreset(-364, 0));
binding.btnPresetAll.setOnClickListener(v -> {
binding.etFilterStartDate.setText("");
binding.etFilterEndDate.setText("");
updateFilterSummary();
});
binding.btnFilterApply.setOnClickListener(v -> applyFiltersFromUI());
binding.btnFilterReset.setOnClickListener(v -> resetFilters());
}
private void toggleFilters() {
filtersExpanded = !filtersExpanded;
binding.llFilterContent.setVisibility(filtersExpanded ? View.VISIBLE : View.GONE);
binding.tvFilterToggleIcon.setText(filtersExpanded ? "" : "");
}
private void applyPreset(int startOffset, int endOffset) {
binding.etFilterStartDate.setText(getDateString(startOffset));
binding.etFilterEndDate.setText(getDateString(endOffset));
updateFilterSummary();
applyFiltersFromUI();
}
private void applyFiltersFromUI() {
AnalyticsViewModel.FilterState filter = new AnalyticsViewModel.FilterState();
filter.startDate = binding.etFilterStartDate.getText().toString().trim();
filter.endDate = binding.etFilterEndDate.getText().toString().trim();
Object pm = binding.spinnerFilterPayment.getSelectedItem();
filter.paymentMethod = pm != null ? pm.toString() : "All";
int topNPos = binding.spinnerTopN.getSelectedItemPosition();
filter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
updateFilterSummary();
viewModel.applyFilter(filter);
}
private void resetFilters() {
binding.etFilterStartDate.setText("");
binding.etFilterEndDate.setText("");
binding.spinnerTopN.setSelection(0);
// Reset payment method to "All"
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, "All");
updateFilterSummary();
viewModel.resetFilter();
}
private void updateFilterSummary() {
String start = binding.etFilterStartDate.getText().toString().trim();
String end = binding.etFilterEndDate.getText().toString().trim();
if (start.isEmpty() && end.isEmpty()) {
binding.tvFilterSummary.setText("All time");
} else if (start.isEmpty()) {
binding.tvFilterSummary.setText("Up to " + shortDate(end));
} else if (end.isEmpty()) {
binding.tvFilterSummary.setText("From " + shortDate(start));
} else {
binding.tvFilterSummary.setText(shortDate(start) + " " + shortDate(end));
}
}
private String shortDate(String date) {
return (date != null && date.length() >= 10) ? date.substring(5) : date;
}
private String getDateString(int offsetDays) {
Calendar c = Calendar.getInstance();
c.add(Calendar.DAY_OF_YEAR, offsetDays);
return String.format(Locale.US, "%04d-%02d-%02d",
c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1, c.get(Calendar.DAY_OF_MONTH));
}
// ViewModel Observation
private void observeViewModel() {
viewModel.getAnalyticsData().observe(getViewLifecycleOwner(), this::computeAndDisplay);
viewModel.getIsLoading().observe(getViewLifecycleOwner(), loading -> {
binding.progressBar.setVisibility(loading ? View.VISIBLE : View.GONE);
if (loading) {
@@ -53,6 +153,15 @@ public class AnalyticsFragment extends Fragment {
viewModel.getErrorMessage().observe(getViewLifecycleOwner(), error -> {
if (error != null) showError(error);
});
viewModel.getAvailablePaymentMethods().observe(getViewLifecycleOwner(), methods -> {
if (methods == null || methods.isEmpty()) return;
String currentSelection = binding.spinnerFilterPayment.getSelectedItem() != null
? binding.spinnerFilterPayment.getSelectedItem().toString() : "All";
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerFilterPayment,
methods.toArray(new String[0]));
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, currentSelection);
});
}
@Override
@@ -61,10 +170,12 @@ public class AnalyticsFragment extends Fragment {
binding = null;
}
// Display
private void computeAndDisplay(AnalyticsViewModel.AnalyticsData data) {
if (data == null) return;
// Summary
// Summary cards
binding.tvTotalRevenue.setText("$" + data.totalRevenue.setScale(2, RoundingMode.HALF_UP));
binding.tvTotalTransactions.setText(String.valueOf(data.totalTransactions));
binding.tvAvgTransaction.setText("$" + data.avgTransaction);
@@ -73,11 +184,12 @@ public class AnalyticsFragment extends Fragment {
// Top Revenue Products
binding.llTopRevenue.removeAllViews();
if (data.topRevenueProducts != null && !data.topRevenueProducts.isEmpty()) {
BigDecimal maxRevenue = data.topRevenueProducts.get(0).getValue();
if (maxRevenue.compareTo(BigDecimal.ZERO) == 0) maxRevenue = BigDecimal.ONE;
BigDecimal maxRev = data.topRevenueProducts.get(0).getValue();
if (maxRev.compareTo(BigDecimal.ZERO) == 0) maxRev = BigDecimal.ONE;
for (Map.Entry<String, BigDecimal> e : data.topRevenueProducts) {
addBarRow(binding.llTopRevenue, e.getKey(), "$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxRevenue.floatValue(), "#ff6b35");
addBarRow(binding.llTopRevenue, e.getKey(),
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxRev.floatValue(), "#ff6b35");
}
} else {
addEmptyRow(binding.llTopRevenue, "No data");
@@ -99,15 +211,13 @@ public class AnalyticsFragment extends Fragment {
// Payment Methods
binding.llPaymentMethods.removeAllViews();
if (data.paymentMethodStats != null && !data.paymentMethodStats.isEmpty()) {
int maxPayment = data.paymentMethodStats.stream().mapToInt(Map.Entry::getValue).max().orElse(1);
String[] paymentColors = { "#1a759f", "#ff9f1c", "#577590", "#90be6d" };
int maxPay = data.paymentMethodStats.stream().mapToInt(Map.Entry::getValue).max().orElse(1);
String[] payColors = { "#1a759f", "#ff9f1c", "#577590", "#90be6d" };
int ci = 0;
for (Map.Entry<String, Integer> e : data.paymentMethodStats) {
addBarRow(binding.llPaymentMethods, e.getKey(),
e.getValue() + " transactions",
(float) e.getValue() / maxPayment,
paymentColors[ci % paymentColors.length]);
ci++;
(float) e.getValue() / maxPay, payColors[ci++ % payColors.length]);
}
} else {
addEmptyRow(binding.llPaymentMethods, "No data");
@@ -116,36 +226,37 @@ public class AnalyticsFragment extends Fragment {
// Employee Performance
binding.llEmployeePerformance.removeAllViews();
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
BigDecimal maxEmp = data.employeePerformance.get(data.employeePerformance.size() - 1).getValue();
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
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");
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
}
} else {
addEmptyRow(binding.llEmployeePerformance, "No data");
}
// Daily Revenue
binding.tvDailyRevenueTitle.setText(data.dailyRevenueTitle);
binding.llDailyRevenue.removeAllViews();
if (data.dailyRevenue != null && !data.dailyRevenue.isEmpty()) {
BigDecimal maxDaily = data.dailyRevenue.stream().map(Map.Entry::getValue).max(BigDecimal::compareTo).orElse(BigDecimal.ONE);
BigDecimal maxDaily = data.dailyRevenue.stream()
.map(Map.Entry::getValue).max(BigDecimal::compareTo).orElse(BigDecimal.ONE);
if (maxDaily.compareTo(BigDecimal.ZERO) == 0) maxDaily = BigDecimal.ONE;
for (Map.Entry<String, BigDecimal> e : data.dailyRevenue) {
String label = e.getKey().length() >= 10 ? e.getKey().substring(5) : e.getKey();
addBarRow(binding.llDailyRevenue, label,
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
e.getValue().floatValue() / maxDaily.floatValue(),
"#ff6b35");
e.getValue().floatValue() / maxDaily.floatValue(), "#ff6b35");
}
} else {
addEmptyRow(binding.llDailyRevenue, "No data");
}
}
// Chart Helpers
private void addBarRow(LinearLayout parent, String label, String value, float ratio, String color) {
if (getContext() == null) return;
LinearLayout row = new LinearLayout(getContext());
@@ -156,8 +267,7 @@ public class AnalyticsFragment extends Fragment {
labelRow.setOrientation(LinearLayout.HORIZONTAL);
TextView tvLabel = new TextView(getContext());
tvLabel.setLayoutParams(new LinearLayout.LayoutParams(
0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
tvLabel.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
tvLabel.setText(label);
tvLabel.setTextColor(Color.parseColor("#444441"));
tvLabel.setTextSize(13f);
@@ -172,22 +282,19 @@ public class AnalyticsFragment extends Fragment {
labelRow.addView(tvValue);
LinearLayout barBg = new LinearLayout(getContext());
LinearLayout.LayoutParams bgParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 12);
LinearLayout.LayoutParams bgParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 12);
bgParams.setMargins(0, 4, 0, 0);
barBg.setLayoutParams(bgParams);
barBg.setBackgroundColor(Color.parseColor("#EEEEEE"));
float safeRatio = Math.max(0f, Math.min(1f, ratio));
View barFill = new View(getContext());
LinearLayout.LayoutParams fillParams = new LinearLayout.LayoutParams(
0, LinearLayout.LayoutParams.MATCH_PARENT, ratio);
barFill.setLayoutParams(fillParams);
barFill.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, safeRatio));
barFill.setBackgroundColor(Color.parseColor(color));
barBg.addView(barFill);
View spacer = new View(getContext());
spacer.setLayoutParams(new LinearLayout.LayoutParams(
0, LinearLayout.LayoutParams.MATCH_PARENT, 1f - ratio));
spacer.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f - safeRatio));
barBg.addView(spacer);
row.addView(labelRow);
@@ -205,8 +312,7 @@ public class AnalyticsFragment extends Fragment {
}
private void showError(String msg) {
if (getContext() == null || binding == null)
return;
if (getContext() == null || binding == null) return;
binding.tvTotalRevenue.setText("Error");
binding.tvTotalTransactions.setText("");
binding.tvAvgTransaction.setText("");

View File

@@ -76,7 +76,7 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
Bundle args = new Bundle();
if (position != -1) {
EmployeeDTO e = staffList.get(position);
args.putLong("employeeId", e.getEmployeeId());
args.putLong("employeeId", e.getId());
args.putString("username", e.getUsername() != null ? e.getUsername() : "");
args.putString("firstName", e.getFirstName() != null ? e.getFirstName() : "");
args.putString("lastName", e.getLastName() != null ? e.getLastName() : "");

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import com.example.petstoremobile.R;
import com.example.petstoremobile.databinding.FragmentStaffDetailBinding;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.EmployeeDTO;
import com.example.petstoremobile.utils.DialogUtils;
import com.example.petstoremobile.utils.InputValidator;
@@ -16,6 +17,9 @@ import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.StaffDetailViewModel;
import com.example.petstoremobile.utils.Resource;
import java.util.List;
import dagger.hilt.android.AndroidEntryPoint;
@AndroidEntryPoint
@@ -25,8 +29,11 @@ public class StaffDetailFragment extends Fragment {
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;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
@@ -34,6 +41,8 @@ public class StaffDetailFragment extends Fragment {
viewModel = new ViewModelProvider(this).get(StaffDetailViewModel.class);
setupSpinners();
observeViewModel();
loadStores();
handleArguments();
binding.btnStaffBack.setOnClickListener(v -> navigateBack());
@@ -45,11 +54,30 @@ public class StaffDetailFragment extends Fragment {
return binding.getRoot();
}
private void observeViewModel() {
viewModel.getStoreList().observe(getViewLifecycleOwner(), list -> refreshStoreSpinner());
}
private void setupSpinners() {
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffRole, ROLES);
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffType, STAFF_ROLES);
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerStaffStatus, STATUSES);
}
private void loadStores() {
viewModel.loadStores().observe(getViewLifecycleOwner(), resource -> {
if (resource != null && resource.status == Resource.Status.SUCCESS && resource.data != null) {
viewModel.setStoreList(resource.data);
}
});
}
private void refreshStoreSpinner() {
SpinnerUtils.populateSpinner(requireContext(), binding.spinnerStaffStore, viewModel.getStoreList().getValue(),
DropdownDTO::getLabel, "-- Select Store --",
preselectedStoreId, DropdownDTO::getId);
}
private void handleArguments() {
Bundle a = getArguments();
if (a != null && a.getBoolean("isEditing", false)) {
@@ -59,16 +87,9 @@ public class StaffDetailFragment extends Fragment {
binding.tvStaffMode.setText("Edit Staff Account");
binding.tvStaffId.setText("ID: " + employeeId);
binding.tvStaffId.setVisibility(View.VISIBLE);
binding.etStaffUsername.setText(a.getString("username", ""));
binding.etStaffFirstName.setText(a.getString("firstName", ""));
binding.etStaffLastName.setText(a.getString("lastName", ""));
binding.etStaffEmail.setText(a.getString("email", ""));
binding.etStaffPhone.setText(a.getString("phone", ""));
binding.btnDeleteStaff.setVisibility(View.VISIBLE);
SpinnerUtils.setSelectionByValue(binding.spinnerStaffRole, a.getString("role", "STAFF"));
binding.spinnerStaffStatus.setSelection(a.getBoolean("active", true) ? 0 : 1);
loadEmployeeData(employeeId);
} else {
viewModel.setEmployeeId(-1, false);
binding.tvStaffMode.setText("Add Staff Account");
@@ -77,6 +98,29 @@ public class StaffDetailFragment extends Fragment {
}
}
private void loadEmployeeData(long id) {
viewModel.loadEmployee(id).observe(getViewLifecycleOwner(), resource -> {
if (resource != null) {
setLoading(resource.status == Resource.Status.LOADING);
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
EmployeeDTO e = resource.data;
binding.etStaffUsername.setText(e.getUsername());
binding.etStaffFirstName.setText(e.getFirstName());
binding.etStaffLastName.setText(e.getLastName());
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;
refreshStoreSpinner();
}
}
});
}
private void setLoading(boolean loading) {
if (binding != null && binding.progressBar != null) {
binding.progressBar.setVisibility(loading ? View.VISIBLE : View.GONE);
@@ -100,6 +144,7 @@ public class StaffDetailFragment extends Fragment {
if (!InputValidator.isNotEmpty(binding.etStaffLastName, "Last Name")) return;
if (!InputValidator.isValidEmail(binding.etStaffEmail)) return;
if (!InputValidator.isValidPhone(binding.etStaffPhone)) return;
if (!InputValidator.isSpinnerSelected(binding.spinnerStaffStore, "Primary Store")) return;
String username = binding.etStaffUsername.getText().toString().trim();
String password = binding.etStaffPassword.getText().toString().trim();
@@ -108,7 +153,11 @@ public class StaffDetailFragment extends Fragment {
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()];
boolean active = binding.spinnerStaffStatus.getSelectedItemPosition() == 0;
List<DropdownDTO> stores = viewModel.getStoreList().getValue();
Long storeId = stores.get(binding.spinnerStaffStore.getSelectedItemPosition() - 1).getId();
EmployeeDTO dto = new EmployeeDTO(
username,
@@ -118,7 +167,9 @@ public class StaffDetailFragment extends Fragment {
email,
phone,
role,
active
staffRole,
active,
storeId
);
viewModel.saveEmployee(dto).observe(getViewLifecycleOwner(), resource -> {

View File

@@ -16,6 +16,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
@@ -30,6 +31,10 @@ public class AnalyticsViewModel extends ViewModel {
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 List<SaleDTO> cachedSales = new ArrayList<>();
private FilterState currentFilter = new FilterState();
@Inject
public AnalyticsViewModel(SaleRepository saleRepository) {
@@ -39,14 +44,17 @@ public class AnalyticsViewModel extends ViewModel {
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 void loadAnalytics() {
isLoading.setValue(true);
errorMessage.setValue(null);
saleRepository.getAllSales(0, 1000, null, null, null, "saleDate,desc").observeForever(resource -> {
saleRepository.getAllSales(0, 2000, null, null, null, "saleDate,desc").observeForever(resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
computeAnalytics(resource.data.getContent());
cachedSales = resource.data.getContent();
derivePaymentMethods();
applyCurrentFilter();
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
errorMessage.setValue(resource.message);
@@ -56,11 +64,53 @@ public class AnalyticsViewModel extends ViewModel {
});
}
private void computeAnalytics(List<SaleDTO> sales) {
public void applyFilter(FilterState filter) {
currentFilter = filter;
applyCurrentFilter();
}
public void resetFilter() {
currentFilter = new FilterState();
applyCurrentFilter();
}
private void applyCurrentFilter() {
List<SaleDTO> filtered = filterSales(cachedSales, currentFilter);
computeAnalytics(filtered, currentFilter);
}
private void derivePaymentMethods() {
java.util.Set<String> methods = new java.util.TreeSet<>();
for (SaleDTO s : cachedSales) {
if (s.getPaymentMethod() != null && !s.getPaymentMethod().isEmpty()) {
methods.add(s.getPaymentMethod());
}
}
List<String> result = new ArrayList<>();
result.add("All");
result.addAll(methods);
availablePaymentMethods.setValue(result);
}
private List<SaleDTO> filterSales(List<SaleDTO> sales, FilterState filter) {
List<SaleDTO> result = new ArrayList<>();
for (SaleDTO s : sales) {
String date = s.getSaleDate() != null && s.getSaleDate().length() >= 10
? s.getSaleDate().substring(0, 10) : "";
if (!filter.startDate.isEmpty() && !date.isEmpty() && date.compareTo(filter.startDate) < 0) continue;
if (!filter.endDate.isEmpty() && !date.isEmpty() && date.compareTo(filter.endDate) > 0) continue;
if (!filter.paymentMethod.equals("All") && !filter.paymentMethod.isEmpty()) {
if (!filter.paymentMethod.equalsIgnoreCase(s.getPaymentMethod())) continue;
}
result.add(s);
}
return result;
}
private void computeAnalytics(List<SaleDTO> sales, FilterState filter) {
List<SaleDTO> regularSales = new ArrayList<>();
for (SaleDTO s : sales) {
if (!Boolean.TRUE.equals(s.getIsRefund()))
regularSales.add(s);
if (!Boolean.TRUE.equals(s.getIsRefund())) regularSales.add(s);
}
AnalyticsData data = new AnalyticsData();
@@ -83,72 +133,127 @@ public class AnalyticsViewModel extends ViewModel {
: BigDecimal.ZERO;
data.totalItems = totalItems;
// Product Maps
Map<String, BigDecimal> revenueByProduct = new LinkedHashMap<>();
Map<String, Integer> quantityByProduct = new LinkedHashMap<>();
Map<String, Integer> paymentCount = new LinkedHashMap<>();
Map<String, BigDecimal> employeeRevenue = new LinkedHashMap<>();
for (SaleDTO s : regularSales) {
// Payments
String method = s.getPaymentMethod() != null ? s.getPaymentMethod() : "Unknown";
paymentCount.merge(method, 1, Integer::sum);
// Employee
String emp = s.getEmployeeName() != null ? s.getEmployeeName() : "Unknown";
if (s.getTotalAmount() != null) employeeRevenue.merge(emp, s.getTotalAmount(), BigDecimal::add);
// Items
if (s.getItems() != null) {
for (SaleDTO.SaleItemDTO item : s.getItems()) {
String name = item.getProductName() != null ? item.getProductName() : "Unknown";
int qty = item.getQuantity() != null ? Math.abs(item.getQuantity()) : 0;
BigDecimal lineTotal = item.getUnitPrice() != null
? item.getUnitPrice().multiply(BigDecimal.valueOf(qty))
: BigDecimal.ZERO;
? item.getUnitPrice().multiply(BigDecimal.valueOf(qty)) : BigDecimal.ZERO;
revenueByProduct.merge(name, lineTotal, BigDecimal::add);
quantityByProduct.merge(name, qty, Integer::sum);
}
}
}
// Sort Top Revenue
int topN = filter.topN > 0 ? filter.topN : 5;
data.topRevenueProducts = new ArrayList<>(revenueByProduct.entrySet());
data.topRevenueProducts.sort((a, b) -> b.getValue().compareTo(a.getValue()));
if (data.topRevenueProducts.size() > 5) data.topRevenueProducts = data.topRevenueProducts.subList(0, 5);
if (data.topRevenueProducts.size() > topN) data.topRevenueProducts = data.topRevenueProducts.subList(0, topN);
// Sort Top Quantity
data.topQuantityProducts = new ArrayList<>(quantityByProduct.entrySet());
data.topQuantityProducts.sort((a, b) -> b.getValue() - a.getValue());
if (data.topQuantityProducts.size() > 5) data.topQuantityProducts = data.topQuantityProducts.subList(0, 5);
if (data.topQuantityProducts.size() > topN) data.topQuantityProducts = data.topQuantityProducts.subList(0, topN);
// Payment Stats
data.paymentMethodStats = new ArrayList<>(paymentCount.entrySet());
data.paymentMethodStats.sort((a, b) -> b.getValue() - a.getValue());
// Employee Performance
data.employeePerformance = new ArrayList<>(employeeRevenue.entrySet());
data.employeePerformance.sort((a, b) -> b.getValue().compareTo(a.getValue()));
// Daily Revenue (last 7 days)
Map<String, BigDecimal> dailyMap = new TreeMap<>();
for (int i = 6; i >= 0; i--) {
Calendar day = Calendar.getInstance();
day.add(Calendar.DAY_OF_YEAR, -i);
String key = String.format("%04d-%02d-%02d",
day.get(Calendar.YEAR), day.get(Calendar.MONTH) + 1, day.get(Calendar.DAY_OF_MONTH));
dailyMap.put(key, BigDecimal.ZERO);
// Daily revenue display to filter date range, max 60 days
String rangeStart = filter.startDate;
String rangeEnd = filter.endDate;
if (rangeStart.isEmpty() && rangeEnd.isEmpty()) {
rangeEnd = todayString(0);
rangeStart = todayString(-6);
} else if (rangeStart.isEmpty()) {
rangeStart = shiftDate(rangeEnd, -6);
} else if (rangeEnd.isEmpty()) {
rangeEnd = todayString(0);
}
List<String> dateRange = buildDateRange(rangeStart, rangeEnd, 60);
Map<String, BigDecimal> dailyMap = new TreeMap<>();
for (String d : dateRange) dailyMap.put(d, BigDecimal.ZERO);
for (SaleDTO s : regularSales) {
if (s.getSaleDate() != null && s.getTotalAmount() != null) {
String date = s.getSaleDate().length() >= 10 ? s.getSaleDate().substring(0, 10) : s.getSaleDate();
if (dailyMap.containsKey(date)) dailyMap.merge(date, s.getTotalAmount(), BigDecimal::add);
String d = s.getSaleDate().length() >= 10 ? s.getSaleDate().substring(0, 10) : s.getSaleDate();
if (dailyMap.containsKey(d)) dailyMap.merge(d, s.getTotalAmount(), BigDecimal::add);
}
}
data.dailyRevenue = new ArrayList<>(dailyMap.entrySet());
data.dailyRevenueTitle = buildDailyTitle(filter, rangeStart, rangeEnd);
analyticsData.setValue(data);
}
private String todayString(int offsetDays) {
Calendar c = Calendar.getInstance();
c.add(Calendar.DAY_OF_YEAR, offsetDays);
return String.format(Locale.US, "%04d-%02d-%02d",
c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1, c.get(Calendar.DAY_OF_MONTH));
}
private String shiftDate(String date, int offsetDays) {
try {
String[] p = date.split("-");
Calendar c = Calendar.getInstance();
c.set(Integer.parseInt(p[0]), Integer.parseInt(p[1]) - 1, Integer.parseInt(p[2]), 0, 0, 0);
c.add(Calendar.DAY_OF_YEAR, offsetDays);
return String.format(Locale.US, "%04d-%02d-%02d",
c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1, c.get(Calendar.DAY_OF_MONTH));
} catch (Exception e) {
return date;
}
}
private List<String> buildDateRange(String start, String end, int maxDays) {
List<String> dates = new ArrayList<>();
try {
String[] sp = start.split("-");
String[] ep = end.split("-");
Calendar cur = Calendar.getInstance();
cur.set(Integer.parseInt(sp[0]), Integer.parseInt(sp[1]) - 1, Integer.parseInt(sp[2]), 0, 0, 0);
Calendar endCal = Calendar.getInstance();
endCal.set(Integer.parseInt(ep[0]), Integer.parseInt(ep[1]) - 1, Integer.parseInt(ep[2]), 0, 0, 0);
int count = 0;
while (!cur.after(endCal) && count < maxDays) {
dates.add(String.format(Locale.US, "%04d-%02d-%02d",
cur.get(Calendar.YEAR), cur.get(Calendar.MONTH) + 1, cur.get(Calendar.DAY_OF_MONTH)));
cur.add(Calendar.DAY_OF_YEAR, 1);
count++;
}
} catch (Exception ignored) {}
return dates;
}
private String buildDailyTitle(FilterState filter, String rangeStart, String rangeEnd) {
if (filter.startDate.isEmpty() && filter.endDate.isEmpty()) return "Daily Revenue (Last 7 Days)";
String s = rangeStart.length() >= 10 ? rangeStart.substring(5) : rangeStart;
String e = rangeEnd.length() >= 10 ? rangeEnd.substring(5) : rangeEnd;
return "Daily Revenue (" + s + " " + e + ")";
}
public static class FilterState {
public String startDate = "";
public String endDate = "";
public String paymentMethod = "All";
public int topN = 5;
}
public static class AnalyticsData {
public BigDecimal totalRevenue;
public int totalTransactions;
@@ -159,5 +264,6 @@ public class AnalyticsViewModel extends ViewModel {
public List<Map.Entry<String, Integer>> paymentMethodStats;
public List<Map.Entry<String, BigDecimal>> employeePerformance;
public List<Map.Entry<String, BigDecimal>> dailyRevenue;
public String dailyRevenueTitle = "Daily Revenue";
}
}

View File

@@ -1,12 +1,17 @@
package com.example.petstoremobile.viewmodels;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.EmployeeDTO;
import com.example.petstoremobile.repositories.EmployeeRepository;
import com.example.petstoremobile.repositories.StoreRepository;
import com.example.petstoremobile.utils.Resource;
import java.util.List;
import javax.inject.Inject;
import dagger.hilt.android.lifecycle.HiltViewModel;
@@ -14,12 +19,31 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
@HiltViewModel
public class StaffDetailViewModel extends ViewModel {
private final EmployeeRepository repository;
private final StoreRepository storeRepository;
private final MutableLiveData<List<DropdownDTO>> storeList = new MutableLiveData<>();
private long employeeId = -1;
private boolean isEditing = false;
@Inject
public StaffDetailViewModel(EmployeeRepository repository) {
public StaffDetailViewModel(EmployeeRepository repository, StoreRepository storeRepository) {
this.repository = repository;
this.storeRepository = storeRepository;
}
public LiveData<Resource<List<DropdownDTO>>> loadStores() {
return storeRepository.getStoreDropdowns();
}
public LiveData<List<DropdownDTO>> getStoreList() {
return storeList;
}
public void setStoreList(List<DropdownDTO> list) {
storeList.setValue(list);
}
public LiveData<Resource<EmployeeDTO>> loadEmployee(long id) {
return repository.getEmployeeById(id);
}
public void setEmployeeId(long id, boolean isEditing) {

View File

@@ -45,9 +45,267 @@
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/rounded_card"
android:padding="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp">
<LinearLayout
android:id="@+id/rowFilterHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:clickable="true"
android:focusable="true"
android:background="?attr/selectableItemBackground">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Filters"
android:textColor="@color/text_dark"
android:textSize="14sp"
android:textStyle="bold"/>
<TextView
android:id="@+id/tvFilterSummary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="All time"
android:textColor="@color/text_light"
android:textSize="12sp"
android:layout_marginEnd="8dp"/>
<TextView
android:id="@+id/tvFilterToggleIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="▼"
android:textColor="@color/text_light"
android:textSize="12sp"/>
</LinearLayout>
<LinearLayout
android:id="@+id/llFilterContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="12dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Quick Range"
android:textColor="@color/text_light"
android:textSize="11sp"
android:layout_marginBottom="6dp"/>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none"
android:layout_marginBottom="12dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnPresetToday"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="Today"
android:textSize="11sp"
android:backgroundTint="@color/primary_medium"
android:textColor="@color/white"
android:layout_marginEnd="6dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"/>
<Button
android:id="@+id/btnPreset7D"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="7D"
android:textSize="11sp"
android:backgroundTint="@color/primary_medium"
android:textColor="@color/white"
android:layout_marginEnd="6dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"/>
<Button
android:id="@+id/btnPreset30D"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="30D"
android:textSize="11sp"
android:backgroundTint="@color/primary_medium"
android:textColor="@color/white"
android:layout_marginEnd="6dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"/>
<Button
android:id="@+id/btnPreset3M"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="3M"
android:textSize="11sp"
android:backgroundTint="@color/primary_medium"
android:textColor="@color/white"
android:layout_marginEnd="6dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"/>
<Button
android:id="@+id/btnPreset1Y"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="1Y"
android:textSize="11sp"
android:backgroundTint="@color/primary_medium"
android:textColor="@color/white"
android:layout_marginEnd="6dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"/>
<Button
android:id="@+id/btnPresetAll"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="All"
android:textSize="11sp"
android:backgroundTint="@color/text_light"
android:textColor="@color/white"
android:paddingStart="10dp"
android:paddingEnd="10dp"/>
</LinearLayout>
</HorizontalScrollView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Date Range"
android:textColor="@color/text_light"
android:textSize="11sp"
android:layout_marginBottom="6dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<EditText
android:id="@+id/etFilterStartDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Start date"
android:inputType="none"
android:focusable="false"
android:clickable="true"
android:drawableEnd="@android:drawable/ic_menu_my_calendar"
android:textSize="13sp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=" "
android:textColor="@color/text_light"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"/>
<EditText
android:id="@+id/etFilterEndDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="End date"
android:inputType="none"
android:focusable="false"
android:clickable="true"
android:drawableEnd="@android:drawable/ic_menu_my_calendar"
android:textSize="13sp"/>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Payment Method"
android:textColor="@color/text_light"
android:textSize="11sp"
android:layout_marginBottom="4dp"/>
<Spinner
android:id="@+id/spinnerFilterPayment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Top N Products"
android:textColor="@color/text_light"
android:textSize="11sp"
android:layout_marginBottom="4dp"/>
<Spinner
android:id="@+id/spinnerTopN"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnFilterApply"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="6dp"
android:text="Apply"
android:backgroundTint="@color/accent_coral"
android:textColor="@color/white"/>
<Button
android:id="@+id/btnFilterReset"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Reset"
android:backgroundTint="@color/primary_medium"
android:textColor="@color/white"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
@@ -299,6 +557,7 @@
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/tvDailyRevenueTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Daily Revenue (Last 7 Days)"

View File

@@ -173,7 +173,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Role"
android:text="User Role"
android:textColor="@color/text_dark"
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
@@ -184,7 +184,34 @@
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"/>
<!-- Status -->
<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"
android:text="Primary Store"
android:textColor="@color/text_dark"
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<Spinner
android:id="@+id/spinnerStaffStore"
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"