Pet adoption appointments (currently has a small issue)

This commit is contained in:
augmentedpotato
2026-04-14 07:14:54 -06:00
parent 7303c22cd3
commit 1f389ca25c
7 changed files with 187 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,8 @@ export default function PetDetailPage() {
petStatus={pet.petStatus}
petPrice={pet.petPrice}
imageUrl={pet.imageUrl}
storeId={pet.storeId}
storeName={pet.storeName}
/>
)}
</div>

View File

@@ -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,34 +662,44 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<label className="appt-label">
Store Location
<select
className="appt-select"
value={storeId}
onChange={(e) => setStoreId(e.target.value)}
required
>
<option value="">Select a store...</option>
{stores.map((s) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
{adoptionMode ? (
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
) : (
<select
className="appt-select"
value={storeId}
onChange={(e) => setStoreId(e.target.value)}
required
>
<option value="">Select a store...</option>
{stores.map((s) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
)}
</label>
<label className="appt-label">
Service
<select
className="appt-select"
value={serviceId}
onChange={(e) => handleServiceChange(e.target.value)}
required
>
<option value="">Select a service...</option>
{services.map((s) => (
<option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</option>
))}
</select>
{adoptionMode ? (
<div className="appt-locked-field">
Adopting a Pet ({[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(", ")})
</div>
) : (
<select
className="appt-select"
value={serviceId}
onChange={(e) => handleServiceChange(e.target.value)}
required
>
<option value="">Select a service...</option>
{services.map((s) => (
<option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</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}

View File

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

View File

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