Pet adoption appointments (currently has a small issue)
This commit is contained in:
@@ -2,6 +2,7 @@ package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.adoption.AdoptionRequest;
|
||||
import com.petshop.backend.dto.adoption.AdoptionResponse;
|
||||
import com.petshop.backend.dto.adoption.CustomerAdoptionRequest;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
@@ -81,6 +82,15 @@ public class AdoptionController {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request));
|
||||
}
|
||||
|
||||
@PostMapping("/request")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
|
||||
public ResponseEntity<AdoptionResponse> requestAdoption(@Valid @RequestBody CustomerAdoptionRequest request) {
|
||||
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(
|
||||
adoptionService.requestAdoption(user.getId(), request.getPetId(), request.getEmployeeId(), request.getSourceStoreId())
|
||||
);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AdoptionResponse> updateAdoption(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.petshop.backend.dto.adoption;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public class CustomerAdoptionRequest {
|
||||
|
||||
@NotNull(message = "Pet ID is required")
|
||||
private Long petId;
|
||||
|
||||
private Long employeeId;
|
||||
|
||||
private Long sourceStoreId;
|
||||
|
||||
public Long getPetId() {
|
||||
return petId;
|
||||
}
|
||||
|
||||
public void setPetId(Long petId) {
|
||||
this.petId = petId;
|
||||
}
|
||||
|
||||
public Long getEmployeeId() {
|
||||
return employeeId;
|
||||
}
|
||||
|
||||
public void setEmployeeId(Long employeeId) {
|
||||
this.employeeId = employeeId;
|
||||
}
|
||||
|
||||
public Long getSourceStoreId() {
|
||||
return sourceStoreId;
|
||||
}
|
||||
|
||||
public void setSourceStoreId(Long sourceStoreId) {
|
||||
this.sourceStoreId = sourceStoreId;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,33 @@ public class AdoptionService {
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId) {
|
||||
Pet pet = petRepository.findById(petId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + petId));
|
||||
User customer = userRepository.findById(customerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + customerId));
|
||||
User employee = resolveAdoptionEmployee(employeeId);
|
||||
StoreLocation sourceStore = sourceStoreId != null
|
||||
? storeRepository.findById(sourceStoreId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + sourceStoreId))
|
||||
: null;
|
||||
|
||||
validatePetAvailability(pet, null, null);
|
||||
|
||||
Adoption adoption = new Adoption();
|
||||
adoption.setPet(pet);
|
||||
adoption.setCustomer(customer);
|
||||
adoption.setEmployee(employee);
|
||||
adoption.setSourceStore(sourceStore);
|
||||
adoption.setAdoptionDate(null);
|
||||
adoption.setAdoptionStatus(ADOPTION_STATUS_PENDING);
|
||||
|
||||
adoption = adoptionRepository.save(adoption);
|
||||
syncPetStatus(pet, ADOPTION_STATUS_PENDING, adoption.getAdoptionId(), customer);
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAdoption(Long id) {
|
||||
if (!adoptionRepository.existsById(id)) {
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function PetDetailPage() {
|
||||
petStatus={pet.petStatus}
|
||||
petPrice={pet.petPrice}
|
||||
imageUrl={pet.imageUrl}
|
||||
storeId={pet.storeId}
|
||||
storeName={pet.storeName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -294,6 +294,16 @@ function AppointmentsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const preselectedPetId = searchParams.get("petId");
|
||||
|
||||
// Adoption mode — set when arriving from a pet detail page
|
||||
const adoptionMode = searchParams.get("adoptionMode") === "true";
|
||||
const adoptionPetId = searchParams.get("petId");
|
||||
const adoptionPetName = searchParams.get("petName") || "";
|
||||
const adoptionPetSpecies = searchParams.get("petSpecies") || "";
|
||||
const adoptionPetBreed = searchParams.get("petBreed") || "";
|
||||
const adoptionStoreId = searchParams.get("storeId") || "";
|
||||
const adoptionStoreName = searchParams.get("storeName") || "";
|
||||
|
||||
const didPreselectRef = useRef(false);
|
||||
|
||||
const [stores, setStores] = useState([]);
|
||||
@@ -366,9 +376,24 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}, [token, loadCustomerPets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didPreselectRef.current) {
|
||||
if (didPreselectRef.current) return;
|
||||
|
||||
if (adoptionMode) {
|
||||
// Need both the store (so employees load) and a serviceId (so availability slots load)
|
||||
if (adoptionStoreId && services.length > 0) {
|
||||
setStoreId(adoptionStoreId);
|
||||
// Prefer a service named "adopt", fall back to the first available service
|
||||
const adoptionSvc =
|
||||
services.find((s) => s.serviceName.toLowerCase().includes("adopt")) ||
|
||||
services[0];
|
||||
if (adoptionSvc) {
|
||||
setServiceId(String(adoptionSvc.serviceId));
|
||||
didPreselectRef.current = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preselectedPetId || services.length === 0 || allPets.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -382,7 +407,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}
|
||||
setSelectedPetIds([Number(preselectedPetId)]);
|
||||
didPreselectRef.current = true;
|
||||
}, [preselectedPetId, services, allPets]);
|
||||
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
|
||||
|
||||
const loadAppointments = useCallback(() => {
|
||||
|
||||
@@ -495,8 +520,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
const formValid =
|
||||
storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
|
||||
const formValid = adoptionMode
|
||||
? Boolean(employeeId && appointmentDate && appointmentTime)
|
||||
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -509,7 +535,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPetIds.length === 0) {
|
||||
if (!adoptionMode && selectedPetIds.length === 0) {
|
||||
setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet.");
|
||||
|
||||
return;
|
||||
@@ -518,6 +544,33 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (adoptionMode) {
|
||||
// Submit an adoption request directly to the adoption table
|
||||
const body = {
|
||||
petId: Number(adoptionPetId),
|
||||
employeeId: employeeId ? Number(employeeId) : undefined,
|
||||
sourceStoreId: adoptionStoreId ? Number(adoptionStoreId) : undefined,
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/adoptions/request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || data?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
setSuccess(`Adoption request submitted! ${adoptionPetName} is now marked as Pending. We'll be in touch soon.`);
|
||||
setEmployeeId("");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
customerId: user.customerId || user.id,
|
||||
storeId: Number(storeId),
|
||||
@@ -609,6 +662,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
<label className="appt-label">
|
||||
Store Location
|
||||
{adoptionMode ? (
|
||||
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={storeId}
|
||||
@@ -620,10 +676,16 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="appt-label">
|
||||
Service
|
||||
{adoptionMode ? (
|
||||
<div className="appt-locked-field">
|
||||
Adopting a Pet ({[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(", ")})
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={serviceId}
|
||||
@@ -637,6 +699,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{employees.length > 0 && (
|
||||
@@ -654,7 +717,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedService && (
|
||||
{!adoptionMode && selectedService && (
|
||||
<div className="appt-service-info">
|
||||
<p>{selectedService.serviceDesc}</p>
|
||||
</div>
|
||||
@@ -693,7 +756,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serviceId && (
|
||||
{!adoptionMode && serviceId && (
|
||||
<div className="appt-label">
|
||||
<span>{petSectionLabel}</span>
|
||||
{isCustomerPetService && (
|
||||
@@ -762,7 +825,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
className="appt-submit-btn"
|
||||
disabled={!formValid || submitting}
|
||||
>
|
||||
{submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
|
||||
{submitting ? "Booking..." : adoptionMode ? "Schedule Appointment" : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
@@ -1356,6 +1356,17 @@ body {
|
||||
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2);
|
||||
}
|
||||
|
||||
.appt-locked-field {
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: #f5f5f5;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.appt-service-info {
|
||||
background: #fff8f0;
|
||||
border: 1px solid #ffd180;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { getStatusClass } from "@/components/petUtils";
|
||||
|
||||
export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl }) {
|
||||
export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, storeId, storeName }) {
|
||||
return (
|
||||
<div className="pet-detail-card">
|
||||
<div className="pet-detail-image-wrapper">
|
||||
@@ -53,7 +53,10 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg
|
||||
<p className="pet-detail-cta-text">
|
||||
Interested in adopting {petName}? Visit us in store or schedule an appointment.
|
||||
</p>
|
||||
<Link href={`/appointments?petId=${petId}`} className="pet-detail-cta-btn">
|
||||
<Link
|
||||
href={`/appointments?adoptionMode=true&petId=${petId}&petName=${encodeURIComponent(petName || "")}&petSpecies=${encodeURIComponent(petSpecies || "")}&petBreed=${encodeURIComponent(petBreed || "")}${storeId ? `&storeId=${storeId}` : ""}${storeName ? `&storeName=${encodeURIComponent(storeName)}` : ""}`}
|
||||
className="pet-detail-cta-btn"
|
||||
>
|
||||
Schedule an Appointment
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user