merge main into websitefinal

This commit is contained in:
2026-04-15 12:26:31 -06:00
212 changed files with 10517 additions and 1699 deletions

73
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Build and Deploy
on:
push:
branches: [main, azure-deploy]
env:
REGISTRY: ghcr.io
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image names (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push backend image
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: ${{ env.BACKEND_IMAGE }}:latest
- name: Build and push frontend image
uses: docker/build-push-action@v5
with:
context: ./web
push: true
tags: ${{ env.FRONTEND_IMAGE }}:latest
build-args: |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
- name: Log in to Azure
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy backend
run: |
az containerapp update \
--name ${{ secrets.AZURE_BACKEND_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ env.BACKEND_IMAGE }}:latest \
--registry-server ${{ env.REGISTRY }} \
--registry-username ${{ github.actor }} \
--registry-password ${{ secrets.GITHUB_TOKEN }}
- name: Deploy frontend
run: |
az containerapp update \
--name ${{ secrets.AZURE_FRONTEND_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ env.FRONTEND_IMAGE }}:latest \
--registry-server ${{ env.REGISTRY }} \
--registry-username ${{ github.actor }} \
--registry-password ${{ secrets.GITHUB_TOKEN }}

1570
.idea/caches/deviceStreaming.xml generated Normal file

File diff suppressed because it is too large Load Diff

8
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View File

@@ -16,9 +16,9 @@ val localProperties = Properties().apply {
fun quoted(value: String): String = "\"$value\""
val emulatorBackendUrl =
(localProperties.getProperty("petstore.backend.emulatorUrl") ?: "http://10.0.2.2:8080/").trim()
(localProperties.getProperty("petstore.backend.emulatorUrl") ?: "https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io/").trim()
val deviceBackendUrl =
(localProperties.getProperty("petstore.backend.deviceUrl") ?: "http://10.0.0.200:8080/").trim()
(localProperties.getProperty("petstore.backend.deviceUrl") ?: "https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io/").trim()
android {
namespace = "com.example.petstoremobile"

View File

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

View File

@@ -10,11 +10,19 @@ import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.dtos.ActivityLogDTO;
import com.example.petstoremobile.utils.DateTimeUtils;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class ActivityLogAdapter extends RecyclerView.Adapter<ActivityLogAdapter.ViewHolder> {
private static final String SEPARATOR = " | ";
private static final SimpleDateFormat INPUT_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault());
private static final SimpleDateFormat OUTPUT_FORMAT = new SimpleDateFormat("MMM d, HH:mm", Locale.getDefault());
private final List<ActivityLogDTO> items;
public ActivityLogAdapter(List<ActivityLogDTO> items) {
@@ -32,26 +40,77 @@ public class ActivityLogAdapter extends RecyclerView.Adapter<ActivityLogAdapter.
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
ActivityLogDTO log = items.get(position);
holder.tvActivity.setText(log.getActivity());
holder.tvUser.setText(log.getFullName() + " (" + log.getUsername() + ")");
holder.tvMeta.setText(log.getStoreName() + " · " + log.getRole());
String timestamp = log.getLogTimestamp();
String date = DateTimeUtils.extractDate(timestamp);
String time = (timestamp != null && timestamp.length() >= 16) ? timestamp.substring(11, 16) : null;
holder.tvTimestamp.setText(date != null && time != null ? date + " " + time : date);
String activity = log.getActivity() != null ? log.getActivity() : "";
int separatorIndex = activity.indexOf(SEPARATOR);
if (separatorIndex >= 0) {
holder.tvActivity.setText(activity.substring(0, separatorIndex).trim());
holder.tvTechnical.setText(activity.substring(separatorIndex + SEPARATOR.length()).trim());
holder.tvTechnical.setVisibility(View.VISIBLE);
} else {
holder.tvActivity.setText(activity);
holder.tvTechnical.setVisibility(View.GONE);
}
String fullName = firstNonBlank(log.getFullName(), log.getFullNameSnapshot(), "Unknown");
String username = firstNonBlank(log.getUsername(), log.getUsernameSnapshot(), "");
holder.tvUser.setText(username.isEmpty() ? fullName : fullName + " (" + username + ")");
String role = firstNonBlank(log.getRole(), log.getRoleSnapshot(), "");
String store = firstNonBlank(log.getStoreName(), log.getStoreNameSnapshot(), "");
if (!role.isEmpty() && !store.isEmpty()) {
holder.tvMeta.setText(store + " · " + formatRole(role));
} else if (!role.isEmpty()) {
holder.tvMeta.setText(formatRole(role));
} else if (!store.isEmpty()) {
holder.tvMeta.setText(store);
} else {
holder.tvMeta.setText("");
}
holder.tvTimestamp.setText(formatTimestamp(log.getLogTimestamp()));
}
@Override
public int getItemCount() { return items.size(); }
private String formatTimestamp(String raw) {
if (raw == null) return "";
try {
String normalized = raw.length() > 19 ? raw.substring(0, 19) : raw;
Date date = INPUT_FORMAT.parse(normalized);
return date != null ? OUTPUT_FORMAT.format(date) : raw.substring(0, Math.min(16, raw.length())).replace("T", " ");
} catch (ParseException e) {
return raw.length() >= 16 ? raw.substring(0, 16).replace("T", " ") : raw;
}
}
private String formatRole(String role) {
if (role == null) return "";
switch (role.toUpperCase(Locale.ROOT)) {
case "ADMIN": return "Admin";
case "STAFF": return "Staff";
case "CUSTOMER": return "Customer";
default: return role;
}
}
private String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return "";
}
public static class ViewHolder extends RecyclerView.ViewHolder {
TextView tvActivity, tvUser, tvMeta, tvTimestamp;
TextView tvActivity, tvTechnical, tvUser, tvMeta, tvTimestamp;
public ViewHolder(@NonNull View itemView) {
super(itemView);
tvActivity = itemView.findViewById(R.id.tvLogActivity);
tvUser = itemView.findViewById(R.id.tvLogUser);
tvMeta = itemView.findViewById(R.id.tvLogMeta);
tvActivity = itemView.findViewById(R.id.tvLogActivity);
tvTechnical = itemView.findViewById(R.id.tvLogTechnical);
tvUser = itemView.findViewById(R.id.tvLogUser);
tvMeta = itemView.findViewById(R.id.tvLogMeta);
tvTimestamp = itemView.findViewById(R.id.tvLogTimestamp);
}
}

View File

@@ -11,11 +11,15 @@ 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;
import com.example.petstoremobile.models.Message;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
@@ -28,6 +32,7 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
private final List<Message> messages;
private Long currentUserId;
private Long staffId;
private String token;
private String baseUrl;
private OnAttachmentClickListener attachmentClickListener;
@@ -35,6 +40,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) {
@@ -42,6 +54,11 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
notifyDataSetChanged();
}
public void setStaffId(Long id) {
this.staffId = id;
notifyDataSetChanged();
}
public void setToken(String token) {
this.token = token;
}
@@ -79,7 +96,7 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
Message m = messages.get(position);
if (holder instanceof SentHolder) ((SentHolder) holder).bind(m, token, baseUrl, attachmentClickListener);
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m, token, baseUrl, attachmentClickListener);
if (holder instanceof ReceivedHolder) ((ReceivedHolder) holder).bind(m, token, baseUrl, attachmentClickListener, staffId);
}
@Override public int getItemCount() { return messages.size(); }
@@ -91,17 +108,18 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
this.binding = binding;
}
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener) {
// Check for Text
binding.tvSenderName.setText("You");
binding.tvTimestamp.setText(formatTimestamp(m.getTimestamp()));
if (m.getContent() != null && !m.getContent().isEmpty()) {
binding.tvMessageContent.setVisibility(View.VISIBLE);
binding.tvMessageContent.setText(m.getContent());
} else {
binding.tvMessageContent.setVisibility(View.GONE);
}
// Check for Attachment
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
View.OnClickListener click = v -> {
if (listener != null) listener.onAttachmentClick(m);
};
@@ -116,8 +134,10 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
super(binding.getRoot());
this.binding = binding;
}
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener) {
// Check for Text
void bind(Message m, String token, String baseUrl, OnAttachmentClickListener listener, Long staffId) {
binding.tvSenderName.setText(resolveSenderName(m, staffId));
binding.tvTimestamp.setText(formatTimestamp(m.getTimestamp()));
if (m.getContent() != null && !m.getContent().isEmpty()) {
binding.tvMessageContent.setVisibility(View.VISIBLE);
binding.tvMessageContent.setText(m.getContent());
@@ -125,7 +145,6 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
binding.tvMessageContent.setVisibility(View.GONE);
}
// Check for Attachment
displayAttachment(m, binding.ivAttachment, binding.tvAttachmentName, token, baseUrl);
View.OnClickListener click = v -> {
@@ -136,7 +155,29 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
}
}
// helper function to display the attachment to the chat bubble
private static String resolveSenderName(Message m, Long staffId) {
if ("BOT".equalsIgnoreCase(m.getSenderRole())) {
return (m.getSenderDisplayName() != null && !m.getSenderDisplayName().isEmpty())
? m.getSenderDisplayName() : "AI Bot";
}
if (staffId != null && staffId.equals(m.getSenderId())) {
return "Staff";
}
return "Customer";
}
private static String formatTimestamp(String timestamp) {
if (timestamp == null || timestamp.isEmpty()) return "";
try {
String normalized = timestamp.length() > 19 ? timestamp.substring(0, 19) : timestamp;
SimpleDateFormat input = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault());
Date date = input.parse(normalized);
return new SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(date);
} catch (Exception e) {
return "";
}
}
private static void displayAttachment(Message m, ImageView iv, TextView tvName, String token, String baseUrl) {
// Check if there's an attachment by looking at name or mime type
if (m.getAttachmentName() != null || m.getAttachmentMimeType() != null) {
@@ -150,6 +191,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 +208,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);
}

View File

@@ -1,6 +1,5 @@
package com.example.petstoremobile.adapters;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
@@ -49,27 +48,6 @@ public class PurchaseOrderAdapter extends RecyclerView.Adapter<PurchaseOrderAdap
binding.tvPOStore.setText("Store: " + (po.getStoreName() != null ? po.getStoreName() : ""));
binding.tvPODate.setText("Date: " + (po.getOrderDate() != null ? po.getOrderDate() : ""));
String status = po.getStatus() != null ? po.getStatus() : "";
binding.tvPOStatus.setText(status);
switch (status.toUpperCase()) {
case "RECEIVED":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#4CAF50"));
break;
case "PLACED":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#2196F3"));
break;
case "PENDING":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#FF9800"));
break;
case "CANCELLED":
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#F44336"));
break;
default:
binding.tvPOStatus.setBackgroundColor(Color.parseColor("#9E9E9E"));
break;
}
holder.itemView.setOnClickListener(v -> listener.onPurchaseOrderClick(position));
}

View File

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

View File

@@ -15,6 +15,8 @@ public interface ActivityLogApi {
@Query("limit") int limit,
@Query("storeId") Long storeId,
@Query("role") String role,
@Query("search") String search
@Query("search") String search,
@Query("startDate") String startDate,
@Query("endDate") String endDate
);
}

View File

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

View File

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

View File

