Comments, appointments adjustments, fixed some issues

This commit is contained in:
augmentedpotato
2026-04-20 19:19:30 -06:00
parent d3b9c51952
commit 2cb0a94bbb
34 changed files with 402 additions and 104 deletions

View File

@@ -20,13 +20,31 @@ const SPECIES_BREEDS = {
Other: ["Other"],
};
//Services that only apply to specific species, keyed by species name
const SPECIES_EXCLUSIVE_SERVICES = {
Bird: ["wing clipping", "beak and nail"],
Fish: ["aquarium health"],
};
//Services that are banned for specific species, keyed by species name
const SPECIES_BANNED_SERVICES = {
Bird: ["teeth cleaning"],
};
//Filters out services that are exclusive to a different species, or banned for the selected species.
//When species is unknown, hides all species-exclusive and banned services to avoid invalid options appearing.
function getAvailableServices(services, species) {
if (!species) return services;
const exclusiveKeywords = Object.values(SPECIES_EXCLUSIVE_SERVICES).flat();
const allBannedKeywords = Object.values(SPECIES_BANNED_SERVICES).flat();
if (!species) {
return services.filter((s) => {
const name = s.serviceName.toLowerCase();
return !exclusiveKeywords.some((kw) => name.includes(kw)) &&
!allBannedKeywords.some((kw) => name.includes(kw));
});
}
return services.filter((s) => {
const name = s.serviceName.toLowerCase();
for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) {
@@ -34,6 +52,10 @@ function getAvailableServices(services, species) {
return false;
}
}
const banned = SPECIES_BANNED_SERVICES[species] ?? [];
if (banned.some((kw) => name.includes(kw))) {
return false;
}
return true;
});
}
@@ -41,6 +63,7 @@ function getAvailableServices(services, species) {
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
//Custom calendar date picker that prevents selecting dates in the past
function DatePicker({ value, minDate, onChange }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -137,6 +160,7 @@ const errorCls = "bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg
const successCls = "bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-4 py-3 text-[0.9rem]";
const submitBtnCls = "py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Modal dialog for quickly adding a new pet without leaving the appointments page
function AddPetModal({ token, onClose, onAdded }) {
const [petName, setPetName] = useState("");
const [species, setSpecies] = useState("");
@@ -219,6 +243,7 @@ function AddPetModal({ token, onClose, onAdded }) {
);
}
//Appointments page - book a service or adoption, and view past and active appointments
function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
@@ -279,6 +304,18 @@ function AppointmentsPage() {
const [showPastAppts, setShowPastAppts] = useState(false);
const [showPastAdoptions, setShowPastAdoptions] = useState(false);
//Pagination state for each of the four history lists
const HISTORY_PAGE_SIZE = 5;
const [apptPage, setApptPage] = useState(0);
const [pastApptPage, setPastApptPage] = useState(0);
const [adoptionPage, setAdoptionPage] = useState(0);
const [pastAdoptionPage, setPastAdoptionPage] = useState(0);
//Reset appointment page to 0 when the search text changes
useEffect(() => { setApptPage(0); }, [apptSearch]);
//Reset adoption page to 0 when the search text changes
useEffect(() => { setAdoptionPage(0); }, [adoptionSearch]);
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
useEffect(() => {
@@ -309,6 +346,7 @@ function AppointmentsPage() {
.finally(() => setAdoptionVerifyLoading(false));
}, [adoptionMode, adoptionPetId, adoptionStoreId]);
//Loads the user's registered pets for the pet selector
const loadCustomerPets = useCallback(() => {
if (!token || !canBookAppointments) return;
fetch(`${API_BASE}/api/v1/my-pets`, {
@@ -369,6 +407,7 @@ function AppointmentsPage() {
didPreselectRef.current = true;
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
//Fetches the user's booked appointments
const loadAppointments = useCallback(() => {
if (!token) return;
setLoadingAppointments(true);
@@ -381,6 +420,7 @@ function AppointmentsPage() {
.finally(() => setLoadingAppointments(false));
}, [token]);
//Fetches the user's adoption requests
const loadAdoptions = useCallback(() => {
if (!token) return;
setLoadingAdoptions(true);
@@ -398,6 +438,7 @@ function AppointmentsPage() {
loadAdoptions();
}, [loadAppointments, loadAdoptions]);
//Cancels an appointment after asking the user to confirm
async function handleCancelAppointment(appointmentId) {
if (!confirm("Cancel this appointment?")) return;
setCancellingId(appointmentId);
@@ -418,6 +459,7 @@ function AppointmentsPage() {
}
}
//Cancels an adoption request after asking the user to confirm
async function handleCancelAdoption(adoptionId) {
if (!confirm("Cancel this adoption request?")) return;
setCancellingId(adoptionId);
@@ -494,6 +536,7 @@ function AppointmentsPage() {
setServiceId(newServiceId);
}
//Selects a pet and clears the chosen service if it is not valid for that species
function handlePetSelect(petId) {
const newPet = eligiblePets.find((p) => p.customerPetId === petId);
setSelectedPetIds([petId]);
@@ -507,6 +550,7 @@ function AppointmentsPage() {
}
}
//Converts a 24-hour time string to 12-hour AM/PM format
function formatTime(timeStr) {
const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10);
@@ -524,6 +568,7 @@ function AppointmentsPage() {
? Boolean(employeeId && appointmentDate && adoptionVerified)
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
//Submits either a new appointment or an adoption request depending on the current mode
async function handleSubmit(e) {
e.preventDefault();
setError(null);
@@ -749,7 +794,7 @@ function AppointmentsPage() {
<option value="">Select a service...</option>
{availableServices.map((s) => (
<option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
{s.serviceName} - ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</option>
))}
</select>
@@ -834,6 +879,12 @@ function AppointmentsPage() {
const filteredActive = activeAppts.filter((a) =>
!q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q))
);
//Paginate active appointments
const apptTotalPages = Math.ceil(filteredActive.length / HISTORY_PAGE_SIZE);
const apptSlice = filteredActive.slice(apptPage * HISTORY_PAGE_SIZE, (apptPage + 1) * HISTORY_PAGE_SIZE);
//Paginate past appointments
const pastApptTotalPages = Math.ceil(pastAppts.length / HISTORY_PAGE_SIZE);
const pastApptSlice = pastAppts.slice(pastApptPage * HISTORY_PAGE_SIZE, (pastApptPage + 1) * HISTORY_PAGE_SIZE);
return (
<>
<input
@@ -846,61 +897,107 @@ function AppointmentsPage() {
{filteredActive.length === 0 ? (
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
) : (
<div className="flex flex-col gap-3">
{filteredActive.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
)}
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.appointmentId}
onClick={() => handleCancelAppointment(a.appointmentId)}
>
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
</button>
<>
<div className="flex flex-col gap-3">
{apptSlice.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
)}
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.appointmentId}
onClick={() => handleCancelAppointment(a.appointmentId)}
>
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
</button>
</div>
</div>
))}
</div>
{apptTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-1 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setApptPage((p) => p - 1)}
disabled={apptPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {apptPage + 1} of {apptTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setApptPage((p) => p + 1)}
disabled={apptPage >= apptTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
{pastAppts.length > 0 && (
<div className="mt-2">
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => setShowPastAppts((v) => !v)}>
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => { setShowPastAppts((v) => !v); setPastApptPage(0); }}>
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
</button>
{showPastAppts && (
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAppts.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
<>
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastApptSlice.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</div>
)}
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</div>
)}
))}
</div>
{pastApptTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-2 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastApptPage((p) => p - 1)}
disabled={pastApptPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {pastApptPage + 1} of {pastApptTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastApptPage((p) => p + 1)}
disabled={pastApptPage >= pastApptTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}
@@ -918,6 +1015,12 @@ function AppointmentsPage() {
const filteredActive = activeAdoptions.filter((a) =>
!q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q))
);
//Paginate active adoptions
const adoptionTotalPages = Math.ceil(filteredActive.length / HISTORY_PAGE_SIZE);
const adoptionSlice = filteredActive.slice(adoptionPage * HISTORY_PAGE_SIZE, (adoptionPage + 1) * HISTORY_PAGE_SIZE);
//Paginate past adoptions
const pastAdoptionTotalPages = Math.ceil(pastAdoptions.length / HISTORY_PAGE_SIZE);
const pastAdoptionSlice = pastAdoptions.slice(pastAdoptionPage * HISTORY_PAGE_SIZE, (pastAdoptionPage + 1) * HISTORY_PAGE_SIZE);
return (
<>
<input
@@ -930,55 +1033,101 @@ function AppointmentsPage() {
{filteredActive.length === 0 ? (
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
) : (
<div className="flex flex-col gap-3">
{filteredActive.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
<>
<div className="flex flex-col gap-3">
{adoptionSlice.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
</div>
</div>
))}
</div>
{adoptionTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-1 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setAdoptionPage((p) => p - 1)}
disabled={adoptionPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {adoptionPage + 1} of {adoptionTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setAdoptionPage((p) => p + 1)}
disabled={adoptionPage >= adoptionTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
{pastAdoptions.length > 0 && (
<div className="mt-2">
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => setShowPastAdoptions((v) => !v)}>
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => { setShowPastAdoptions((v) => !v); setPastAdoptionPage(0); }}>
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
</button>
{showPastAdoptions && (
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAdoptions.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
<>
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAdoptionSlice.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
</div>
))}
</div>
{pastAdoptionTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-2 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastAdoptionPage((p) => p - 1)}
disabled={pastAdoptionPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {pastAdoptionPage + 1} of {pastAdoptionTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastAdoptionPage((p) => p + 1)}
disabled={pastAdoptionPage >= pastAdoptionTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}