Merge Attachments Branch #155

Merged
RecentRunner merged 7 commits from AttachmentsToChat into main 2026-04-08 13:44:13 -06:00
24 changed files with 369 additions and 116 deletions
Showing only changes of commit 3beb4105ea - Show all commits

View File

@@ -50,7 +50,7 @@ public class AppointmentController {
.orElse(null);
Long effectiveCustomerId = customerId;
if (role != null && role.equals("CUSTOMER")) {
if ("CUSTOMER".equals(role)) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
effectiveCustomerId = user.getId();
}
@@ -88,7 +88,7 @@ public class AppointmentController {
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
.orElse(null);
if (role != null && role.equals("CUSTOMER")) {
if ("CUSTOMER".equals(role)) {
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
if (!request.getCustomerId().equals(user.getId())) {
throw new org.springframework.security.access.AccessDeniedException("You can only create appointments for yourself");

View File

@@ -52,7 +52,7 @@ public class DropdownController {
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<List<DropdownOption>> getAdoptionPets() {
return ResponseEntity.ok(
petRepository.findAllByPetStatusIgnoreCaseOrderByPetNameAsc("Available").stream()
petRepository.findAdoptablePetsOrderByPetNameAsc().stream()
.map(p -> new DropdownOption(p.getPetId(), p.getPetName()))
.collect(Collectors.toList())
);

View File

@@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.core.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
@@ -118,7 +119,9 @@ public class GlobalExceptionHandler {
request.getRequestURI(),
LocalDateTime.now()
);
return ResponseEntity.status(status).body(error);
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(error);
}
private String buildDetails(Exception ex) {

View File

@@ -15,6 +15,14 @@ import java.util.Optional;
public interface PetRepository extends JpaRepository<Pet, Long> {
List<Pet> findAllByPetStatusIgnoreCaseOrderByPetNameAsc(String petStatus);
@Query("SELECT p FROM Pet p " +
"WHERE LOWER(p.petStatus) = 'available' " +
"AND NOT EXISTS (" +
" SELECT 1 FROM Adoption a " +
" WHERE a.pet = p AND LOWER(a.adoptionStatus) = 'completed'" +
") " +
"ORDER BY p.petName ASC")
List<Pet> findAdoptablePetsOrderByPetNameAsc();
List<Pet> findAllByOwner_IdOrderByPetNameAsc(Long ownerId);
Optional<Pet> findByIdAndOwner_Id(Long id, Long ownerId);

View File

@@ -80,7 +80,7 @@ public class AdoptionService {
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId()))
: null;
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
validatePetAvailability(pet, null);
validatePetAvailability(pet, null, null);
Adoption adoption = new Adoption();
adoption.setPet(pet);
@@ -111,7 +111,8 @@ public class AdoptionService {
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + request.getSourceStoreId()))
: null;
String adoptionStatus = normalizeAdoptionStatus(request.getAdoptionStatus());
validatePetAvailability(pet, adoption.getAdoptionId());
Long currentPetId = adoption.getPet() != null ? adoption.getPet().getPetId() : null;
validatePetAvailability(pet, adoption.getAdoptionId(), currentPetId);
adoption.setPet(pet);
adoption.setCustomer(customer);
@@ -201,7 +202,8 @@ public class AdoptionService {
throw new IllegalArgumentException("Adoption status must be Pending, Completed, or Cancelled");
}
private void validatePetAvailability(Pet pet, Long adoptionId) {
private void validatePetAvailability(Pet pet, Long adoptionId, Long currentPetId) {
boolean samePetAsCurrentAdoption = currentPetId != null && currentPetId.equals(pet.getPetId());
boolean adoptedElsewhere = adoptionId == null
? adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCase(pet.getPetId(), ADOPTION_STATUS_COMPLETED)
: adoptionRepository.existsByPet_IdAndAdoptionStatusIgnoreCaseAndAdoptionIdNot(pet.getPetId(), ADOPTION_STATUS_COMPLETED, adoptionId);
@@ -209,7 +211,7 @@ public class AdoptionService {
throw new IllegalArgumentException("Selected pet has already been adopted");
}
if (!PET_STATUS_AVAILABLE.equalsIgnoreCase(pet.getPetStatus()) && adoptionId == null) {
if (!samePetAsCurrentAdoption && !PET_STATUS_AVAILABLE.equalsIgnoreCase(pet.getPetStatus())) {
throw new IllegalArgumentException("Selected pet is not available for adoption");
}
}

View File

@@ -247,7 +247,13 @@ public class PetService {
if (principal instanceof AppPrincipal appPrincipal) {
return new CurrentViewer(appPrincipal.getUserId(), appPrincipal.getRole());
}
return null;
String username = authentication.getName();
if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) {
return null;
}
return userRepository.findByUsername(username)
.map(user -> new CurrentViewer(user.getId(), user.getRole()))
.orElse(null);
}
private Pet findPet(Long id) {

View File

@@ -245,9 +245,6 @@ public class SaleService {
}
String normalized = paymentMethod.trim();
if (normalized.equalsIgnoreCase("Debit")) {
return "Card";
}
if (normalized.equalsIgnoreCase("Cash")) {
return "Cash";
}

View File

@@ -83,8 +83,9 @@ public class AdoptionController {
tvAdoptions.getSelectionModel().selectedItemProperty().addListener(
(observable, oldValue, newValue) -> {
btnEdit.setDisable(false);
btnDelete.setDisable(false);
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
});
txtSearch.textProperty().addListener((observable, oldValue, newValue) -> {

View File

@@ -47,7 +47,8 @@ public class AppointmentController {
@FXML
public void initialize(){
btnEdit.setDisable(true);
btnDelete.setDisable(true);
tvAppointments.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
colAppointmentId.setCellValueFactory(new PropertyValueFactory<>("appointmentId"));
@@ -66,6 +67,12 @@ public class AppointmentController {
txtSearch.textProperty().addListener((obs, o, n) -> applyFilter(n));
}
tvAppointments.getSelectionModel().selectedItemProperty().addListener((obs, oldValue, newValue) -> {
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
});
tvAppointments.setOnKeyPressed(event -> {
if (event.getCode() == javafx.scene.input.KeyCode.DELETE) {
if (tvAppointments.getSelectionModel().getSelectedItem() != null) {

View File

@@ -123,29 +123,20 @@ public class ChatController {
@FXML
void btnSendClicked() {
try {
if (selectedConversation == null) {
lblChatStatus.setText("Select a conversation");
return;
}
String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim();
if (content.isEmpty()) {
return;
}
txtMessage.clear();
boolean sent = realtimeClient.sendMessage(selectedConversation.getId(), content);
if (!sent) {
sendMessageFallback(selectedConversation.getId(), content);
}
} catch (Exception e) {
ActivityLogger.getInstance().logException(
"ChatController.btnSendClicked",
e,
"Sending chat message");
lblChatStatus.setText("Chat send failed");
if (selectedConversation == null) {
lblChatStatus.setText("Select a conversation");
return;
}
String content = txtMessage.getText() == null ? "" : txtMessage.getText().trim();
if (content.isEmpty()) {
return;
}
txtMessage.clear();
btnSend.setDisable(true);
lblChatStatus.setText("Sending message...");
sendMessage(selectedConversation.getId(), content);
}
private void loadCustomers() {
@@ -211,19 +202,29 @@ public class ChatController {
}).start();
}
private void sendMessageFallback(Long conversationId, String content) {
private void sendMessage(Long conversationId, String content) {
new Thread(() -> {
try {
MessageResponse response = ChatApi.getInstance().sendMessage(conversationId, new MessageRequest(content));
Platform.runLater(() -> {
lblChatStatus.setText("Chat fallback active");
appendMessageIfSelected(response);
btnSend.setDisable(false);
if (!realtimeClient.isConnected()) {
appendMessageIfSelected(response);
}
if (selectedConversation != null && selectedConversation.getId().equals(conversationId)) {
lblChatStatus.setText("Message sent");
}
});
} catch (Exception e) {
Platform.runLater(() -> ActivityLogger.getInstance().logException(
"ChatController.sendMessageFallback",
e,
"Sending chat message for conversation " + conversationId));
Platform.runLater(() -> {
txtMessage.setText(content);
btnSend.setDisable(false);
lblChatStatus.setText("Chat send failed");
ActivityLogger.getInstance().logException(
"ChatController.sendMessage",
e,
"Sending chat message for conversation " + conversationId);
});
}
}).start();
}

View File

@@ -73,8 +73,9 @@ public class InventoryController {
tvInventory.getSelectionModel().selectedItemProperty().addListener(
(observable, oldValue, newValue) -> {
btnEdit.setDisable(false);
btnDelete.setDisable(false);
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
});
txtSearch.textProperty().addListener((observable, oldValue, newValue) -> {

View File

@@ -175,8 +175,9 @@ public class PetController {
tvPets.getSelectionModel().selectedItemProperty().addListener(
(observable, oldValue, newValue) -> {
btnEdit.setDisable(false);
btnDelete.setDisable(false);
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
});
txtSearch.textProperty().addListener((observable, oldValue, newValue) -> {

View File

@@ -99,8 +99,9 @@ public class ProductController {
//EventListener to Enable buttons when a row is selected
tvProducts.getSelectionModel().selectedItemProperty().addListener(
(observable, oldValue, newValue) -> {
btnEdit.setDisable(false);
btnDelete.setDisable(false);
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
}
);

View File

@@ -80,8 +80,9 @@ public class ProductSupplierController {
//EventListener to Enable buttons when a row is selected
tvProductSuppliers.getSelectionModel().selectedItemProperty().addListener(
(observable, oldValue, newValue) -> {
btnEdit.setDisable(false);
btnDelete.setDisable(false);
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
}
);

View File

@@ -57,8 +57,9 @@ public class ServiceController {
tvServices.getSelectionModel().selectedItemProperty().addListener(
(observable, oldValue, newValue) -> {
btnEdit.setDisable(false);
btnDelete.setDisable(false);
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
}
);
@@ -230,4 +231,4 @@ public class ServiceController {
response.getServicePrice().doubleValue()
);
}
}
}

View File

@@ -82,8 +82,9 @@ public class SupplierController {
//EventListener to Enable buttons when a row is selected
tvSuppliers.getSelectionModel().selectedItemProperty().addListener(
(observable, oldValue, newValue) -> {
btnEdit.setDisable(false);
btnDelete.setDisable(false);
boolean hasSelection = newValue != null;
btnEdit.setDisable(!hasSelection);
btnDelete.setDisable(!hasSelection);
}
);
@@ -285,19 +286,21 @@ public class SupplierController {
}
private Supplier mapToSupplier(SupplierResponse response) {
String contactPerson = response.getSupContactFirstName() + " " + response.getSupContactLastName() != null ? response.getSupContactFirstName() + " " + response.getSupContactLastName() : "";
String firstName = response.getSupContactFirstName() != null ? response.getSupContactFirstName().trim() : "";
String lastName = response.getSupContactLastName() != null ? response.getSupContactLastName().trim() : "";
String contactPerson = (firstName + " " + lastName).trim();
String[] nameParts = contactPerson.split(" ", 2);
String firstName = nameParts.length > 0 ? nameParts[0] : "";
String lastName = nameParts.length > 1 ? nameParts[1] : "";
String mappedFirstName = nameParts.length > 0 ? nameParts[0] : "";
String mappedLastName = nameParts.length > 1 ? nameParts[1] : "";
return new Supplier(
response.getSupId().intValue(),
response.getSupCompany(),
firstName,
lastName,
mappedFirstName,
mappedLastName,
response.getSupEmail(),
response.getSupPhone()
);
}
}
}

View File

@@ -74,6 +74,7 @@ public class AdoptionDialogController {
Platform.runLater(() -> {
if (pets != null) {
ObservableList<DropdownOption> petsObs = FXCollections.observableArrayList(pets);
ensureSelectedPetOption(petsObs);
cbPet.setItems(petsObs);
applySelectedPet();
}
@@ -318,4 +319,17 @@ public class AdoptionDialogController {
}
return null;
}
private void ensureSelectedPetOption(ObservableList<DropdownOption> options) {
if (selectedAdoption == null || selectedAdoption.getPetId() <= 0 || options == null) {
return;
}
DropdownOption existing = findOptionById(options, (long) selectedAdoption.getPetId());
if (existing == null) {
DropdownOption option = new DropdownOption();
option.setId((long) selectedAdoption.getPetId());
option.setLabel(selectedAdoption.getPetName());
options.add(0, option);
}
}
}

View File

@@ -9,6 +9,7 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import org.example.petshopdesktop.Validator;
import org.example.petshopdesktop.api.dto.common.DropdownOption;
@@ -48,6 +49,12 @@ public class PetDialogController {
@FXML
private ComboBox<DropdownOption> cbStore;
@FXML
private VBox vbCustomerField;
@FXML
private VBox vbStoreField;
@FXML
private Label lblMode;
@@ -84,7 +91,7 @@ public class PetDialogController {
private Long pendingStoreId = null;
private ObservableList<String> statusList = FXCollections.observableArrayList(
"Available", "Adopted", "Owned"
"Available", "Adopted", "Owned", "Pending"
);
@FXML
@@ -118,14 +125,11 @@ public class PetDialogController {
}
});
cbCustomer.setVisible(false);
cbStore.setVisible(false);
setFieldVisibility(vbCustomerField, false);
setFieldVisibility(vbStoreField, false);
cbPetStatus.valueProperty().addListener((obs, oldVal, newVal) -> {
boolean isOwned = "Owned".equalsIgnoreCase(newVal);
boolean isAvailable = "Available".equalsIgnoreCase(newVal) || "Unadopted".equalsIgnoreCase(newVal);
cbCustomer.setVisible(isOwned);
cbStore.setVisible(isAvailable);
updateStatusFieldVisibility(newVal);
});
btnSave.setOnMouseClicked(new EventHandler<MouseEvent>() {
@@ -166,6 +170,9 @@ public class PetDialogController {
if ("Owned".equalsIgnoreCase(selectedStatus) && cbCustomer.getValue() == null) {
errorMsg += "Customer is required for Owned status\n";
}
if (requiresStore(selectedStatus) && cbStore.getValue() == null) {
errorMsg += "Store is required for " + selectedStatus + " status\n";
}
//Check validation (length size)
errorMsg += Validator.isLessThanVarChars(txtPetName.getText(), "Pet Name", 50);
@@ -243,7 +250,7 @@ public class PetDialogController {
if ("Owned".equalsIgnoreCase(status) && cbCustomer.getValue() != null) {
request.setCustomerId(cbCustomer.getValue().getId());
}
if (("Available".equalsIgnoreCase(status) || "Unadopted".equalsIgnoreCase(status)) && cbStore.getValue() != null) {
if (requiresStore(status) && cbStore.getValue() != null) {
request.setStoreId(cbStore.getValue().getId());
}
@@ -339,8 +346,10 @@ public class PetDialogController {
for (String status : cbPetStatus.getItems()) {
if(status.equals(pet.getPetStatus())){
cbPetStatus.getSelectionModel().select(status);
break;
}
}
updateStatusFieldVisibility(cbPetStatus.getValue());
}
}
@@ -358,6 +367,7 @@ public class PetDialogController {
lblPetId.setVisible(true);
refreshImagePreview();
}
updateStatusFieldVisibility(cbPetStatus.getValue());
}
private void handleChangeImage() {
@@ -421,4 +431,25 @@ public class PetDialogController {
btnRemoveImage.setDisable(true);
}
private void updateStatusFieldVisibility(String status) {
boolean owned = "Owned".equalsIgnoreCase(status);
boolean storeBased = requiresStore(status);
setFieldVisibility(vbCustomerField, owned);
setFieldVisibility(vbStoreField, storeBased);
}
private boolean requiresStore(String status) {
return "Available".equalsIgnoreCase(status)
|| "Pending".equalsIgnoreCase(status)
|| "Unadopted".equalsIgnoreCase(status);
}
private void setFieldVisibility(VBox field, boolean visible) {
if (field == null) {
return;
}
field.setVisible(visible);
field.setManaged(visible);
}
}

View File

@@ -19,6 +19,7 @@ import org.example.petshopdesktop.api.endpoints.ProductSupplierApi;
import org.example.petshopdesktop.util.ActivityLogger;
import java.math.BigDecimal;
import java.util.List;
public class ProductSupplierDialogController {
@@ -46,6 +47,8 @@ public class ProductSupplierDialogController {
private String mode = null;
private int selectedSupId = -1;
private int selectedProdId = -1;
private Long pendingSupplierId = null;
private Long pendingProductId = null;
/**
* add event listeners to buttons and set up combobox
@@ -120,9 +123,11 @@ public class ProductSupplierDialogController {
Platform.runLater(() -> {
if (suppliers != null) {
cbSupplier.setItems(FXCollections.observableArrayList(suppliers));
applyPendingSupplierSelection();
}
if (products != null) {
cbProduct.setItems(FXCollections.observableArrayList(products));
applyPendingProductSelection();
}
});
} catch (Exception e) {
@@ -220,21 +225,14 @@ public class ProductSupplierDialogController {
* @param productSupplier
*/
public void displayProductSupplierDetails(ProductSupplierDTO productSupplier){
if(productSupplier != null){
txtCost.setText(productSupplier.getCost() + "");
}
for (DropdownOption product : cbProduct.getItems()) {
if(product.getId() == productSupplier.getProdId()){
cbProduct.getSelectionModel().select(product);
}
}
for (DropdownOption supplier : cbSupplier.getItems()) {
if (supplier.getId() == productSupplier.getSupId()) {
cbSupplier.getSelectionModel().select(supplier);
}
if (productSupplier == null) {
return;
}
txtCost.setText(productSupplier.getCost() + "");
pendingProductId = (long) productSupplier.getProdId();
pendingSupplierId = (long) productSupplier.getSupId();
applyPendingProductSelection();
applyPendingSupplierSelection();
}
/**
@@ -253,7 +251,7 @@ public class ProductSupplierDialogController {
*/
public void setMode(String mode) {
this.mode = mode;
lblMode.setText(mode + " Product");
lblMode.setText(mode + " Product-Supplier");
lblProductSupplierId.setVisible(false);
}
@@ -267,4 +265,38 @@ public class ProductSupplierDialogController {
this.selectedProdId = prodId;
}
private void applyPendingProductSelection() {
if (pendingProductId == null) {
return;
}
DropdownOption product = findOptionById(cbProduct.getItems(), pendingProductId);
if (product != null) {
cbProduct.getSelectionModel().select(product);
pendingProductId = null;
}
}
private void applyPendingSupplierSelection() {
if (pendingSupplierId == null) {
return;
}
DropdownOption supplier = findOptionById(cbSupplier.getItems(), pendingSupplierId);
if (supplier != null) {
cbSupplier.getSelectionModel().select(supplier);
pendingSupplierId = null;
}
}
private DropdownOption findOptionById(List<DropdownOption> options, Long id) {
if (options == null || id == null) {
return null;
}
for (DropdownOption option : options) {
if (option.getId() != null && option.getId().equals(id)) {
return option;
}
}
return null;
}
}

View File

@@ -99,7 +99,7 @@ public class RefundDialogController {
@FXML
public void initialize() {
setupTables();
cbPaymentMethod.setItems(FXCollections.observableArrayList("Cash", "Card", "Debit"));
cbPaymentMethod.setItems(FXCollections.observableArrayList("Cash", "Card"));
cbPaymentMethod.getSelectionModel().selectFirst();
updateRefundTotal();
}

View File

@@ -153,7 +153,7 @@
</TextField>
</children>
</VBox>
<VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0" GridPane.rowIndex="3">
<VBox fx:id="vbCustomerField" prefHeight="200.0" prefWidth="100.0" spacing="8.0" GridPane.rowIndex="3">
<children>
<Label text="Customer:" textFill="#2c3e50">
<font>
@@ -167,7 +167,7 @@
</ComboBox>
</children>
</VBox>
<VBox prefHeight="200.0" prefWidth="100.0" spacing="8.0" GridPane.columnIndex="1" GridPane.rowIndex="3">
<VBox fx:id="vbStoreField" prefHeight="200.0" prefWidth="100.0" spacing="8.0" GridPane.columnIndex="1" GridPane.rowIndex="3">
<children>
<Label text="Store:" textFill="#2c3e50">
<font>

View File

@@ -195,6 +195,100 @@ function DatePicker({ value, minDate, onChange }) {
);
}
function AddPetModal({ token, onClose, onAdded }) {
const [petName, setPetName] = useState("");
const [species, setSpecies] = useState("");
const [breed, setBreed] = useState("");
const [submitting, setSubmitting] = useState(false);
const [petError, setPetError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setPetError(null);
setSubmitting(true);
try {
const res = await fetch(`${API_BASE}/api/v1/my-pets`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ petName, species, breed: breed || null }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || `Request failed (${res.status})`);
}
onAdded();
onClose();
}
catch (err) {
setPetError(err.message);
}
finally {
setSubmitting(false);
}
}
return (
<div className="appt-modal-overlay" onClick={onClose}>
<div className="appt-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="profile-pet-form-title">Add a New Pet</h3>
{petError && <div className="appt-error">{petError}</div>}
<form onSubmit={handleSubmit}>
<label className="appt-label">
Name
<input
className="appt-input"
type="text"
value={petName}
onChange={(e) => setPetName(e.target.value)}
required
maxLength={50}
/>
</label>
<label className="appt-label">
Species
<input
className="appt-input"
type="text"
value={species}
onChange={(e) => setSpecies(e.target.value)}
required
maxLength={50}
placeholder="e.g. Dog, Cat, Bird"
/>
</label>
<label className="appt-label">
Breed (optional)
<input
className="appt-input"
type="text"
value={breed}
onChange={(e) => setBreed(e.target.value)}
maxLength={50}
placeholder="e.g. Golden Retriever"
/>
</label>
<div className="profile-pet-form-actions">
<button type="submit" className="appt-submit-btn" disabled={submitting}>
{submitting ? "Saving..." : "Add Pet"}
</button>
<button type="button" className="profile-pet-cancel-btn" onClick={onClose}>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
@@ -224,7 +318,9 @@ function AppointmentsPage() {
const [appointments, setAppointments] = useState([]);
const [loadingAppointments, setLoadingAppointments] = useState(false);
const canBookAppointments = user?.role === "CUSTOMER";
const [showAddPetModal, setShowAddPetModal] = useState(false);
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
useEffect(() => {
if (!authLoading && !user) {
@@ -234,6 +330,16 @@ function AppointmentsPage() {
}, [authLoading, user, router, preselectedPetId]);
const loadCustomerPets = useCallback(() => {
if (!token || !canBookAppointments) return;
fetch(`${API_BASE}/api/v1/my-pets`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setCustomerPets(Array.isArray(data) ? data : []))
.catch(() => {});
}, [token, canBookAppointments]);
useEffect(() => {
if (!token) {
return;
@@ -256,15 +362,8 @@ function AppointmentsPage() {
.then((data) => setAllPets(data.content ?? []))
.catch(() => {});
if (canBookAppointments) {
fetch(`${API_BASE}/api/v1/my-pets`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setCustomerPets(Array.isArray(data) ? data : []))
.catch(() => {});
}
}, [token, canBookAppointments]);
loadCustomerPets();
}, [token, loadCustomerPets]);
useEffect(() => {
if (didPreselectRef.current) {
@@ -392,7 +491,6 @@ function AppointmentsPage() {
function getMinDate() {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().split("T")[0];
}
@@ -411,12 +509,6 @@ function AppointmentsPage() {
return;
}
if (!user?.customerId) {
setError("Customer account not found. Please contact support.");
return;
}
if (selectedPetIds.length === 0) {
setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet.");
@@ -427,7 +519,7 @@ function AppointmentsPage() {
try {
const body = {
customerId: user.customerId,
customerId: user.customerId || user.id,
storeId: Number(storeId),
serviceId: Number(serviceId),
employeeId: employeeId ? Number(employeeId) : undefined,
@@ -436,12 +528,8 @@ function AppointmentsPage() {
appointmentStatus: "Booked",
};
if (isCustomerPetService) {
body.customerPetIds = selectedPetIds;
}
else {
body.petIds = selectedPetIds;
if (selectedPetIds.length > 0) {
body.petId = selectedPetIds[0];
}
const res = await fetch(`${API_BASE}/api/v1/appointments`, {
@@ -493,10 +581,18 @@ function AppointmentsPage() {
const petSectionLabel = isAdoptionService ? "Select a Pet to Adopt" : "Select Pet(s)";
const noPetsMessage = isAdoptionService
? "No pets are currently available for adoption."
: "No pets found. Please add your pets in your profile before booking.";
: "No pets found on your profile.";
return (
<main className="appt-page">
{showAddPetModal && (
<AddPetModal
token={token}
onClose={() => setShowAddPetModal(false)}
onAdded={loadCustomerPets}
/>
)}
<section className="appt-hero">
<h1 className="appt-hero-title">Schedule an Appointment</h1>
<p className="appt-hero-subtitle">Book a service for your pet or schedule a pet adoption visit</p>
@@ -600,6 +696,15 @@ function AppointmentsPage() {
{serviceId && (
<div className="appt-label">
<span>{petSectionLabel}</span>
{isCustomerPetService && (
<button
type="button"
className="appt-add-pet-btn"
onClick={() => setShowAddPetModal(true)}
>
+ Add New Pet
</button>
)}
{petsToShow.length === 0 ? (
<p className="appt-no-slots">{noPetsMessage}</p>
) : isAdoptionService ? (

View File

@@ -1351,6 +1351,44 @@ body {
accent-color: orange;
}
.appt-add-pet-btn {
display: inline-block;
margin-top: 0.5rem;
padding: 0.4rem 0.85rem;
background: none;
border: 1.5px solid orange;
border-radius: 6px;
color: orange;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.appt-add-pet-btn:hover {
background: orange;
color: white;
}
.appt-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.appt-modal {
background: white;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.appt-link {
color: orange;
font-weight: 600;

View File

@@ -108,7 +108,7 @@ export default function ProfilePage() {
}, [clearPetImageObjectUrls]);
useEffect(() => {
if (user?.role === "CUSTOMER") {
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
loadPets();
}
}, [user, loadPets]);
@@ -419,7 +419,7 @@ export default function ProfilePage() {
</button>
</div>
{user.role === "CUSTOMER" && (
{(user.role === "CUSTOMER" || user.role === "ADMIN") && (
<div className="profile-pets-section">
<div className="profile-pets-header">
<h2 className="profile-pets-title">My Pets</h2>