@@ -34,6 +34,12 @@ public class MessageDTO {
@SerializedName("attachmentSizeBytes")
private Long attachmentSizeBytes;
@SerializedName("senderRole")
private String senderRole;
@SerializedName("senderDisplayName")
private String senderDisplayName;
public MessageDTO() {}
public Long getId() { return id; }
@@ -65,4 +71,10 @@ public class MessageDTO {
public Long getAttachmentSizeBytes() { return attachmentSizeBytes; }
public void setAttachmentSizeBytes(Long attachmentSizeBytes) { this.attachmentSizeBytes = attachmentSizeBytes; }
public String getSenderRole() { return senderRole; }
public void setSenderRole(String senderRole) { this.senderRole = senderRole; }
public String getSenderDisplayName() { return senderDisplayName; }
public void setSenderDisplayName(String senderDisplayName) { this.senderDisplayName = senderDisplayName; }
}

View File

@@ -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();
});
@@ -356,6 +362,8 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
binding.tvChatTitle.setText(chat.getCustomerName());
binding.chatDrawerLayout.closeDrawer(GravityCompat.START);
messageAdapter.setStaffId(chat.getStaffId());
if (stompChatManager != null) stompChatManager.subscribeToConversation(activeConversationId);
viewModel.loadMessageHistory(activeConversationId);
}
@@ -464,9 +472,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
public void onMessageReceived(MessageDTO dto) {
requireActivity().runOnUiThread(() -> {
if (activeConversationId != null && activeConversationId.equals(dto.getConversationId())) {
if (!tokenManager.getUserId().equals(dto.getSenderId())) {
viewModel.addMessageLocally(dto);
}
viewModel.addMessageLocally(dto);
}
viewModel.updateConversationLocally(new ConversationDTO(dto.getConversationId(), 0L, 0L, dto.getContent(), ""));
});

View File

@@ -1,5 +1,6 @@
package com.example.petstoremobile.fragments.listfragments;
import android.app.DatePickerDialog;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -10,6 +11,7 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.widget.Toast;
@@ -27,6 +29,7 @@ import com.example.petstoremobile.viewmodels.ActivityLogListViewModel;
import javax.inject.Inject;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import dagger.hilt.android.AndroidEntryPoint;
@@ -38,6 +41,8 @@ public class ActivityLogFragment extends Fragment {
private ActivityLogAdapter adapter;
private final List<ActivityLogDTO> logList = new ArrayList<>();
private List<DropdownDTO> storeList = new ArrayList<>();
private String selectedStartDate = null;
private String selectedEndDate = null;
@Inject TokenManager tokenManager;
@@ -74,6 +79,22 @@ public class ActivityLogFragment extends Fragment {
adapter = new ActivityLogAdapter(logList);
binding.recyclerViewActivityLog.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewActivityLog.setAdapter(adapter);
binding.recyclerViewActivityLog.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewActivityLog.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
viewModel.loadLogs(false);
}
}
});
}
private void setupFilters() {
@@ -89,6 +110,35 @@ public class ActivityLogFragment extends Fragment {
? binding.spinnerRoleFilter.getSelectedItem().toString() : "All Roles"));
SpinnerUtils.setupFilterSpinner(binding.spinnerStoreFilter, this::onStoreSelected);
binding.btnStartDate.setOnClickListener(v -> showDatePicker(true));
binding.btnEndDate.setOnClickListener(v -> showDatePicker(false));
binding.btnClearDates.setOnClickListener(v -> {
selectedStartDate = null;
selectedEndDate = null;
binding.btnStartDate.setText("Start Date");
binding.btnEndDate.setText("End Date");
binding.btnClearDates.setVisibility(View.GONE);
viewModel.setDateRange(null, null);
});
}
private void showDatePicker(boolean isStart) {
Calendar cal = Calendar.getInstance();
new DatePickerDialog(requireContext(), (view, year, month, day) -> {
String date = String.format("%04d-%02d-%02d", year, month + 1, day);
String label = String.format("%02d/%02d/%04d", day, month + 1, year);
if (isStart) {
selectedStartDate = date;
binding.btnStartDate.setText(label);
} else {
selectedEndDate = date;
binding.btnEndDate.setText(label);
}
binding.btnClearDates.setVisibility(
selectedStartDate != null || selectedEndDate != null ? View.VISIBLE : View.GONE);
viewModel.setDateRange(selectedStartDate, selectedEndDate);
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show();
}
private void onStoreSelected() {

View File

@@ -14,6 +14,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.AdoptionAdapter;
@@ -114,14 +115,14 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
adapter,
"adoption",
viewModel::bulkDeleteAdoptions,
this::loadAdoptions
() -> loadAdoptions(true)
);
}
@Override
public void onResume() {
super.onResume();
loadAdoptions();
loadAdoptions(true);
if (!isStaff()) viewModel.loadStores();
}
@@ -159,7 +160,7 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
} else {
selectedCalendarDay = null;
}
loadAdoptions();
loadAdoptions(true);
});
}
@@ -187,26 +188,42 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
adapter = new AdoptionAdapter(adoptionList, this);
binding.recyclerViewAdoptions.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewAdoptions.setAdapter(adapter);
binding.recyclerViewAdoptions.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewAdoptions.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadAdoptions(false);
}
}
});
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchAdoption, this::loadAdoptions);
UIUtils.attachSearch(binding.etSearchAdoption, () -> loadAdoptions(true));
}
private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Completed", "Pending", "Cancelled"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusAdoption, statuses, this::loadAdoptions);
String[] statuses = {"All Statuses", "Completed", "Pending", "Missed", "Cancelled"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusAdoption, statuses, () -> loadAdoptions(true));
}
private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStoreAdoption, this::loadAdoptions);
SpinnerUtils.setupFilterSpinner(binding.spinnerStoreAdoption, () -> loadAdoptions(true));
}
private void setupSwipeRefresh() {
binding.swipeRefreshAdoption.setOnRefreshListener(this::loadAdoptions);
binding.swipeRefreshAdoption.setOnRefreshListener(() -> loadAdoptions(true));
}
private void loadAdoptions() {
private void loadAdoptions(boolean reset) {
String query = binding.etSearchAdoption.getText().toString().trim();
String status = binding.spinnerStatusAdoption.getSelectedItem() != null ? binding.spinnerStatusAdoption.getSelectedItem().toString() : "All Statuses";
@@ -230,7 +247,7 @@ public class AdoptionFragment extends Fragment implements AdoptionAdapter.OnAdop
if (status.equals("All Statuses")) status = null;
else status = status.toUpperCase();
viewModel.loadAdoptions(true, query, status, storeId, selectedDateString, null);
viewModel.loadAdoptions(reset, query, status, storeId, selectedDateString, null);
}
private void openDetail(int position) {

View File

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

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.Log;
import android.view.LayoutInflater;
@@ -123,14 +124,14 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
adapter,
"appointment",
viewModel::bulkDeleteAppointments,
this::loadAppointmentData
() -> loadAppointmentData(true)
);
}
@Override
public void onResume() {
super.onResume();
loadAppointmentData();
loadAppointmentData(true);
if (!isStaff()) viewModel.loadStores();
}
@@ -143,7 +144,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
private void setupMyAppointmentFilter() {
binding.btnMyAppointments.setOnClickListener(v -> {
loadAppointmentData();
loadAppointmentData(true);
});
}
@@ -177,7 +178,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
} else {
selectedCalendarDay = null;
}
loadAppointmentData();
loadAppointmentData(true);
});
}
@@ -200,20 +201,20 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchAppointment, this::loadAppointmentData);
UIUtils.attachSearch(binding.etSearchAppointment, () -> loadAppointmentData(true));
}
private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Booked", "Completed", "Cancelled", "Missed"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, this::loadAppointmentData);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, () -> loadAppointmentData(true));
}
private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadAppointmentData);
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadAppointmentData(true));
}
private void setupSwipeRefresh() {
binding.swipeRefreshAppointment.setOnRefreshListener(this::loadAppointmentData);
binding.swipeRefreshAppointment.setOnRefreshListener(() -> loadAppointmentData(true));
}
private void openAppointmentDetails(int position) {
@@ -241,7 +242,7 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
return "STAFF".equalsIgnoreCase(tokenManager.getRole());
}
private void loadAppointmentData() {
private void loadAppointmentData(boolean reset) {
String query = binding.etSearchAppointment.getText().toString().trim();
String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses";
@@ -270,13 +271,29 @@ public class AppointmentFragment extends Fragment implements AppointmentAdapter.
if (status.equals("All Statuses")) status = null;
else status = status.toUpperCase();
viewModel.loadAppointments(query, status, storeId, selectedDateString, employeeId);
viewModel.loadAppointments(reset, query, status, storeId, selectedDateString, employeeId);
}
private void setupRecyclerView() {
adapter = new AppointmentAdapter(appointmentList, this);
binding.recyclerViewAppointments.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewAppointments.setAdapter(adapter);
binding.recyclerViewAppointments.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewAppointments.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadAppointmentData(false);
}
}
});
}
@Override

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.CouponAdapter;
@@ -46,7 +47,7 @@ public class CouponFragment extends Fragment implements CouponAdapter.OnCouponCl
setupSwipeRefresh();
observeViewModel();
viewModel.loadCoupons(0, 100, null, null, null);
applyFilters(true);
binding.fabAddCoupon.setOnClickListener(v -> openDetail(-1));
binding.btnBulkDeleteCoupons.setOnClickListener(v -> confirmBulkDelete());
@@ -74,38 +75,54 @@ public class CouponFragment extends Fragment implements CouponAdapter.OnCouponCl
adapter = new CouponAdapter(couponList, this);
binding.recyclerViewCoupon.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewCoupon.setAdapter(adapter);
binding.recyclerViewCoupon.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewCoupon.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
applyFilters(false);
}
}
});
}
private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Active", "Inactive"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusCoupon, statuses, this::applyFilters);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatusCoupon, statuses, () -> applyFilters(true));
}
private void setupTypeFilter() {
String[] types = {"All Types", "FIXED", "PERCENT"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerTypeCoupon, types, this::applyFilters);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerTypeCoupon, types, () -> applyFilters(true));
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchCoupon, this::applyFilters);
UIUtils.attachSearch(binding.etSearchCoupon, () -> applyFilters(true));
}
private void setupSwipeRefresh() {
binding.swipeRefreshCoupon.setOnRefreshListener(this::applyFilters);
binding.swipeRefreshCoupon.setOnRefreshListener(() -> applyFilters(true));
}
private void applyFilters() {
String statusStr = binding.spinnerStatusCoupon.getSelectedItem() != null ?
private void applyFilters(boolean reset) {
String statusStr = binding.spinnerStatusCoupon.getSelectedItem() != null ?
binding.spinnerStatusCoupon.getSelectedItem().toString() : "All Statuses";
Boolean active = null;
if (statusStr.equals("Active")) active = true;
else if (statusStr.equals("Inactive")) active = false;
String typeStr = binding.spinnerTypeCoupon.getSelectedItem() != null ?
String typeStr = binding.spinnerTypeCoupon.getSelectedItem() != null ?
binding.spinnerTypeCoupon.getSelectedItem().toString() : "All Types";
String discountType = typeStr.equals("All Types") ? null : typeStr;
viewModel.loadCoupons(0, 100, active, discountType, null);
viewModel.loadCoupons(reset, active, discountType, null);
}
private void openDetail(long id) {
@@ -133,7 +150,7 @@ public class CouponFragment extends Fragment implements CouponAdapter.OnCouponCl
viewModel.bulkDeleteCoupons(ids).observe(getViewLifecycleOwner(), resource -> {
if (resource.status == Resource.Status.SUCCESS) {
adapter.setSelectionMode(false);
applyFilters();
applyFilters(true);
} else if (resource.status == Resource.Status.ERROR) {
UIUtils.showToast(requireContext(), resource.message);
}

View File

@@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.CustomerAdapter;
import com.example.petstoremobile.api.auth.TokenManager;
@@ -44,7 +45,7 @@ public class CustomerFragment extends Fragment implements CustomerAdapter.OnCust
setupSwipeRefresh();
observeViewModel();
viewModel.loadCustomers();
viewModel.loadCustomers(true);
binding.fabAddCustomer.setOnClickListener(v -> openDetail(-1));
@@ -73,6 +74,22 @@ public class CustomerFragment extends Fragment implements CustomerAdapter.OnCust
adapter.setToken(tokenManager.getToken());
binding.recyclerViewCustomer.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewCustomer.setAdapter(adapter);
binding.recyclerViewCustomer.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewCustomer.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
viewModel.loadCustomers(false);
}
}
});
}
private void setupStatusFilter() {
@@ -92,7 +109,7 @@ public class CustomerFragment extends Fragment implements CustomerAdapter.OnCust
}
private void setupSwipeRefresh() {
binding.swipeRefreshCustomer.setOnRefreshListener(viewModel::loadCustomers);
binding.swipeRefreshCustomer.setOnRefreshListener(() -> viewModel.loadCustomers(true));
}
private void openDetail(int position) {

View File

@@ -8,11 +8,11 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.PetAdapter;
@@ -21,7 +21,6 @@ import com.example.petstoremobile.databinding.FragmentPetBinding;
import com.example.petstoremobile.dtos.PetDTO;
import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.utils.BulkDeleteHandler;
import com.example.petstoremobile.utils.Resource;
import com.example.petstoremobile.utils.SpinnerUtils;
import com.example.petstoremobile.utils.UIUtils;
import com.example.petstoremobile.viewmodels.PetListViewModel;
@@ -91,7 +90,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
viewModel.getSpeciesOptions().observe(getViewLifecycleOwner(), options -> {
String[] arr = options.toArray(new String[0]);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, arr, this::loadPetData);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, arr, () -> loadPetData(true));
});
}
@@ -104,14 +103,14 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
adapter,
"pet",
viewModel::bulkDeletePets,
this::loadPetData
() -> loadPetData(true)
);
}
@Override
public void onResume() {
super.onResume();
loadPetData();
loadPetData(true);
viewModel.loadSpecies();
if (!isStaff()) viewModel.loadStores();
}
@@ -132,28 +131,28 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchPet, this::loadPetData);
UIUtils.attachSearch(binding.etSearchPet, () -> loadPetData(true));
}
private void setupStatusFilter() {
String[] statuses = {"All Statuses", "Available", "Adopted", "Owned", "Pending"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, this::loadPetData);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerStatus, statuses, () -> loadPetData(true));
}
private void setupSpeciesFilter() {
String[] initial = {"All Species"};
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, initial, this::loadPetData);
SpinnerUtils.setupStringFilterSpinner(requireContext(), binding.spinnerSpecies, initial, () -> loadPetData(true));
}
private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadPetData);
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadPetData(true));
}
private void setupSwipeRefresh() {
binding.swipeRefreshPet.setOnRefreshListener(this::loadPetData);
binding.swipeRefreshPet.setOnRefreshListener(() -> loadPetData(true));
}
private void loadPetData() {
private void loadPetData(boolean reset) {
String query = binding.etSearchPet.getText().toString().trim();
String status = binding.spinnerStatus.getSelectedItem() != null ? binding.spinnerStatus.getSelectedItem().toString() : "All Statuses";
String species = binding.spinnerSpecies.getSelectedItem() != null ? binding.spinnerSpecies.getSelectedItem().toString() : "All Species";
@@ -169,7 +168,7 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
}
}
viewModel.loadPets(query, status, species, storeId);
viewModel.loadPets(reset, query, status, species, storeId);
}
private void setupRecyclerView() {
@@ -178,6 +177,22 @@ public class PetFragment extends Fragment implements PetAdapter.OnPetClickListen
adapter.setToken(tokenManager.getToken());
binding.recyclerViewPets.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewPets.setAdapter(adapter);
binding.recyclerViewPets.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewPets.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadPetData(false);
}
}
});
}
private void openPetProfile(int position) {

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
@@ -85,28 +86,28 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc
@Override
public void onResume() {
super.onResume();
loadProductData();
loadProductData(true);
viewModel.loadCategories();
}
private void setupFilterToggle() {
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter,
UIUtils.setupFilterToggle(binding.btnToggleFilter, binding.layoutFilter,
binding.etSearchProduct, binding.spinnerCategory);
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchProduct, this::loadProductData);
UIUtils.attachSearch(binding.etSearchProduct, () -> loadProductData(true));
}
private void setupCategoryFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerCategory, this::loadProductData);
SpinnerUtils.setupFilterSpinner(binding.spinnerCategory, () -> loadProductData(true));
}
private void setupSwipeRefresh() {
binding.swipeRefreshProduct.setOnRefreshListener(this::loadProductData);
binding.swipeRefreshProduct.setOnRefreshListener(() -> loadProductData(true));
}
private void loadProductData() {
private void loadProductData(boolean reset) {
String query = binding.etSearchProduct.getText().toString().trim();
if (query.isEmpty()) query = null;
@@ -116,7 +117,7 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc
categoryId = categories.get(binding.spinnerCategory.getSelectedItemPosition() - 1).getId();
}
viewModel.loadProducts(query, categoryId);
viewModel.loadProducts(reset, query, categoryId);
}
private void setupRecyclerView() {
@@ -124,6 +125,22 @@ public class ProductFragment extends Fragment implements ProductAdapter.OnProduc
adapter.setBaseUrl(baseUrl);
binding.recyclerViewProducts.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewProducts.setAdapter(adapter);
binding.recyclerViewProducts.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewProducts.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadProductData(false);
}
}
});
}
private void openProductDetails(int position) {

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.ProductSupplierAdapter;
@@ -34,7 +35,7 @@ public class ProductSupplierFragment extends Fragment
private FragmentProductSupplierBinding binding;
private List<ProductSupplierDTO> psList = new ArrayList<>();
private ProductSupplierAdapter adapter;
private ProductSupplierListViewModel viewModel;
private BulkDeleteHandler bulkDeleteHandler;
@@ -97,14 +98,14 @@ public class ProductSupplierFragment extends Fragment
adapter,
"relationship",
viewModel::bulkDeleteProductSuppliers,
this::loadData
() -> loadData(true)
);
}
@Override
public void onResume() {
super.onResume();
loadData();
loadData(true);
viewModel.loadFilterData();
}
@@ -123,25 +124,41 @@ public class ProductSupplierFragment extends Fragment
adapter = new ProductSupplierAdapter(psList, this);
binding.recyclerViewPS.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewPS.setAdapter(adapter);
binding.recyclerViewPS.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewPS.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadData(false);
}
}
});
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchPS, this::loadData);
UIUtils.attachSearch(binding.etSearchPS, () -> loadData(true));
}
private void setupProductFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerProduct, this::loadData);
SpinnerUtils.setupFilterSpinner(binding.spinnerProduct, () -> loadData(true));
}
private void setupSupplierFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerSupplier, this::loadData);
SpinnerUtils.setupFilterSpinner(binding.spinnerSupplier, () -> loadData(true));
}
private void setupSwipeRefresh() {
binding.swipeRefreshPS.setOnRefreshListener(this::loadData);
binding.swipeRefreshPS.setOnRefreshListener(() -> loadData(true));
}
private void loadData() {
private void loadData(boolean reset) {
String query = binding.etSearchPS.getText().toString().trim();
if (query.isEmpty()) query = null;
@@ -157,7 +174,7 @@ public class ProductSupplierFragment extends Fragment
supplierId = suppliers.get(binding.spinnerSupplier.getSelectedItemPosition() - 1).getSupId();
}
viewModel.loadProductSuppliers(query, productId, supplierId);
viewModel.loadProductSuppliers(reset, query, productId, supplierId);
}
private void openDetail(int position) {

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.PurchaseOrderAdapter;
@@ -83,7 +84,7 @@ public class PurchaseOrderFragment extends Fragment
@Override
public void onResume() {
super.onResume();
loadData();
loadData(true);
if (!isStaff()) viewModel.loadStores();
}
@@ -101,24 +102,40 @@ public class PurchaseOrderFragment extends Fragment
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchPO, this::loadData);
UIUtils.attachSearch(binding.etSearchPO, () -> loadData(true));
}
private void setupStoreFilter() {
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, this::loadData);
SpinnerUtils.setupFilterSpinner(binding.spinnerStore, () -> loadData(true));
}
private void setupRecyclerView() {
adapter = new PurchaseOrderAdapter(poList, this);
binding.recyclerViewPO.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewPO.setAdapter(adapter);
binding.recyclerViewPO.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewPO.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadData(false);
}
}
});
}
private void setupSwipeRefresh() {
binding.swipeRefreshPO.setOnRefreshListener(this::loadData);
binding.swipeRefreshPO.setOnRefreshListener(() -> loadData(true));
}
private void loadData() {
private void loadData(boolean reset) {
String query = binding.etSearchPO != null ? binding.etSearchPO.getText().toString().trim() : "";
if (query.isEmpty()) query = null;
@@ -133,7 +150,7 @@ public class PurchaseOrderFragment extends Fragment
}
}
viewModel.loadPurchaseOrders(query, storeId);
viewModel.loadPurchaseOrders(reset, query, storeId);
}
private void openDetail(int position) {

View File

@@ -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,10 +58,11 @@ public class SaleFragment extends Fragment implements SaleAdapter.OnSaleClickLis
setupStoreFilter();
setupPaymentMethodFilter();
setupRefundStatusFilter();
setupCustomerFilter();
setupSwipeRefresh();
setupFilterToggle();
observeViewModel();
loadSales(true);
UIUtils.setupHamburgerMenu(binding.btnHamburger, this);
@@ -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

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.example.petstoremobile.R;
import com.example.petstoremobile.adapters.EmployeeAdapter;
import com.example.petstoremobile.api.auth.TokenManager;
@@ -39,15 +40,15 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
Bundle savedInstanceState) {
binding = FragmentStaffBinding.inflate(inflater, container, false);
viewModel = new ViewModelProvider(this).get(StaffListViewModel.class);
setupRecyclerView();
setupSearch();
setupStatusFilter();
setupStoreFilter();
setupSwipeRefresh();
observeViewModel();
viewModel.loadStaff();
viewModel.loadStaff(true);
viewModel.loadStores();
binding.fabAddStaff.setOnClickListener(v -> openDetail(-1));
@@ -82,6 +83,22 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
adapter.setToken(tokenManager.getToken());
binding.recyclerViewStaff.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewStaff.setAdapter(adapter);
binding.recyclerViewStaff.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewStaff.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
viewModel.loadStaff(false);
}
}
});
}
private void setupStatusFilter() {
@@ -99,9 +116,9 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
private void applyFilters() {
String query = binding.etSearchStaff.getText().toString().trim();
String status = binding.spinnerStatusStaff.getSelectedItem() != null ?
String status = binding.spinnerStatusStaff.getSelectedItem() != null ?
binding.spinnerStatusStaff.getSelectedItem().toString() : "All Statuses";
Long storeId = null;
List<StoreDTO> stores = viewModel.getStores().getValue();
if (binding.spinnerStoreStaff.getSelectedItemPosition() > 0 && stores != null && !stores.isEmpty()) {
@@ -112,7 +129,7 @@ public class StaffFragment extends Fragment implements EmployeeAdapter.OnEmploye
}
private void setupSwipeRefresh() {
binding.swipeRefreshStaff.setOnRefreshListener(viewModel::loadStaff);
binding.swipeRefreshStaff.setOnRefreshListener(() -> viewModel.loadStaff(true));
}
private void openDetail(int position) {

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.fragment.NavHostFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
@@ -52,8 +53,8 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
setupFilterToggle();
setupBulkDelete();
observeViewModel();
loadSupplierData();
loadSupplierData(true);
binding.fabAddSupplier.setOnClickListener(v -> openSupplierDetails(-1));
@@ -83,7 +84,7 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
adapter,
"supplier",
viewModel::bulkDeleteSuppliers,
this::loadSupplierData
() -> loadSupplierData(true)
);
}
@@ -98,11 +99,11 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
}
private void setupSearch() {
UIUtils.attachSearch(binding.etSearchSupplier, this::loadSupplierData);
UIUtils.attachSearch(binding.etSearchSupplier, () -> loadSupplierData(true));
}
private void setupSwipeRefresh() {
binding.swipeRefreshSupplier.setOnRefreshListener(this::loadSupplierData);
binding.swipeRefreshSupplier.setOnRefreshListener(() -> loadSupplierData(true));
}
private void openSupplierDetails(int position) {
@@ -126,15 +127,31 @@ public class SupplierFragment extends Fragment implements SupplierAdapter.OnSupp
}
}
private void loadSupplierData() {
private void loadSupplierData(boolean reset) {
String query = binding.etSearchSupplier != null ? binding.etSearchSupplier.getText().toString().trim() : "";
if (query.isEmpty()) query = null;
viewModel.loadSuppliers(query);
viewModel.loadSuppliers(reset, query);
}
private void setupRecyclerView() {
adapter = new SupplierAdapter(supplierList, this);
binding.recyclerViewSuppliers.setLayoutManager(new LinearLayoutManager(getContext()));
binding.recyclerViewSuppliers.setAdapter(adapter);
binding.recyclerViewSuppliers.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (dy <= 0) return;
LinearLayoutManager lm = (LinearLayoutManager) binding.recyclerViewSuppliers.getLayoutManager();
if (lm == null) return;
int visible = lm.getChildCount();
int total = lm.getItemCount();
int firstVis = lm.findFirstVisibleItemPosition();
Boolean isLoading = viewModel.getIsLoading().getValue();
if ((isLoading == null || !isLoading) && !viewModel.isLastPage() && (visible + firstVis) >= total - 3) {
loadSupplierData(false);
}
}
});
}
}

