Pet adoption appointments (currently has a small issue)

This commit is contained in:
augmentedpotato
2026-04-14 07:14:54 -06:00
parent 995088ece2
commit 580813792a
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.AdoptionRequest;
import com.petshop.backend.dto.adoption.AdoptionResponse; 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.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
@@ -81,6 +82,15 @@ public class AdoptionController {
return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request)); 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}") @PutMapping("/{id}")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<AdoptionResponse> updateAdoption( 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); 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 @Transactional
public void deleteAdoption(Long id) { public void deleteAdoption(Long id) {
if (!adoptionRepository.existsById(id)) { if (!adoptionRepository.existsById(id)) {

View File

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

View File

@@ -294,6 +294,16 @@ function AppointmentsPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const preselectedPetId = searchParams.get("petId"); 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 didPreselectRef = useRef(false);
const [stores, setStores] = useState([]); const [stores, setStores] = useState([]);
@@ -366,9 +376,24 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
}, [token, loadCustomerPets]); }, [token, loadCustomerPets]);
useEffect(() => { 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; return;
} }
if (!preselectedPetId || services.length === 0 || allPets.length === 0) { if (!preselectedPetId || services.length === 0 || allPets.length === 0) {
return; return;
} }
@@ -382,7 +407,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
} }
setSelectedPetIds([Number(preselectedPetId)]); setSelectedPetIds([Number(preselectedPetId)]);
didPreselectRef.current = true; didPreselectRef.current = true;
}, [preselectedPetId, services, allPets]); }, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
const loadAppointments = useCallback(() => { const loadAppointments = useCallback(() => {
@@ -495,8 +520,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
return d.toISOString().split("T")[0]; return d.toISOString().split("T")[0];
} }
const formValid = const formValid = adoptionMode
storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0; ? Boolean(employeeId && appointmentDate && appointmentTime)
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
@@ -509,7 +535,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
return; return;
} }
if (selectedPetIds.length === 0) { if (!adoptionMode && selectedPetIds.length === 0) {
setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet."); setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet.");
return; return;
@@ -518,6 +544,33 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
setSubmitting(true); setSubmitting(true);
try { 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 = { const body = {
customerId: user.customerId || user.id, customerId: user.customerId || user.id,
storeId: Number(storeId), storeId: Number(storeId),
@@ -609,6 +662,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<label className="appt-label"> <label className="appt-label">
Store Location Store Location
{adoptionMode ? (
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
) : (
<select <select
className="appt-select" className="appt-select"
value={storeId} value={storeId}
@@ -620,10 +676,16 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<option key={s.id} value={s.id}>{s.label}</option> <option key={s.id} value={s.id}>{s.label}</option>
))} ))}
</select> </select>
)}
</label> </label>
<label className="appt-label"> <label className="appt-label">
Service Service
{adoptionMode ? (
<div className="appt-locked-field">
Adopting a Pet ({[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(", ")})
</div>
) : (
<select <select
className="appt-select" className="appt-select"
value={serviceId} value={serviceId}
@@ -637,6 +699,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
</option> </option>
))} ))}
</select> </select>
)}
</label> </label>
{employees.length > 0 && ( {employees.length > 0 && (
@@ -654,7 +717,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
</label> </label>
)} )}
{selectedService && ( {!adoptionMode && selectedService && (
<div className="appt-service-info"> <div className="appt-service-info">
<p>{selectedService.serviceDesc}</p> <p>{selectedService.serviceDesc}</p>
</div> </div>
@@ -693,7 +756,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
</div> </div>
)} )}
{serviceId && ( {!adoptionMode && serviceId && (
<div className="appt-label"> <div className="appt-label">
<span>{petSectionLabel}</span> <span>{petSectionLabel}</span>
{isCustomerPetService && ( {isCustomerPetService && (
@@ -762,7 +825,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
className="appt-submit-btn" className="appt-submit-btn"
disabled={!formValid || submitting} disabled={!formValid || submitting}
> >
{submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"} {submitting ? "Booking..." : adoptionMode ? "Schedule Appointment" : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
</button> </button>
</form> </form>
) : null} ) : null}

View File

@@ -1356,6 +1356,17 @@ body {
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2); 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 { .appt-service-info {
background: #fff8f0; background: #fff8f0;
border: 1px solid #ffd180; border: 1px solid #ffd180;

View File

@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { getStatusClass } from "@/components/petUtils"; 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 ( return (
<div className="pet-detail-card"> <div className="pet-detail-card">
<div className="pet-detail-image-wrapper"> <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"> <p className="pet-detail-cta-text">
Interested in adopting {petName}? Visit us in store or schedule an appointment. Interested in adopting {petName}? Visit us in store or schedule an appointment.
</p> </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 Schedule an Appointment
</Link> </Link>
</div> </div>