From 79261274f6db442e306667cc618bdc301a556d8e Mon Sep 17 00:00:00 2001 From: Alex <78383757+Lextical@users.noreply.github.com> Date: Fri, 10 Apr 2026 05:03:36 -0600 Subject: [PATCH] added Analytics filter --- .../petstoremobile/dtos/EmployeeDTO.java | 114 ++++++-- .../listfragments/AnalyticsFragment.java | 170 +++++++++--- .../listfragments/StaffFragment.java | 2 +- .../detailfragments/StaffDetailFragment.java | 69 ++++- .../viewmodels/AnalyticsViewModel.java | 160 +++++++++-- .../viewmodels/StaffDetailViewModel.java | 26 +- .../main/res/layout/fragment_analytics.xml | 261 +++++++++++++++++- .../main/res/layout/fragment_staff_detail.xml | 31 ++- 8 files changed, 730 insertions(+), 103 deletions(-) diff --git a/android/app/src/main/java/com/example/petstoremobile/dtos/EmployeeDTO.java b/android/app/src/main/java/com/example/petstoremobile/dtos/EmployeeDTO.java index 21577a25..f29b4beb 100644 --- a/android/app/src/main/java/com/example/petstoremobile/dtos/EmployeeDTO.java +++ b/android/app/src/main/java/com/example/petstoremobile/dtos/EmployeeDTO.java @@ -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; + } } diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java index 44719127..b8656b60 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java @@ -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 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 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 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 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("—"); diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/StaffFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/StaffFragment.java index 8407d3f6..62ec931f 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/StaffFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/StaffFragment.java @@ -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() : ""); diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java index 1c1bfc4e..3c1f81d6 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/StaffDetailFragment.java @@ -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 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 -> { diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java index 76c039ac..6ebfb741 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java @@ -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 = new MutableLiveData<>(); private final MutableLiveData isLoading = new MutableLiveData<>(false); private final MutableLiveData errorMessage = new MutableLiveData<>(); + private final MutableLiveData> availablePaymentMethods = new MutableLiveData<>(new ArrayList<>()); + + private List cachedSales = new ArrayList<>(); + private FilterState currentFilter = new FilterState(); @Inject public AnalyticsViewModel(SaleRepository saleRepository) { @@ -39,14 +44,17 @@ public class AnalyticsViewModel extends ViewModel { public LiveData getAnalyticsData() { return analyticsData; } public LiveData getIsLoading() { return isLoading; } public LiveData getErrorMessage() { return errorMessage; } + public LiveData> 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 sales) { + public void applyFilter(FilterState filter) { + currentFilter = filter; + applyCurrentFilter(); + } + + public void resetFilter() { + currentFilter = new FilterState(); + applyCurrentFilter(); + } + + private void applyCurrentFilter() { + List filtered = filterSales(cachedSales, currentFilter); + computeAnalytics(filtered, currentFilter); + } + + private void derivePaymentMethods() { + java.util.Set methods = new java.util.TreeSet<>(); + for (SaleDTO s : cachedSales) { + if (s.getPaymentMethod() != null && !s.getPaymentMethod().isEmpty()) { + methods.add(s.getPaymentMethod()); + } + } + List result = new ArrayList<>(); + result.add("All"); + result.addAll(methods); + availablePaymentMethods.setValue(result); + } + + private List filterSales(List sales, FilterState filter) { + List 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 sales, FilterState filter) { List 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 revenueByProduct = new LinkedHashMap<>(); Map quantityByProduct = new LinkedHashMap<>(); Map paymentCount = new LinkedHashMap<>(); Map 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 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 dateRange = buildDateRange(rangeStart, rangeEnd, 60); + Map 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 buildDateRange(String start, String end, int maxDays) { + List 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> paymentMethodStats; public List> employeePerformance; public List> dailyRevenue; + public String dailyRevenueTitle = "Daily Revenue"; } } diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java index 91162405..dffe47c3 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/StaffDetailViewModel.java @@ -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> 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>> loadStores() { + return storeRepository.getStoreDropdowns(); + } + + public LiveData> getStoreList() { + return storeList; + } + + public void setStoreList(List list) { + storeList.setValue(list); + } + + public LiveData> loadEmployee(long id) { + return repository.getEmployeeById(id); } public void setEmployeeId(long id, boolean isEditing) { diff --git a/android/app/src/main/res/layout/fragment_analytics.xml b/android/app/src/main/res/layout/fragment_analytics.xml index d06b36ec..19a7a51b 100644 --- a/android/app/src/main/res/layout/fragment_analytics.xml +++ b/android/app/src/main/res/layout/fragment_analytics.xml @@ -45,9 +45,267 @@ + + + + + + + + + + + + + + + + + + + + +