View File

@@ -114,9 +114,18 @@ public class ProductSupplierDetailFragment extends Fragment {
viewModel.setEditMode(productId, supplierId);
preselectedProductId = productId;
preselectedSupplierId = supplierId;
binding.tvPSMode.setText("Edit Product Supplier");
binding.btnDeletePS.setVisibility(View.VISIBLE);
viewModel.loadProductSupplier().observe(getViewLifecycleOwner(), resource -> {
if (resource == null) return;
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
if (resource.data.getCost() != null) {
binding.etPSCost.setText(resource.data.getCost().toPlainString());
}
}
});
} else {
binding.tvPSMode.setText("Add Product Supplier");
binding.btnDeletePS.setVisibility(View.GONE);

View File

@@ -1,6 +1,5 @@
package com.example.petstoremobile.fragments.listfragments.detailfragments;
import android.graphics.Color;
import android.os.Bundle;
import android.view.*;
import android.widget.Toast;
@@ -82,26 +81,6 @@ public class PurchaseOrderDetailFragment extends Fragment {
binding.tvPODetailSupplier.setText(po.getSupplierName());
binding.tvPODetailStore.setText(po.getStoreName() != null ? po.getStoreName() : "N/A");
binding.tvPODetailDate.setText(po.getOrderDate());
String status = po.getStatus() != null ? po.getStatus() : "";
binding.tvPODetailStatus.setText(status);
switch (status.toUpperCase()) {
case "RECEIVED":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#4CAF50"));
break;
case "PLACED":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#2196F3"));
break;
case "PENDING":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#FF9800"));
break;
case "CANCELLED":
binding.tvPODetailStatus.setTextColor(Color.parseColor("#F44336"));
break;
default:
binding.tvPODetailStatus.setTextColor(Color.parseColor("#9E9E9E"));
break;
}
} else if (resource.status == Resource.Status.ERROR) {
Toast.makeText(getContext(), "Failed to load order: " + resource.message, Toast.LENGTH_SHORT).show();
}

View File

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

View File

@@ -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;
@@ -131,17 +125,35 @@ 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;
if (!InputValidator.isNotEmpty(binding.etStaffLastName, "Last Name")) return;
if (!InputValidator.isValidEmail(binding.etStaffEmail)) 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();

View File

@@ -11,6 +11,8 @@ public class Message {
private String attachmentName;
private String attachmentMimeType;
private Long attachmentSizeBytes;
private String senderRole;
private String senderDisplayName;
public Message() {}
@@ -49,4 +51,10 @@ public class Message {
public Long getAttachmentSizeBytes() { return attachmentSizeBytes; }
public void setAttachmentSizeBytes(Long attachmentSizeBytes) { this.attachmentSizeBytes = attachmentSizeBytes; }
public String getSenderRole() { return senderRole; }
public void setSenderRole(String senderRole) { this.senderRole = senderRole; }
public String getSenderDisplayName() { return senderDisplayName; }
public void setSenderDisplayName(String senderDisplayName) { this.senderDisplayName = senderDisplayName; }
}

View File

@@ -21,7 +21,7 @@ public class ActivityLogRepository extends BaseRepository {
this.activityLogApi = activityLogApi;
}
public LiveData<Resource<List<ActivityLogDTO>>> getActivityLogs(int limit, Long storeId, String role, String search) {
return executeCall(activityLogApi.getActivityLogs(limit, storeId, role, search));
public LiveData<Resource<List<ActivityLogDTO>>> getActivityLogs(int limit, Long storeId, String role, String search, String startDate, String endDate) {
return executeCall(activityLogApi.getActivityLogs(limit, storeId, role, search, startDate, endDate));
}
}

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
@HiltViewModel
public class ActivityLogListViewModel extends ViewModel {
private static final int LIMIT = 2000;
private static final int PAGE_SIZE = 20;
private final ActivityLogRepository repository;
private final StoreRepository storeRepository;
@@ -32,6 +32,11 @@ public class ActivityLogListViewModel extends ViewModel {
private Long currentStoreId = null;
private String currentRole = null;
private String currentSearch = null;
private String currentStartDate = null;
private String currentEndDate = null;
private int currentLimit = PAGE_SIZE;
private boolean isLastPage = false;
@Inject
public ActivityLogListViewModel(ActivityLogRepository repository, StoreRepository storeRepository) {
@@ -42,10 +47,11 @@ public class ActivityLogListViewModel extends ViewModel {
public LiveData<List<ActivityLogDTO>> getLogs() { return logs; }
public LiveData<List<DropdownDTO>> getStoreOptions() { return storeOptions; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadInitialData() {
loadStores();
loadLogs();
loadLogs(true);
}
private void loadStores() {
@@ -56,12 +62,24 @@ public class ActivityLogListViewModel extends ViewModel {
});
}
public void loadLogs() {
public void loadLogs(boolean reset) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentLimit = PAGE_SIZE;
isLastPage = false;
}
if (isLastPage) return;
isLoading.setValue(true);
observeOnce(repository.getActivityLogs(LIMIT, currentStoreId, currentRole, currentSearch), resource -> {
observeOnce(repository.getActivityLogs(currentLimit, currentStoreId, currentRole, currentSearch, currentStartDate, currentEndDate), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
logs.setValue(resource.data);
List<ActivityLogDTO> result = resource.data;
logs.setValue(result);
isLastPage = result.size() < currentLimit;
if (!isLastPage) currentLimit += PAGE_SIZE;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);
@@ -72,19 +90,26 @@ public class ActivityLogListViewModel extends ViewModel {
public void setRoleFilter(String role) {
currentRole = "All Roles".equals(role) ? null : role;
loadLogs();
loadLogs(true);
}
public void setStoreFilter(Long storeId) {
currentStoreId = storeId;
loadLogs();
loadLogs(true);
}
public void setSearchQuery(String query) {
currentSearch = (query == null || query.trim().isEmpty()) ? null : query.trim();
loadLogs();
loadLogs(true);
}
public void setDateRange(String startDate, String endDate) {
currentStartDate = startDate;
currentEndDate = endDate;
loadLogs(true);
}
private <T> void observeOnce(LiveData<Resource<T>> liveData, Observer<Resource<T>> handler) {
liveData.observeForever(new Observer<Resource<T>>() {
@Override

View File

@@ -52,6 +52,8 @@ public class AdoptionListViewModel extends ViewModel {
isLastPage = false;
}
if (isLastPage) return;
if ("All Statuses".equals(status)) status = null;
isLoading.setValue(true);

View File

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

View File

@@ -29,6 +29,10 @@ public class AppointmentListViewModel extends ViewModel {
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject
public AppointmentListViewModel(AppointmentRepository appointmentRepository, StoreRepository storeRepository) {
this.appointmentRepository = appointmentRepository;
@@ -38,13 +42,27 @@ public class AppointmentListViewModel extends ViewModel {
public LiveData<List<AppointmentDTO>> getAppointments() { return appointments; }
public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadAppointments(boolean reset, String query, String status, Long storeId, String date, Long employeeId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadAppointments(String query, String status, Long storeId, String date, Long employeeId) {
isLoading.setValue(true);
observeOnce(appointmentRepository.getAllAppointments(0, 500, query, status, storeId, date, employeeId, "appointmentId,desc"), resource -> {
observeOnce(appointmentRepository.getAllAppointments(currentPage, PAGE_SIZE, query, status, storeId, date, employeeId, "appointmentId,desc"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
appointments.setValue(resource.data.getContent());
List<AppointmentDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(appointments.getValue());
currentList.addAll(resource.data.getContent());
appointments.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);

View File

@@ -133,6 +133,11 @@ public class ChatListViewModel extends ViewModel {
public void addMessageLocally(MessageDTO dto) {
List<Message> current = new ArrayList<>(messageList.getValue());
if (dto.getId() != null) {
for (Message m : current) {
if (dto.getId().equals(m.getId())) return;
}
}
current.add(dtoToModel(dto));
messageList.setValue(current);
}
@@ -168,6 +173,8 @@ public class ChatListViewModel extends ViewModel {
m.setIsRead(dto.getIsRead());
m.setAttachmentUrl(dto.getAttachmentUrl());
m.setAttachmentName(dto.getAttachmentName());
m.setSenderRole(dto.getSenderRole());
m.setSenderDisplayName(dto.getSenderDisplayName());
m.setAttachmentMimeType(dto.getAttachmentMimeType());
m.setAttachmentSizeBytes(dto.getAttachmentSizeBytes());
return m;

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.dtos.CouponDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.repositories.CouponRepository;
import com.example.petstoremobile.utils.Resource;
@@ -24,6 +23,10 @@ public class CouponListViewModel extends ViewModel {
private final MutableLiveData<List<CouponDTO>> coupons = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject
public CouponListViewModel(CouponRepository repository) {
this.repository = repository;
@@ -31,13 +34,27 @@ public class CouponListViewModel extends ViewModel {
public LiveData<List<CouponDTO>> getCoupons() { return coupons; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadCoupons(boolean reset, Boolean active, String discountType, String sort) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadCoupons(int page, int size, Boolean active, String discountType, String sort) {
isLoading.setValue(true);
observeOnce(repository.getAllCoupons(page, size, active, discountType, sort), resource -> {
observeOnce(repository.getAllCoupons(currentPage, PAGE_SIZE, active, discountType, sort), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
coupons.setValue(resource.data.getContent());
List<CouponDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(coupons.getValue());
currentList.addAll(resource.data.getContent());
coupons.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);

View File

@@ -25,6 +25,10 @@ public class CustomerListViewModel extends ViewModel {
private final MutableLiveData<List<CustomerDTO>> filteredCustomers = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
private String lastQuery = "";
private String lastStatus = "All Statuses";
@@ -35,14 +39,28 @@ public class CustomerListViewModel extends ViewModel {
public LiveData<List<CustomerDTO>> getFilteredCustomers() { return filteredCustomers; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadCustomers(boolean reset) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadCustomers() {
isLoading.setValue(true);
observeOnce(repository.getAllCustomers(0, 200), resource -> {
observeOnce(repository.getAllCustomers(currentPage, PAGE_SIZE), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
customers.setValue(resource.data.getContent());
filter(lastQuery, lastStatus);
List<CustomerDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(customers.getValue());
currentList.addAll(resource.data.getContent());
customers.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
applyFilter(currentList);
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);
@@ -66,23 +84,25 @@ public class CustomerListViewModel extends ViewModel {
public void filter(String query, String status) {
this.lastQuery = query;
this.lastStatus = status;
applyFilter(customers.getValue());
}
List<CustomerDTO> all = customers.getValue();
private void applyFilter(List<CustomerDTO> all) {
if (all == null) return;
List<CustomerDTO> filtered = new ArrayList<>();
String lowerQuery = query.toLowerCase();
String lowerQuery = lastQuery.toLowerCase();
for (CustomerDTO c : all) {
boolean matchesQuery = query.isEmpty() ||
boolean matchesQuery = lastQuery.isEmpty() ||
(c.getFullName() != null && c.getFullName().toLowerCase().contains(lowerQuery)) ||
(c.getUsername() != null && c.getUsername().toLowerCase().contains(lowerQuery)) ||
(c.getEmail() != null && c.getEmail().toLowerCase().contains(lowerQuery)) ||
(c.getPhone() != null && c.getPhone().toLowerCase().contains(lowerQuery));
boolean matchesStatus = status.equals("All Statuses") ||
(status.equals("Active") && Boolean.TRUE.equals(c.getActive())) ||
(status.equals("Inactive") && Boolean.FALSE.equals(c.getActive()));
boolean matchesStatus = lastStatus.equals("All Statuses") ||
(lastStatus.equals("Active") && Boolean.TRUE.equals(c.getActive())) ||
(lastStatus.equals("Inactive") && Boolean.FALSE.equals(c.getActive()));
if (matchesQuery && matchesStatus) {
filtered.add(c);

View File

@@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModel;
import com.example.petstoremobile.dtos.BulkDeleteRequest;
import com.example.petstoremobile.dtos.DropdownDTO;
import com.example.petstoremobile.dtos.PageResponse;
import com.example.petstoremobile.dtos.PetDTO;
import com.example.petstoremobile.dtos.StoreDTO;
import com.example.petstoremobile.repositories.PetRepository;
@@ -32,6 +31,10 @@ public class PetListViewModel extends ViewModel {
private final MutableLiveData<List<String>> speciesOptions = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject
public PetListViewModel(PetRepository petRepository, StoreRepository storeRepository) {
this.petRepository = petRepository;
@@ -42,16 +45,32 @@ public class PetListViewModel extends ViewModel {
public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<List<String>> getSpeciesOptions() { return speciesOptions; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadPets(boolean reset, String query, String status, String species, Long storeId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadPets(String query, String status, String species, Long storeId) {
if ("All Statuses".equals(status)) status = null;
if ("All Species".equals(species)) species = null;
isLoading.setValue(true);
observeOnce(petRepository.getAllPets(0, 100, query, status, species, storeId, null, "petName"), resource -> {
final String finalStatus = status;
final String finalSpecies = species;
observeOnce(petRepository.getAllPets(currentPage, PAGE_SIZE, query, finalStatus, finalSpecies, storeId, null, "petName"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
pets.setValue(resource.data.getContent());
List<PetDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(pets.getValue());
currentList.addAll(resource.data.getContent());
pets.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);

View File

@@ -28,6 +28,10 @@ public class ProductListViewModel extends ViewModel {
private final MutableLiveData<List<DropdownDTO>> categories = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject
public ProductListViewModel(ProductRepository productRepository, CategoryRepository categoryRepository) {
this.productRepository = productRepository;
@@ -37,13 +41,27 @@ public class ProductListViewModel extends ViewModel {
public LiveData<List<ProductDTO>> getProducts() { return products; }
public LiveData<List<DropdownDTO>> getCategories() { return categories; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadProducts(boolean reset, String query, Long categoryId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadProducts(String query, Long categoryId) {
isLoading.setValue(true);
observeOnce(productRepository.getAllProducts(query, categoryId, 0, 100, "prodName"), resource -> {
observeOnce(productRepository.getAllProducts(query, categoryId, currentPage, PAGE_SIZE, "prodName"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
products.setValue(resource.data.getContent());
List<ProductDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(products.getValue());
currentList.addAll(resource.data.getContent());
products.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);

View File

@@ -46,6 +46,10 @@ public class ProductSupplierDetailViewModel extends ViewModel {
this.editSupplierId = supplierId;
}
public LiveData<Resource<ProductSupplierDTO>> loadProductSupplier() {
return psRepository.getProductSupplierById(editProductId, editSupplierId);
}
public boolean isEditing() { return isEditing; }
public long getEditProductId() { return editProductId; }
public long getEditSupplierId() { return editSupplierId; }

View File

@@ -33,6 +33,10 @@ public class ProductSupplierListViewModel extends ViewModel {
private final MutableLiveData<List<SupplierDTO>> suppliers = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject
public ProductSupplierListViewModel(ProductSupplierRepository psRepository, ProductRepository productRepository, SupplierRepository supplierRepository) {
this.psRepository = psRepository;
@@ -44,13 +48,27 @@ public class ProductSupplierListViewModel extends ViewModel {
public LiveData<List<ProductDTO>> getProducts() { return products; }
public LiveData<List<SupplierDTO>> getSuppliers() { return suppliers; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadProductSuppliers(boolean reset, String query, Long productId, Long supplierId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadProductSuppliers(String query, Long productId, Long supplierId) {
isLoading.setValue(true);
observeOnce(psRepository.getAllProductSuppliers(0, 100, query, productId, supplierId, "productName"), resource -> {
observeOnce(psRepository.getAllProductSuppliers(currentPage, PAGE_SIZE, query, productId, supplierId, "productName"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
productSuppliers.setValue(resource.data.getContent());
List<ProductSupplierDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(productSuppliers.getValue());
currentList.addAll(resource.data.getContent());
productSuppliers.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);

View File

@@ -28,6 +28,10 @@ public class PurchaseOrderListViewModel extends ViewModel {
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject
public PurchaseOrderListViewModel(PurchaseOrderRepository purchaseOrderRepository, StoreRepository storeRepository) {
this.purchaseOrderRepository = purchaseOrderRepository;
@@ -37,13 +41,27 @@ public class PurchaseOrderListViewModel extends ViewModel {
public LiveData<List<PurchaseOrderDTO>> getPurchaseOrders() { return purchaseOrders; }
public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadPurchaseOrders(boolean reset, String query, Long storeId) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadPurchaseOrders(String query, Long storeId) {
isLoading.setValue(true);
observeOnce(purchaseOrderRepository.getAllPurchaseOrders(0, 100, query, storeId, "purchaseOrderId,desc"), resource -> {
observeOnce(purchaseOrderRepository.getAllPurchaseOrders(currentPage, PAGE_SIZE, query, storeId, "purchaseOrderId,desc"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
purchaseOrders.setValue(resource.data.getContent());
List<PurchaseOrderDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(purchaseOrders.getValue());
currentList.addAll(resource.data.getContent());
purchaseOrders.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);

View File

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

View File

@@ -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,27 +26,31 @@ 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;
private boolean isLastPage = false;
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

View File

@@ -28,6 +28,11 @@ public class StaffListViewModel extends ViewModel {
private final MutableLiveData<List<EmployeeDTO>> filteredEmployees = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<List<StoreDTO>> stores = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
private String lastQuery = "";
private Long lastStoreId = null;
private String lastStatus = "All Statuses";
@@ -41,14 +46,28 @@ public class StaffListViewModel extends ViewModel {
public LiveData<List<EmployeeDTO>> getFilteredEmployees() { return filteredEmployees; }
public LiveData<List<StoreDTO>> getStores() { return stores; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadStaff(boolean reset) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadStaff() {
isLoading.setValue(true);
observeOnce(repository.getAllEmployees(0, 100), resource -> {
observeOnce(repository.getAllEmployees(currentPage, PAGE_SIZE), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
employees.setValue(resource.data.getContent());
filter(lastQuery, lastStoreId, lastStatus);
List<EmployeeDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(employees.getValue());
currentList.addAll(resource.data.getContent());
employees.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
applyFilter(currentList);
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);
@@ -81,28 +100,27 @@ public class StaffListViewModel extends ViewModel {
this.lastQuery = query;
this.lastStoreId = storeId;
this.lastStatus = status;
List<EmployeeDTO> all = employees.getValue();
applyFilter(employees.getValue());
}
private void applyFilter(List<EmployeeDTO> all) {
if (all == null) return;
List<EmployeeDTO> filtered = new ArrayList<>();
String lowerQuery = query.toLowerCase();
String lowerQuery = lastQuery.toLowerCase();
for (EmployeeDTO e : all) {
// Search Query Filter
boolean matchesQuery = query.isEmpty() ||
boolean matchesQuery = lastQuery.isEmpty() ||
(e.getFullName() != null && e.getFullName().toLowerCase().contains(lowerQuery)) ||
(e.getUsername() != null && e.getUsername().toLowerCase().contains(lowerQuery)) ||
(e.getEmail() != null && e.getEmail().toLowerCase().contains(lowerQuery)) ||
(e.getPhone() != null && e.getPhone().toLowerCase().contains(lowerQuery));
// Store Filter
boolean matchesStore = storeId == null || (e.getPrimaryStoreId() != null && e.getPrimaryStoreId().equals(storeId));
boolean matchesStore = lastStoreId == null || (e.getPrimaryStoreId() != null && e.getPrimaryStoreId().equals(lastStoreId));
// Status Filter
boolean matchesStatus = status.equals("All Statuses") ||
(status.equals("Active") && Boolean.TRUE.equals(e.getActive())) ||
(status.equals("Inactive") && Boolean.FALSE.equals(e.getActive()));
boolean matchesStatus = lastStatus.equals("All Statuses") ||
(lastStatus.equals("Active") && Boolean.TRUE.equals(e.getActive())) ||
(lastStatus.equals("Inactive") && Boolean.FALSE.equals(e.getActive()));
if (matchesQuery && matchesStore && matchesStatus) {
filtered.add(e);

View File

@@ -25,6 +25,10 @@ public class SupplierListViewModel extends ViewModel {
private final MutableLiveData<List<SupplierDTO>> suppliers = new MutableLiveData<>(new ArrayList<>());
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
private int currentPage = 0;
private boolean isLastPage = false;
private static final int PAGE_SIZE = 20;
@Inject
public SupplierListViewModel(SupplierRepository repository) {
this.repository = repository;
@@ -32,13 +36,27 @@ public class SupplierListViewModel extends ViewModel {
public LiveData<List<SupplierDTO>> getSuppliers() { return suppliers; }
public LiveData<Boolean> getIsLoading() { return isLoading; }
public boolean isLastPage() { return isLastPage; }
public void loadSuppliers(boolean reset, String query) {
if (isLoading.getValue() != null && isLoading.getValue() && !reset) return;
if (reset) {
currentPage = 0;
isLastPage = false;
}
if (isLastPage) return;
public void loadSuppliers(String query) {
isLoading.setValue(true);
observeOnce(repository.getAllSuppliers(0, 100, query, "supCompany"), resource -> {
observeOnce(repository.getAllSuppliers(currentPage, PAGE_SIZE, query, "supCompany"), resource -> {
if (resource != null) {
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
suppliers.setValue(resource.data.getContent());
List<SupplierDTO> currentList = reset ? new ArrayList<>() : new ArrayList<>(suppliers.getValue());
currentList.addAll(resource.data.getContent());
suppliers.setValue(currentList);
isLastPage = resource.data.isLast();
if (!isLastPage) currentPage++;
isLoading.setValue(false);
} else if (resource.status == Resource.Status.ERROR) {
isLoading.setValue(false);

View File

@@ -95,6 +95,7 @@ public class StompChatManager {
headers.put("Authorization", "Bearer " + authToken);
stompClient = Stomp.over(Stomp.ConnectionProvider.OKHTTP, webSocketUrl, headers);
stompClient.withClientHeartbeat(0).withServerHeartbeat(0);
compositeDisposable.add(
stompClient.lifecycle()

View File

@@ -88,6 +88,57 @@
</LinearLayout>
<!-- Date range pickers -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<Button
android:id="@+id/btnStartDate"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:text="Start Date"
android:textSize="12sp"
android:backgroundTint="@color/white"
android:textColor="@color/text_dark"
android:ellipsize="end"
android:maxLines="1"/>
<View
android:layout_width="8dp"
android:layout_height="0dp"/>
<Button
android:id="@+id/btnEndDate"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:text="End Date"
android:textSize="12sp"
android:backgroundTint="@color/white"
android:textColor="@color/text_dark"
android:ellipsize="end"
android:maxLines="1"/>
<View
android:layout_width="8dp"
android:layout_height="0dp"/>
<Button
android:id="@+id/btnClearDates"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:text="Clear"
android:textSize="12sp"
android:backgroundTint="#e2e8f0"
android:textColor="@color/text_dark"
android:visibility="gone"/>
</LinearLayout>
<!-- Role and Store spinners -->
<LinearLayout
android:layout_width="match_parent"
@@ -119,6 +170,7 @@
</LinearLayout>
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout

View File

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

View File

@@ -105,19 +105,6 @@
android:textSize="15sp"
android:layout_marginBottom="16dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status"
android:textColor="@color/text_light"
android:textSize="12sp"/>
<TextView
android:id="@+id/tvPODetailStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:layout_marginBottom="16dp"/>
</LinearLayout>

View File

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

View File

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

View File

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

View File

@@ -13,17 +13,17 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="4dp">
android:gravity="center_vertical"
android:layout_marginBottom="2dp">
<TextView
android:id="@+id/tvLogActivity"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="13sp"
android:textSize="14sp"
android:textColor="@color/text_dark"
android:textStyle="bold"
android:fontFamily="monospace"/>
android:textStyle="bold"/>
<TextView
android:id="@+id/tvLogTimestamp"
@@ -36,18 +36,42 @@
</LinearLayout>
<TextView
android:id="@+id/tvLogUser"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@color/text_dark"
android:layout_marginBottom="2dp"/>
<TextView
android:id="@+id/tvLogMeta"
android:id="@+id/tvLogTechnical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="@color/text_light"/>
android:textColor="@color/text_light"
android:fontFamily="monospace"
android:layout_marginBottom="6dp"
android:visibility="gone"/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#F0F0F0"
android:layout_marginBottom="6dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvLogUser"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="12sp"
android:textColor="@color/text_dark"/>
<TextView
android:id="@+id/tvLogMeta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textColor="@color/text_light"/>
</LinearLayout>
</LinearLayout>

View File

@@ -14,6 +14,15 @@
android:padding="8dp"
android:maxWidth="300dp">
<TextView
android:id="@+id/tvSenderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_dark"
android:textStyle="bold"
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<ImageView
android:id="@+id/ivAttachment"
android:layout_width="200dp"
@@ -38,6 +47,14 @@
android:layout_height="wrap_content"
android:text="Received message"
android:textColor="@color/text_dark" />
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#94a3b8"
android:textSize="11sp"
android:layout_marginTop="4dp"/>
</LinearLayout>
</LinearLayout>

View File

@@ -14,6 +14,16 @@
android:padding="8dp"
android:maxWidth="300dp">
<TextView
android:id="@+id/tvSenderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="You"
android:textColor="@color/white"
android:textStyle="bold"
android:textSize="12sp"
android:layout_marginBottom="4dp"/>
<ImageView
android:id="@+id/ivAttachment"
android:layout_width="200dp"
@@ -38,6 +48,14 @@
android:layout_height="wrap_content"
android:text="Sent message"
android:textColor="@color/white" />
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#dbeafe"
android:textSize="11sp"
android:layout_marginTop="4dp"/>
</LinearLayout>
</LinearLayout>

View File

@@ -27,19 +27,6 @@
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvPOStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingTop="3dp"
android:paddingEnd="8dp"
android:paddingBottom="3dp"
android:text="Status"
android:textAllCaps="true"
android:textColor="@color/white"
android:textSize="11sp" />
</LinearLayout>
<TextView

View File

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

View File

@@ -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
View File

@@ -44,7 +44,8 @@ build/
.env
### Project Specific ###
src/test/
!src/test/
!src/test/**
tmp/
uploads/*
!uploads/avatars/

View File

@@ -9,6 +9,5 @@ RUN mvn -q -DskipTests package
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
COPY uploads ./uploads
EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"]

265
backend/log.txt Normal file
View File

@@ -0,0 +1,265 @@
2026-04-14T20:46:27.127-06:00 INFO 332751 --- [petshop-backend] [main] org.flywaydb.core.FlywayExecutor : Database: jdbc:mysql://localhost:3306/Petstoredb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC (MySQL 8.0)
2026-04-14T20:46:27.163-06:00 INFO 332751 --- [petshop-backend] [main] o.f.core.internal.command.DbValidate : Successfully validated 4 migrations (execution time 00:00.014s)
2026-04-14T20:46:27.171-06:00 INFO 332751 --- [petshop-backend] [main] o.f.core.internal.command.DbMigrate : Current version of schema `Petstoredb`: 3
2026-04-14T20:46:27.179-06:00 INFO 332751 --- [petshop-backend] [main] o.f.core.internal.command.DbMigrate : Migrating schema `Petstoredb` to version "4 - drop purchase order status"
2026-04-14T20:46:27.207-06:00 INFO 332751 --- [petshop-backend] [main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema `Petstoredb`, now at version v4 (execution time 00:00.016s)
2026-04-14T20:46:27.212-06:00 INFO 332751 --- [petshop-backend] [main] com.petshop.backend.BackendApplication : Starting BackendApplication using Java 25.0.2 with PID 332751 (/home/user/threaded/main/backend/target/classes started by user in /home/user/threaded/main/backend)
2026-04-14T20:46:27.213-06:00 INFO 332751 --- [petshop-backend] [main] com.petshop.backend.BackendApplication : No active profile set, falling back to 1 default profile: "default"
2026-04-14T20:46:27.648-06:00 INFO 332751 --- [petshop-backend] [main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-04-14T20:46:27.688-06:00 INFO 332751 --- [petshop-backend] [main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 36 ms. Found 22 JPA repository interfaces.
2026-04-14T20:46:27.920-06:00 INFO 332751 --- [petshop-backend] [main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2026-04-14T20:46:27.926-06:00 INFO 332751 --- [petshop-backend] [main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-04-14T20:46:27.926-06:00 INFO 332751 --- [petshop-backend] [main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.18]
2026-04-14T20:46:27.944-06:00 INFO 332751 --- [petshop-backend] [main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 711 ms
2026-04-14T20:46:28.006-06:00 INFO 332751 --- [petshop-backend] [main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-04-14T20:46:28.025-06:00 INFO 332751 --- [petshop-backend] [main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.2.4.Final
2026-04-14T20:46:28.185-06:00 INFO 332751 --- [petshop-backend] [main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-04-14T20:46:28.198-06:00 INFO 332751 --- [petshop-backend] [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2026-04-14T20:46:28.205-06:00 INFO 332751 --- [petshop-backend] [main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@71e2ddd
2026-04-14T20:46:28.206-06:00 INFO 332751 --- [petshop-backend] [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2026-04-14T20:46:28.232-06:00 INFO 332751 --- [petshop-backend] [main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:mysql://localhost:3306/Petstoredb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC]
Database driver: MySQL Connector/J
Database dialect: MySQLDialect
Database version: 8.0.45
Default catalog/schema: Petstoredb/undefined
Autocommit mode: undefined/unknown
Isolation level: REPEATABLE_READ [default REPEATABLE_READ]
JDBC fetch size: none
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-04-14T20:46:28.790-06:00 INFO 332751 --- [petshop-backend] [main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-04-14T20:46:28.813-06:00 INFO 332751 --- [petshop-backend] [main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-04-14T20:46:28.844-06:00 INFO 332751 --- [petshop-backend] [main] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
2026-04-14T20:46:29.610-06:00 ERROR 332751 --- [petshop-backend] [main] t.s.DeferredServletContainerInitializers : Error starting Tomcat context. Exception: org.springframework.beans.factory.UnsatisfiedDependencyException. Message: Error creating bean with name 'jwtAuthenticationFilter' defined in file [/home/user/threaded/main/backend/target/classes/com/petshop/backend/security/JwtAuthenticationFilter.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'jwtUtil': Injection of autowired dependencies failed
2026-04-14T20:46:29.619-06:00 INFO 332751 --- [petshop-backend] [main] o.apache.catalina.core.StandardService : Stopping service [Tomcat]
2026-04-14T20:46:29.620-06:00 WARN 332751 --- [petshop-backend] [main] o.a.c.loader.WebappClassLoaderBase : The web application [ROOT] appears to have started a thread named [HikariPool-1:housekeeper] but has failed to stop it. This is very likely to create a memory leak. Stack trace of thread:
java.base/jdk.internal.misc.Unsafe.park(Native Method)
java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:271)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1803)
java.base/java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:1166)
java.base/java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(ScheduledThreadPoolExecutor.java:883)
java.base/java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1016)
java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1076)
java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
java.base/java.lang.Thread.run(Thread.java:1474)
2026-04-14T20:46:29.620-06:00 WARN 332751 --- [petshop-backend] [main] o.a.c.loader.WebappClassLoaderBase : The web application [ROOT] appears to have started a thread named [HikariPool-1:connection-adder] but has failed to stop it. This is very likely to create a memory leak. Stack trace of thread:
java.base/jdk.internal.misc.Unsafe.park(Native Method)
java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:271)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1803)
java.base/java.util.concurrent.LinkedBlockingQueue.poll(LinkedBlockingQueue.java:460)
java.base/java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1015)
java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1076)
java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
java.base/java.lang.Thread.run(Thread.java:1474)
2026-04-14T20:46:29.621-06:00 WARN 332751 --- [petshop-backend] [main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.context.ApplicationContextException: Unable to start web server
2026-04-14T20:46:29.621-06:00 INFO 332751 --- [petshop-backend] [main] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2026-04-14T20:46:29.623-06:00 INFO 332751 --- [petshop-backend] [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2026-04-14T20:46:29.624-06:00 INFO 332751 --- [petshop-backend] [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
2026-04-14T20:46:29.627-06:00 INFO 332751 --- [petshop-backend] [main] .s.b.a.l.ConditionEvaluationReportLogger :
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-04-14T20:46:29.633-06:00 ERROR 332751 --- [petshop-backend] [main] o.s.boot.SpringApplication : Application run failed
org.springframework.context.ApplicationContextException: Unable to start web server
at org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:167) ~[spring-boot-web-server-4.0.3.jar:4.0.3]
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:615) ~[spring-context-7.0.5.jar:7.0.5]
at org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:143) ~[spring-boot-web-server-4.0.3.jar:4.0.3]
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:756) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:445) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:321) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.builder.SpringApplicationBuilder.run(SpringApplicationBuilder.java:154) ~[spring-boot-4.0.3.jar:4.0.3]
at com.petshop.backend.BackendApplication.main(BackendApplication.java:17) ~[classes/:na]
Caused by: org.springframework.boot.web.server.WebServerException: Unable to start embedded Tomcat
at org.springframework.boot.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:150) ~[spring-boot-tomcat-4.0.3.jar:4.0.3]
at org.springframework.boot.tomcat.TomcatWebServer.<init>(TomcatWebServer.java:110) ~[spring-boot-tomcat-4.0.3.jar:4.0.3]
at org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory.getTomcatWebServer(TomcatServletWebServerFactory.java:408) ~[spring-boot-tomcat-4.0.3.jar:4.0.3]
at org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory.getWebServer(TomcatServletWebServerFactory.java:166) ~[spring-boot-tomcat-4.0.3.jar:4.0.3]
at org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext.createWebServer(ServletWebServerApplicationContext.java:190) ~[spring-boot-web-server-4.0.3.jar:4.0.3]
at org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext.onRefresh(ServletWebServerApplicationContext.java:164) ~[spring-boot-web-server-4.0.3.jar:4.0.3]
... 7 common frames omitted
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'jwtAuthenticationFilter' defined in file [/home/user/threaded/main/backend/target/classes/com/petshop/backend/security/JwtAuthenticationFilter.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'jwtUtil': Injection of autowired dependencies failed
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1382) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1221) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:565) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:525) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:333) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:371) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:331) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:201) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.boot.web.servlet.ServletContextInitializerBeans.getOrderedBeansOfType(ServletContextInitializerBeans.java:231) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addAsRegistrationBean(ServletContextInitializerBeans.java:185) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addAsRegistrationBean(ServletContextInitializerBeans.java:180) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.web.servlet.ServletContextInitializerBeans.addAdaptableBeans(ServletContextInitializerBeans.java:165) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.web.servlet.ServletContextInitializerBeans.<init>(ServletContextInitializerBeans.java:97) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.web.context.servlet.WebApplicationContextInitializer.initialize(WebApplicationContextInitializer.java:53) ~[spring-boot-4.0.3.jar:4.0.3]
at org.springframework.boot.tomcat.servlet.DeferredServletContainerInitializers.onStartup(DeferredServletContainerInitializers.java:55) ~[spring-boot-tomcat-4.0.3.jar:4.0.3]
at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:4416) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1162) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1158) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328) ~[na:na]
at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:149) ~[na:na]
at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:714) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.StandardHost.startInternal(StandardHost.java:780) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1162) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1158) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328) ~[na:na]
at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:81) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:149) ~[na:na]
at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:714) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.StandardEngine.startInternal(StandardEngine.java:201) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.StandardService.startInternal(StandardService.java:410) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.core.StandardServer.startInternal(StandardServer.java:864) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:164) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.apache.catalina.startup.Tomcat.start(Tomcat.java:435) ~[tomcat-embed-core-11.0.18.jar:11.0.18]
at org.springframework.boot.tomcat.TomcatWebServer.initialize(TomcatWebServer.java:131) ~[spring-boot-tomcat-4.0.3.jar:4.0.3]
... 12 common frames omitted
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtUtil': Injection of autowired dependencies failed
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:499) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1446) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:602) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:525) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:333) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:371) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:331) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:201) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveBean(DefaultListableBeanFactory.java:1225) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1704) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1651) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:912) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) ~[spring-beans-7.0.5.jar:7.0.5]
... 52 common frames omitted
Caused by: org.springframework.util.PlaceholderResolutionException: Could not resolve placeholder 'JWT_SECRET' in value "${JWT_SECRET}" <-- "${jwt.secret}"
at org.springframework.util.PlaceholderResolutionException.withValue(PlaceholderResolutionException.java:81) ~[spring-core-7.0.5.jar:7.0.5]
at org.springframework.util.PlaceholderParser$ParsedValue.resolve(PlaceholderParser.java:296) ~[spring-core-7.0.5.jar:7.0.5]
at org.springframework.util.PlaceholderParser.replacePlaceholders(PlaceholderParser.java:129) ~[spring-core-7.0.5.jar:7.0.5]
at org.springframework.util.PropertyPlaceholderHelper.replacePlaceholders(PropertyPlaceholderHelper.java:96) ~[spring-core-7.0.5.jar:7.0.5]
at org.springframework.core.env.AbstractPropertyResolver.doResolvePlaceholders(AbstractPropertyResolver.java:286) ~[spring-core-7.0.5.jar:7.0.5]
at org.springframework.core.env.AbstractPropertyResolver.resolveRequiredPlaceholders(AbstractPropertyResolver.java:257) ~[spring-core-7.0.5.jar:7.0.5]
at org.springframework.context.support.PropertySourcesPlaceholderConfigurer.lambda$processProperties$0(PropertySourcesPlaceholderConfigurer.java:184) ~[spring-context-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.AbstractBeanFactory.resolveEmbeddedValue(AbstractBeanFactory.java:959) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1672) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1651) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.resolveFieldValue(AutowiredAnnotationBeanPostProcessor.java:764) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredFieldElement.inject(AutowiredAnnotationBeanPostProcessor.java:748) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:146) ~[spring-beans-7.0.5.jar:7.0.5]
at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:493) ~[spring-beans-7.0.5.jar:7.0.5]
... 64 common frames omitted
2026-04-14T20:47:58.809-06:00 INFO 333355 --- [petshop-backend] [main] org.flywaydb.core.FlywayExecutor : Database: jdbc:mysql://localhost:3306/Petstoredb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC (MySQL 8.0)
2026-04-14T20:47:58.842-06:00 INFO 333355 --- [petshop-backend] [main] o.f.core.internal.command.DbValidate : Successfully validated 4 migrations (execution time 00:00.012s)
2026-04-14T20:47:58.850-06:00 INFO 333355 --- [petshop-backend] [main] o.f.core.internal.command.DbMigrate : Current version of schema `Petstoredb`: 4
2026-04-14T20:47:58.851-06:00 INFO 333355 --- [petshop-backend] [main] o.f.core.internal.command.DbMigrate : Schema `Petstoredb` is up to date. No migration necessary.
2026-04-14T20:47:58.857-06:00 INFO 333355 --- [petshop-backend] [main] com.petshop.backend.BackendApplication : Starting BackendApplication using Java 25.0.2 with PID 333355 (/home/user/threaded/main/backend/target/classes started by user in /home/user/threaded/main/backend)
2026-04-14T20:47:58.857-06:00 INFO 333355 --- [petshop-backend] [main] com.petshop.backend.BackendApplication : The following 1 profile is active: "local"
2026-04-14T20:47:59.251-06:00 INFO 333355 --- [petshop-backend] [main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-04-14T20:47:59.288-06:00 INFO 333355 --- [petshop-backend] [main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 33 ms. Found 22 JPA repository interfaces.
2026-04-14T20:47:59.550-06:00 INFO 333355 --- [petshop-backend] [main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2026-04-14T20:47:59.557-06:00 INFO 333355 --- [petshop-backend] [main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-04-14T20:47:59.557-06:00 INFO 333355 --- [petshop-backend] [main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.18]
2026-04-14T20:47:59.575-06:00 INFO 333355 --- [petshop-backend] [main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 699 ms
2026-04-14T20:47:59.636-06:00 INFO 333355 --- [petshop-backend] [main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-04-14T20:47:59.655-06:00 INFO 333355 --- [petshop-backend] [main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.2.4.Final
2026-04-14T20:47:59.799-06:00 INFO 333355 --- [petshop-backend] [main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-04-14T20:47:59.811-06:00 INFO 333355 --- [petshop-backend] [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2026-04-14T20:47:59.817-06:00 INFO 333355 --- [petshop-backend] [main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@6109366a
2026-04-14T20:47:59.818-06:00 INFO 333355 --- [petshop-backend] [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2026-04-14T20:47:59.845-06:00 INFO 333355 --- [petshop-backend] [main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:mysql://localhost:3306/Petstoredb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC]
Database driver: MySQL Connector/J
Database dialect: MySQLDialect
Database version: 8.0.45
Default catalog/schema: Petstoredb/undefined
Autocommit mode: undefined/unknown
Isolation level: REPEATABLE_READ [default REPEATABLE_READ]
JDBC fetch size: none
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-04-14T20:48:00.410-06:00 INFO 333355 --- [petshop-backend] [main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-04-14T20:48:00.429-06:00 INFO 333355 --- [petshop-backend] [main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-04-14T20:48:00.460-06:00 INFO 333355 --- [petshop-backend] [main] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
2026-04-14T20:48:01.322-06:00 INFO 333355 --- [petshop-backend] [main] o.s.boot.web.servlet.RegistrationBean : Filter activityLoggingFilterRegistration was not registered (disabled)
2026-04-14T20:48:02.531-06:00 INFO 333355 --- [petshop-backend] [main] o.s.m.s.b.SimpleBrokerMessageHandler : Starting...
2026-04-14T20:48:02.531-06:00 INFO 333355 --- [petshop-backend] [main] o.s.m.s.b.SimpleBrokerMessageHandler : BrokerAvailabilityEvent[available=true, SimpleBrokerMessageHandler [org.springframework.messaging.simp.broker.DefaultSubscriptionRegistry@6a975fe4]]
2026-04-14T20:48:02.532-06:00 INFO 333355 --- [petshop-backend] [main] o.s.m.s.b.SimpleBrokerMessageHandler : Started.
2026-04-14T20:48:02.537-06:00 INFO 333355 --- [petshop-backend] [main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2026-04-14T20:48:03.256-06:00 INFO 333355 --- [petshop-backend] [main] c.p.backend.service.ActivityLogService : [ACTIVITY] CUSTOMER | ellie.washington | West Side Store | Email sent: Reminder: appointment tomorrow — Veterinary Checkup → ellie.washington@gmail.com
2026-04-14T20:48:03.729-06:00 INFO 333355 --- [petshop-backend] [main] c.p.backend.service.ActivityLogService : [ACTIVITY] STAFF | daniel.moore | West Side Store | Email sent: Reminder: appointment tomorrow — Veterinary Checkup → daniel.moore@petshop.com
2026-04-14T20:48:03.742-06:00 INFO 333355 --- [petshop-backend] [main] com.petshop.backend.BackendApplication : Started BackendApplication in 5.357 seconds (process running for 5.532)
2026-04-14T20:48:05.562-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-04-14T20:48:05.562-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2026-04-14T20:48:05.563-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
2026-04-14T20:48:05.571-06:00 WARN 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-1] c.p.b.s.RestAuthenticationEntryPoint : Unauthorized request: POST /api/auth/login - Full authentication is required to access this resource
2026-04-14T20:48:10.402-06:00 WARN 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-2] c.p.b.s.RestAuthenticationEntryPoint : Unauthorized request: POST /api/auth/login - Full authentication is required to access this resource
2026-04-14T20:48:10.431-06:00 WARN 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-3] c.p.b.s.RestAuthenticationEntryPoint : Unauthorized request: POST /api/auth/login - Full authentication is required to access this resource
2026-04-14T20:48:12.470-06:00 WARN 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-4] c.p.b.s.RestAuthenticationEntryPoint : Unauthorized request: POST /api/auth/login - Full authentication is required to access this resource
2026-04-14T20:48:14.398-06:00 WARN 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.b.s.RestAuthenticationEntryPoint : Unauthorized request: POST /api/auth/login - Full authentication is required to access this resource
2026-04-14T20:48:29.437-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:48:29.539-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] STAFF | staff | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:48:34.923-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:48:43.731-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-4] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:48:50.154-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-8] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:48:50.197-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/adoptions -> 200
2026-04-14T20:48:53.571-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-1] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:48:53.594-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-2] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/adoptions -> 200
2026-04-14T20:48:57.295-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-4] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:48:57.340-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/adoptions -> 200
2026-04-14T20:49:02.530-06:00 INFO 333355 --- [petshop-backend] [MessageBroker-2] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 4, active threads = 1, queued tasks = 2, completed tasks = 1]
2026-04-14T20:49:04.360-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:04.503-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-9] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets -> 200
2026-04-14T20:49:04.532-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-2] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/adoptions -> 500
2026-04-14T20:49:09.172-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-3] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:09.305-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets -> 200
2026-04-14T20:49:13.132-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:13.158-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-8] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets/2 -> 200
2026-04-14T20:49:18.817-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:18.967-06:00 WARN 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 1048, SQLState: 23000
2026-04-14T20:49:18.967-06:00 WARN 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] org.hibernate.orm.jdbc.error : Column 'sourceStoreId' cannot be null
2026-04-14T20:49:18.973-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/adoptions -> 400
2026-04-14T20:49:19.013-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets/2 -> 200
2026-04-14T20:49:23.058-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:29.750-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:29.890-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-3] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/users -> 200
2026-04-14T20:49:30.434-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.backend.service.ActivityLogService : [ACTIVITY] CUSTOMER | customer | Downtown Branch | Email sent: Your adoption — Milo → customer@petshop.com
2026-04-14T20:49:30.924-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.backend.service.ActivityLogService : [ACTIVITY] STAFF | staff | Downtown Branch | Email sent: Adoption assigned — Milo → staff@petshop.com
2026-04-14T20:49:30.936-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/adoptions -> 201
2026-04-14T20:49:36.666-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:36.690-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets/2 -> 200
2026-04-14T20:49:36.719-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-9] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | DELETE /api/v1/adoptions/20 -> 204
2026-04-14T20:49:36.736-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-2] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets/2 -> 200
2026-04-14T20:49:44.162-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-3] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:56.940-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:49:57.066-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-4] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets -> 200
2026-04-14T20:49:57.103-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/appointments -> 400
2026-04-14T20:49:57.140-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/pets -> 200
2026-04-14T20:49:57.654-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] CUSTOMER | customer | Downtown Branch | Email sent: Appointment confirmed — Pet Grooming → customer@petshop.com
2026-04-14T20:49:58.138-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] STAFF | staff | Downtown Branch | Email sent: Appointment assigned — Pet Grooming → staff@petshop.com
2026-04-14T20:49:58.147-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/appointments -> 201
2026-04-14T20:50:03.962-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-8] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:50:04.077-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-3] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/users/15 -> 200
2026-04-14T20:50:07.463-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:50:07.485-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-7] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/products -> 200
2026-04-14T20:50:14.352-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:50:14.473-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-3] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/users/15 -> 200
2026-04-14T20:50:14.509-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-4] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/sales -> 403
2026-04-14T20:50:14.538-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/users/15 -> 200
2026-04-14T20:50:18.667-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-8] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:50:26.690-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-9] c.p.backend.service.ActivityLogService : [ACTIVITY] STAFF | staff | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:50:26.816-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-4] c.p.backend.service.ActivityLogService : [ACTIVITY] STAFF | staff | Downtown Branch | GET /api/v1/auth/me -> 200
2026-04-14T20:50:26.893-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-5] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | POST /api/v1/auth/login -> 200
2026-04-14T20:50:26.913-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-6] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/users/15 -> 200
2026-04-14T20:50:26.929-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-8] c.p.backend.service.ActivityLogService : [ACTIVITY] STAFF | staff | Downtown Branch | POST /api/v1/sales -> 500
2026-04-14T20:50:26.965-06:00 INFO 333355 --- [petshop-backend] [http-nio-0.0.0.0-8080-exec-10] c.p.backend.service.ActivityLogService : [ACTIVITY] ADMIN | admin | Downtown Branch | GET /api/v1/users/15 -> 200
2026-04-14T21:19:02.531-06:00 INFO 333355 --- [petshop-backend] [MessageBroker-6] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 11, active threads = 1, queued tasks = 2, completed tasks = 8]
2026-04-14T21:49:02.532-06:00 INFO 333355 --- [petshop-backend] [MessageBroker-12] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 16, active threads = 1, queued tasks = 2, completed tasks = 15]
2026-04-14T22:19:02.532-06:00 INFO 333355 --- [petshop-backend] [MessageBroker-15] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 16, active threads = 1, queued tasks = 2, completed tasks = 22]
2026-04-14T22:49:02.533-06:00 INFO 333355 --- [petshop-backend] [MessageBroker-1] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 16, active threads = 1, queued tasks = 2, completed tasks = 29]

View File

@@ -96,6 +96,18 @@
<version>25.3.0</version>
</dependency>
<dependency>
<groupId>com.resend</groupId>
<artifactId>resend-java</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-storage-blob</artifactId>
<version>12.29.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -17,9 +17,9 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 20)
public class ActivityLoggingFilter extends OncePerRequestFilter {
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 20)
public class ActivityLoggingFilter extends OncePerRequestFilter {
private final ActivityLogService activityLogService;
@@ -30,13 +30,8 @@ import java.io.IOException;
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null || uri.isBlank()) {
return true;
}
if (!uri.startsWith("/api/")) {
return true;
}
if (uri == null || uri.isBlank()) return true;
if (!uri.startsWith("/api/")) return true;
String lower = uri.toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("/api/v1/health")
|| lower.startsWith("/api/v1/activity-logs")
@@ -46,16 +41,15 @@ import java.io.IOException;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(request, response);
recordActivity(request, response);
}
private void recordActivity(HttpServletRequest request, HttpServletResponse response) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return;
}
if (authentication == null || !authentication.isAuthenticated()) return;
Long userId = null;
User.Role role = null;
@@ -69,11 +63,136 @@ import java.io.IOException;
role = appPrincipal.getRole();
}
if (userId == null || role == null || role == User.Role.CUSTOMER) {
return;
}
if (userId == null || role == null) return;
if ("GET".equalsIgnoreCase(request.getMethod())) return;
String method = request.getMethod();
String uri = request.getRequestURI();
int status = response.getStatus();
String technical = method + " " + uri + "" + status;
String description = describe(method, uri, status);
String activity = description != null ? description + " | " + technical : technical;
String activity = String.format("%s %s -> %d", request.getMethod(), request.getRequestURI(), response.getStatus());
activityLogService.record(userId, activity);
}
private String describe(String method, String rawUri, int status) {
String uri = rawUri.contains("?") ? rawUri.substring(0, rawUri.indexOf('?')) : rawUri;
String[] parts = uri.replaceFirst("^/+", "").split("/");
if (parts.length < 3) return null;
String r = parts[2];
String seg3 = parts.length > 3 ? parts[3] : null;
String seg4 = parts.length > 4 ? parts[4] : null;
String seg5 = parts.length > 5 ? parts[5] : null;
boolean seg3IsId = seg3 != null && seg3.matches("\\d+");
boolean seg4IsId = seg4 != null && seg4.matches("\\d+");
String id = seg3IsId ? seg3 : null;
String sub = seg3IsId ? seg4 : seg3;
String subsub = seg3IsId ? seg5 : seg4;
boolean isGet = "GET".equalsIgnoreCase(method);
boolean isPost = "POST".equalsIgnoreCase(method);
boolean isPut = "PUT".equalsIgnoreCase(method);
boolean isPatch = "PATCH".equalsIgnoreCase(method);
boolean isDelete = "DELETE".equalsIgnoreCase(method);
boolean failed = status >= 400;
switch (r) {
case "auth" -> {
if ("login".equals(seg3) && isPost) return failed ? "Failed login attempt" : "Logged in";
if ("logout".equals(seg3) && isPost) return "Logged out";
if ("register".equals(seg3) && isPost) return failed ? "Failed registration attempt" : "Registered a new account";
if ("forgot-password".equals(seg3) && isPost) return "Requested a password reset";
if ("reset-password".equals(seg3) && isPost) return "Reset their password";
if ("me".equals(seg3) && isPut) return "Updated their profile";
if ("me".equals(seg3) && "avatar".equals(sub) && isPost) return "Uploaded a profile picture";
if ("me".equals(seg3) && "avatar".equals(sub) && isDelete) return "Removed their profile picture";
}
case "cart" -> {
if ("add".equals(seg3) && isPost) return "Added an item to cart";
if ("update".equals(seg3) && isPut) return "Updated a cart item";
if ("remove".equals(seg3) && isDelete) return "Removed an item from cart";
if ("clear".equals(seg3) && isDelete) return "Cleared their cart";
if ("apply-coupon".equals(seg3) && isPost) return "Applied a coupon to cart";
if ("checkout".equals(seg3)) {
if ("complete".equals(seg4) && isPost) return "Completed a purchase";
if ("cancel".equals(seg4) && isPost) return "Cancelled checkout";
if (isPost) return "Started checkout";
}
}
case "chat" -> {
if ("conversations".equals(seg3) && isPost && seg4 == null) return "Started a new chat conversation";
if ("conversations".equals(seg3) && seg4IsId) {
String convId = seg4;
String chatSub = parts.length > 5 ? parts[5] : null;
if ("messages".equals(seg5) && isPost) return "Sent a chat message";
if ("attachments".equals(seg5) && isPost) return "Sent a file in chat";
if ("request-human".equals(seg5) && isPost) return "Requested human support in chat";
if (chatSub == null && isPut) return "Updated chat conversation #" + convId;
}
}
case "ai-chat" -> {
if ("message".equals(seg3) && isPost) return "Sent a message to the AI assistant";
}
case "product-suppliers" -> {
if (isPost && seg3 == null) return "Linked a product to a supplier";
if (seg3IsId && seg4IsId && isPut) return "Updated product-supplier link";
if (seg3IsId && seg4IsId && isDelete) return "Removed product-supplier link";
if (isDelete && !seg3IsId) return "Removed multiple product-supplier links";
}
}
String label = resourceLabel(r);
if (label == null) return null;
if ("image".equals(sub) && id != null) {
if (isPost) return "Uploaded image for " + label + " #" + id;
if (isDelete) return "Removed image from " + label + " #" + id;
}
if ("cancel".equals(sub) && id != null && isPatch) return "Cancelled " + label + " #" + id;
if (isPost && id == null) {
if ("request".equals(seg3)) return "Submitted an adoption request";
if ("bulk-delete".equals(seg3)) return "Deleted multiple " + plural(label);
return "Created a new " + label;
}
if ((isPut || isPatch) && id != null && sub == null) return "Updated " + label + " #" + id;
if (isDelete && id != null && sub == null) return "Deleted " + label + " #" + id;
if (isDelete && id == null) return "Deleted multiple " + plural(label);
return null;
}
private String resourceLabel(String resource) {
return switch (resource) {
case "products" -> "product";
case "categories" -> "category";
case "customers" -> "customer";
case "employees" -> "employee";
case "users" -> "user";
case "pets" -> "pet";
case "my-pets" -> "pet";
case "appointments" -> "appointment";
case "adoptions" -> "adoption";
case "sales" -> "sale";
case "refunds" -> "refund";
case "inventory" -> "inventory record";
case "services" -> "service";
case "suppliers" -> "supplier";
case "purchase-orders" -> "purchase order";
case "coupons" -> "coupon";
case "stores" -> "store";
default -> null;
};
}
private String plural(String label) {
if (label.endsWith("y")) return label.substring(0, label.length() - 1) + "ies";
if (label.endsWith("s")) return label + "es";
return label + "s";
}
}

View File

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

View File

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

View File

@@ -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(
@@ -132,7 +137,7 @@ public class AuthController {
String token = jwtUtil.generateToken(user);
if (user.getRole() != User.Role.CUSTOMER) {
activityLogService.record(user.getId(), "POST /api/v1/auth/login -> 200");
activityLogService.record(user.getId(), "Logged in | POST /api/v1/auth/login 200");
}
return ResponseEntity.ok(new LoginResponse(
@@ -262,7 +267,8 @@ public class AuthController {
user.getRole().name(),
customerId,
primaryStore != null ? primaryStore.getStoreId() : null,
primaryStore != null ? primaryStore.getStoreName() : null
primaryStore != null ? primaryStore.getStoreName() : null,
user.getLoyaltyPoints()
);
}

View File

@@ -75,6 +75,24 @@ public class CartController {
return ResponseEntity.ok(cartService.applyCoupon(userId, storeId, request.getCouponCode()));
}
@PostMapping("/apply-points")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> applyPoints(
@RequestParam Long storeId,
@RequestParam Boolean useLoyaltyPoints) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.applyPoints(userId, storeId, useLoyaltyPoints));
}
@DeleteMapping("/coupon")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CartResponse> removeCoupon(@RequestParam Long storeId) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
return ResponseEntity.ok(cartService.removeCoupon(userId, storeId));
}
@PostMapping("/checkout")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CheckoutResponse> checkout(@Valid @RequestBody CheckoutRequest request) {

View File

@@ -0,0 +1,40 @@
package com.petshop.backend.controller;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.service.EmailService;
import com.petshop.backend.util.AuthenticationHelper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/contact")
public class ContactController {
private final EmailService emailService;
private final UserRepository userRepository;
public ContactController(EmailService emailService, UserRepository userRepository) {
this.emailService = emailService;
this.userRepository = userRepository;
}
public record ContactRequest(
@NotBlank @Size(max = 150) String subject,
@NotBlank @Size(max = 2000) String body
) {}
@PostMapping
public ResponseEntity<Void> sendContactEmail(@Valid @RequestBody ContactRequest req) {
Long userId = AuthenticationHelper.getAuthenticatedUserId();
User user = userRepository.findById(userId).orElseThrow();
emailService.sendContactMessage(user, req.subject(), req.body());
return ResponseEntity.ok().build();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,11 +15,12 @@ public class UserInfoResponse {
private Long customerId;
private Long storeId;
private String storeName;
private Integer loyaltyPoints;
public UserInfoResponse() {
}
public UserInfoResponse(Long id, String username, String firstName, String lastName, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName) {
public UserInfoResponse(Long id, String username, String firstName, String lastName, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName, Integer loyaltyPoints) {
this.id = id;
this.username = username;
this.firstName = firstName;
@@ -32,6 +33,7 @@ public class UserInfoResponse {
this.customerId = customerId;
this.storeId = storeId;
this.storeName = storeName;
this.loyaltyPoints = loyaltyPoints;
}
public Long getId() {
@@ -131,6 +133,14 @@ public class UserInfoResponse {
this.storeName = storeName;
}
public Integer getLoyaltyPoints() {
return loyaltyPoints;
}
public void setLoyaltyPoints(Integer loyaltyPoints) {
this.loyaltyPoints = loyaltyPoints;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@@ -14,6 +14,8 @@ public class CartResponse {
private BigDecimal pointsDiscountAmount;
private BigDecimal totalAmount;
private String couponCode;
private String couponDiscountType;
private BigDecimal couponDiscountValue;
private Boolean pointsApplied;
private Integer availableLoyaltyPoints;
private Boolean checkoutPending;
@@ -45,6 +47,12 @@ public class CartResponse {
public String getCouponCode() { return couponCode; }
public void setCouponCode(String couponCode) { this.couponCode = couponCode; }
public String getCouponDiscountType() { return couponDiscountType; }
public void setCouponDiscountType(String couponDiscountType) { this.couponDiscountType = couponDiscountType; }
public BigDecimal getCouponDiscountValue() { return couponDiscountValue; }
public void setCouponDiscountValue(BigDecimal couponDiscountValue) { this.couponDiscountValue = couponDiscountValue; }
public BigDecimal getPointsDiscountAmount() { return pointsDiscountAmount; }
public void setPointsDiscountAmount(BigDecimal pointsDiscountAmount) { this.pointsDiscountAmount = pointsDiscountAmount; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
package com.petshop.backend.service;
import com.petshop.backend.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
@@ -22,11 +24,14 @@ 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";
private static final String BLOB_CONTAINER = "avatars";
@Value("${app.upload.base-dir:uploads}")
private String uploadBaseDir;
@Autowired
private AzureBlobService blobService;
private Path avatarDirectory;
@PostConstruct
@@ -35,18 +40,22 @@ public class AvatarStorageService {
}
public String storeAvatar(MultipartFile file) throws IOException {
Files.createDirectories(avatarDirectory);
String originalFilename = file.getOriginalFilename();
String extension = resolveExtension(originalFilename);
String extension = resolveExtension(file.getOriginalFilename());
String filename = UUID.randomUUID() + extension;
Path filePath = avatarDirectory.resolve(filename).normalize();
Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
if (blobService.isEnabled()) {
blobService.upload(BLOB_CONTAINER, filename, file.getBytes());
} else {
Files.createDirectories(avatarDirectory);
Files.copy(file.getInputStream(), avatarDirectory.resolve(filename).normalize(), StandardCopyOption.REPLACE_EXISTING);
}
return STORED_PREFIX + filename;
}
public Resource loadAvatarResource(User user) {
String filename = extractFilename(user.getAvatarUrl());
if (blobService.isEnabled()) {
return new ByteArrayResource(blobService.download(BLOB_CONTAINER, filename));
}
Path filePath = resolveStoredAvatarPath(user.getAvatarUrl());
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new IllegalArgumentException("Avatar file was not found");
@@ -55,17 +64,23 @@ public class AvatarStorageService {
}
public void deleteAvatar(User user) throws IOException {
if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) {
return;
}
try {
Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl()));
} catch (IllegalArgumentException ignored) {
if (user.getAvatarUrl() == null || user.getAvatarUrl().isBlank()) return;
if (blobService.isEnabled()) {
blobService.delete(BLOB_CONTAINER, extractFilename(user.getAvatarUrl()));
} else {
try {
Files.deleteIfExists(resolveStoredAvatarPath(user.getAvatarUrl()));
} catch (IllegalArgumentException ignored) {}
}
}
private String extractFilename(String avatarUrl) {
if (avatarUrl == null || !avatarUrl.startsWith(STORED_PREFIX)) throw new IllegalArgumentException("Avatar file was not found");
return avatarUrl.substring(STORED_PREFIX.length());
}
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) {

View File

@@ -0,0 +1,44 @@
package com.petshop.backend.service;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.BlobServiceClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.azure.core.util.BinaryData;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class AzureBlobService {
private final BlobServiceClient client;
private final String containerPrefix;
public AzureBlobService(
@Value("${azure.storage.connection-string:}") String connectionString,
@Value("${azure.storage.container-prefix:petshop}") String containerPrefix) {
this.containerPrefix = containerPrefix;
this.client = (connectionString != null && !connectionString.isBlank())
? new BlobServiceClientBuilder().connectionString(connectionString).buildClient()
: null;
}
public boolean isEnabled() {
return client != null;
}
public void upload(String container, String blobName, byte[] bytes) {
getContainerClient(container).getBlobClient(blobName).upload(BinaryData.fromBytes(bytes), true);
}
public byte[] download(String container, String blobName) {
return getContainerClient(container).getBlobClient(blobName).downloadContent().toBytes();
}
public void delete(String container, String blobName) {
getContainerClient(container).getBlobClient(blobName).deleteIfExists();
}
private BlobContainerClient getContainerClient(String container) {
return client.createBlobContainerIfNotExists(containerPrefix + "-" + container);
}
}

View File

@@ -212,6 +212,19 @@ public class CartService {
return toResponse(cart);
}
@Transactional
public CartResponse removeCoupon(Long userId, Long storeId) {
Cart cart = cartRepository
.findActiveCartByUserAndStore(userId, storeId, "ACTIVE")
.orElseThrow(() -> new BusinessException("No active cart found"));
requireNotCheckoutPending(cart);
cart.setCoupon(null);
recalculate(cart);
return toResponse(cart);
}
@Transactional
public CartResponse applyPoints(Long userId, Long storeId, Boolean useLoyaltyPoints) {
Cart cart = cartRepository
@@ -244,6 +257,34 @@ public class CartService {
.setScale(0, RoundingMode.HALF_UP)
.longValue();
// Free checkout: total is $0.00, or points are applied and the remaining
// amount is below Stripe's $0.50 minimum (cannot be charged via card)
if (amountInCents == 0 || (amountInCents < 50 && Boolean.TRUE.equals(cart.getPointsApplied()))) {
SaleRequest saleRequest = new SaleRequest();
saleRequest.setStoreId(cart.getStore().getStoreId());
saleRequest.setCustomerId(cart.getUser().getId());
saleRequest.setCartId(cart.getCartId());
saleRequest.setCouponId(cart.getCoupon() != null ? cart.getCoupon().getCouponId() : null);
saleRequest.setPaymentMethod("Points");
saleRequest.setChannel("WEBSITE");
saleRequest.setItems(items.stream()
.map(item -> {
SaleItemRequest sir = new SaleItemRequest();
sir.setProdId(item.getProduct().getProdId());
sir.setQuantity(item.getQuantity());
return sir;
})
.toList());
saleService.createSale(saleRequest);
cart.setCartStatus("CHECKED_OUT");
cart.setCheckoutPending(false);
cartRepository.save(cart);
return new CheckoutResponse(cart.getCartId(), null, BigDecimal.ZERO, "succeeded");
}
if (amountInCents < 50) {
throw new BusinessException("Order total is too low to process payment");
}
@@ -411,12 +452,10 @@ public class CartService {
Coupon coupon = cart.getCoupon();
if (coupon != null) {
if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) {
if (isPercentageType(coupon.getDiscountType())) {
discount = subtotal.multiply(coupon.getDiscountValue())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) {
} else if (isFixedType(coupon.getDiscountType())) {
discount = coupon.getDiscountValue().min(subtotal);
}
}
@@ -442,12 +481,10 @@ public class CartService {
Coupon coupon = cart.getCoupon();
if (coupon != null) {
if ("PERCENTAGE".equalsIgnoreCase(coupon.getDiscountType())) {
if (isPercentageType(coupon.getDiscountType())) {
discount = subtotal.multiply(coupon.getDiscountValue())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
else if ("FIXED".equalsIgnoreCase(coupon.getDiscountType())) {
} else if (isFixedType(coupon.getDiscountType())) {
discount = coupon.getDiscountValue().min(subtotal);
}
}
@@ -460,6 +497,14 @@ public class CartService {
return remainingAfterCoupon.subtract(pointsDiscount).max(BigDecimal.ZERO);
}
private boolean isPercentageType(String discountType) {
return "PERCENTAGE".equalsIgnoreCase(discountType) || "PERCENT".equalsIgnoreCase(discountType);
}
private boolean isFixedType(String discountType) {
return "FIXED".equalsIgnoreCase(discountType) || "FLAT".equalsIgnoreCase(discountType);
}
private BigDecimal calculatePointsDiscount(User user, BigDecimal remainingAmount, boolean pointsApplied) {
if (!pointsApplied || user == null || remainingAmount.compareTo(BigDecimal.ZERO) <= 0) {
@@ -472,10 +517,12 @@ public class CartService {
return BigDecimal.ZERO;
}
BigDecimal maxRedeemable = remainingAmount.setScale(0, RoundingMode.DOWN);
return BigDecimal.valueOf(wholeDollars)
.min(maxRedeemable)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal maxDiscount = BigDecimal.valueOf(wholeDollars);
// If points can fully cover the remaining amount, discount the entire total to $0.00
if (maxDiscount.compareTo(remainingAmount) >= 0) {
return remainingAmount.setScale(2, RoundingMode.HALF_UP);
}
return maxDiscount.setScale(2, RoundingMode.HALF_UP);
}
private CartResponse toResponse(Cart cart) {
@@ -509,6 +556,8 @@ public class CartService {
response.setPointsDiscountAmount(cart.getPointsDiscountAmount());
response.setTotalAmount(cart.getTotalAmount());
response.setCouponCode(cart.getCoupon() != null ? cart.getCoupon().getCouponCode() : null);
response.setCouponDiscountType(cart.getCoupon() != null ? cart.getCoupon().getDiscountType() : null);
response.setCouponDiscountValue(cart.getCoupon() != null ? cart.getCoupon().getDiscountValue() : null);
response.setPointsApplied(cart.getPointsApplied());
response.setAvailableLoyaltyPoints(cart.getUser() != null ? cart.getUser().getLoyaltyPoints() : null);
response.setCheckoutPending(cart.getCheckoutPending());

Some files were not shown because too many files have changed in this diff Show More