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.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(
|
||||||
|
|||||||
@@ -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);
|
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)) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user