condition check gallery

This commit is contained in:
jackiettran
2025-12-16 13:50:23 -05:00
parent 372ab093ef
commit 27a7b641dd
8 changed files with 421 additions and 64 deletions

View File

@@ -177,7 +177,16 @@ router.get(
requireS3Enabled,
async (req, res, next) => {
try {
const { key } = req.params;
// Express wildcard params may be string or array - handle both
let key = req.params.key;
if (Array.isArray(key)) {
key = key.join("/");
}
if (!key || typeof key !== "string") {
return res.status(400).json({ error: "Invalid key parameter" });
}
// Decode URL-encoded characters (e.g., %2F -> /)
key = decodeURIComponent(key);
// Only allow private folders to use signed URLs
const isPrivate =

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { conditionCheckAPI } from "../services/api";
import { uploadFiles } from "../services/uploadService";
import { IMAGE_LIMITS } from "../config/imageLimits";
interface ConditionCheckModalProps {
@@ -83,18 +84,17 @@ const ConditionCheckModal: React.FC<ConditionCheckModalProps> = ({
setSubmitting(true);
setError(null);
const formData = new FormData();
formData.append("checkType", checkType);
if (notes.trim()) {
formData.append("notes", notes.trim());
}
// Upload photos to S3 first
const uploadResults = await uploadFiles("condition-check", photos);
const imageFilenames = uploadResults.map((result) => result.key);
photos.forEach((photo, index) => {
formData.append("photos", photo);
// Submit condition check with S3 keys
await conditionCheckAPI.submitConditionCheck(rentalId, {
checkType,
imageFilenames,
notes: notes.trim() || undefined,
});
await conditionCheckAPI.submitConditionCheck(rentalId, formData);
// Reset form
setPhotos([]);
setNotes("");
@@ -180,8 +180,8 @@ const ConditionCheckModal: React.FC<ConditionCheckModalProps> = ({
alt={`Photo ${index + 1}`}
className="img-fluid rounded"
style={{
height: "100px",
objectFit: "cover",
maxHeight: "120px",
objectFit: "contain",
width: "100%",
}}
/>

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect } from "react";
import { ConditionCheck } from "../types";
import { getSignedUrl } from "../services/uploadService";
interface ConditionCheckViewerModalProps {
show: boolean;
onHide: () => void;
conditionCheck: ConditionCheck | null;
}
const checkTypeLabels: Record<string, string> = {
pre_rental_owner: "Pre-Rental Check (Owner)",
rental_start_renter: "Rental Start Check (Renter)",
rental_end_renter: "Rental End Check (Renter)",
post_rental_owner: "Post-Rental Check (Owner)",
};
const ConditionCheckViewerModal: React.FC<ConditionCheckViewerModalProps> = ({
show,
onHide,
conditionCheck,
}) => {
const [imageUrls, setImageUrls] = useState<Map<string, string>>(new Map());
const [selectedImage, setSelectedImage] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchImageUrls = async () => {
if (!conditionCheck?.imageFilenames?.length) return;
// Filter to only valid string keys
const validKeys = conditionCheck.imageFilenames.filter(
(key): key is string => typeof key === "string" && key.length > 0
);
if (validKeys.length === 0) return;
setLoading(true);
const newUrls = new Map<string, string>();
try {
await Promise.all(
validKeys.map(async (key) => {
const url = await getSignedUrl(key);
newUrls.set(key, url);
})
);
setImageUrls(newUrls);
} catch (error) {
console.error("Failed to fetch image URLs:", error);
} finally {
setLoading(false);
}
};
if (show && conditionCheck) {
setSelectedImage(0);
fetchImageUrls();
}
}, [show, conditionCheck]);
if (!show || !conditionCheck) return null;
const submitterName = conditionCheck.submittedByUser
? `${conditionCheck.submittedByUser.firstName} ${conditionCheck.submittedByUser.lastName}`
: "Unknown";
const submittedDate = new Date(conditionCheck.submittedAt).toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
// Filter to only valid string keys for display
const validImageKeys = (conditionCheck.imageFilenames || []).filter(
(key): key is string => typeof key === "string" && key.length > 0
);
const currentImageKey = validImageKeys[selectedImage];
const currentImageUrl = currentImageKey ? imageUrls.get(currentImageKey) : undefined;
return (
<div
className="modal d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={(e) => {
if (e.target === e.currentTarget) onHide();
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-camera me-2" />
{checkTypeLabels[conditionCheck.checkType] || "Condition Check"}
</h5>
<button
type="button"
className="btn-close"
onClick={onHide}
aria-label="Close"
/>
</div>
<div className="modal-body">
{loading ? (
<div className="text-center py-5">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="mt-2 text-muted">Loading images...</p>
</div>
) : (
<>
{/* Main Image */}
<div
className="text-center mb-3"
style={{
backgroundColor: "#f8f9fa",
borderRadius: "8px",
padding: "1rem",
}}
>
{currentImageUrl ? (
<img
src={currentImageUrl}
alt={`Condition check photo ${selectedImage + 1}`}
style={{
maxWidth: "100%",
maxHeight: "400px",
objectFit: "contain",
}}
className="rounded"
/>
) : (
<div className="text-muted py-5">
<i className="bi bi-image fs-1" />
<p>Image not available</p>
</div>
)}
</div>
{/* Thumbnail Strip */}
{validImageKeys.length > 1 && (
<div className="d-flex gap-2 overflow-auto justify-content-center mb-3">
{validImageKeys.map((key, index) => {
const thumbUrl = imageUrls.get(key);
return (
<div
key={key}
onClick={() => setSelectedImage(index)}
style={{
cursor: "pointer",
border:
selectedImage === index
? "2px solid #0d6efd"
: "2px solid transparent",
borderRadius: "4px",
padding: "2px",
}}
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={`Thumbnail ${index + 1}`}
style={{
width: "60px",
height: "60px",
objectFit: "cover",
borderRadius: "4px",
}}
/>
) : (
<div
className="bg-secondary d-flex align-items-center justify-content-center"
style={{
width: "60px",
height: "60px",
borderRadius: "4px",
}}
>
<i className="bi bi-image text-white" />
</div>
)}
</div>
);
})}
</div>
)}
{/* Image Counter */}
<p className="text-center text-muted small mb-3">
Photo {selectedImage + 1} of {validImageKeys.length}
</p>
{/* Notes */}
{conditionCheck.notes && (
<div className="mb-3">
<h6>
<i className="bi bi-chat-left-text me-2" />
Notes
</h6>
<p className="mb-0 text-muted">{conditionCheck.notes}</p>
</div>
)}
{/* Metadata */}
<div className="border-top pt-3">
<small className="text-muted">
<i className="bi bi-person me-1" />
Submitted by <strong>{submitterName}</strong>
<span className="mx-2">|</span>
<i className="bi bi-calendar me-1" />
{submittedDate}
</small>
</div>
</>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onHide}>
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default ConditionCheckViewerModal;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { rentalAPI, conditionCheckAPI } from "../services/api";
import { uploadFiles } from "../services/uploadService";
import { Rental } from "../types";
interface ReturnStatusModalProps {
@@ -289,19 +290,15 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
// Submit post-rental condition check if photos are provided
if (photos.length > 0) {
const conditionCheckFormData = new FormData();
conditionCheckFormData.append("checkType", "post_rental_owner");
if (conditionNotes.trim()) {
conditionCheckFormData.append("notes", conditionNotes.trim());
}
photos.forEach((photo) => {
conditionCheckFormData.append("photos", photo);
});
// Upload photos to S3 first
const uploadResults = await uploadFiles("condition-check", photos);
const imageFilenames = uploadResults.map((result) => result.key);
await conditionCheckAPI.submitConditionCheck(
rental.id,
conditionCheckFormData
);
await conditionCheckAPI.submitConditionCheck(rental.id, {
checkType: "post_rental_owner",
imageFilenames,
notes: conditionNotes.trim() || undefined,
});
}
// Determine primary status for API call
@@ -643,8 +640,8 @@ const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
alt={`Photo ${index + 1}`}
className="img-fluid rounded"
style={{
height: "100px",
objectFit: "cover",
maxHeight: "120px",
objectFit: "contain",
width: "100%",
}}
/>

View File

@@ -51,6 +51,7 @@ const EditItem: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [imageFiles, setImageFiles] = useState<File[]>([]);
@@ -258,6 +259,7 @@ const EditItem: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
// Try to geocode the address before submitting
let geocodedCoordinates = null;
@@ -341,6 +343,8 @@ const EditItem: React.FC = () => {
}, 1500);
} catch (err: any) {
setError(err.response?.data?.message || err.message || "Failed to update item");
} finally {
setSubmitting(false);
}
};
@@ -573,9 +577,9 @@ const EditItem: React.FC = () => {
<button
type="submit"
className="btn btn-primary"
disabled={loading}
disabled={submitting}
>
{loading ? "Updating..." : "Update Listing"}
{submitting ? "Updating..." : "Update Listing"}
</button>
<button
type="button"

View File

@@ -2,13 +2,14 @@ import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import api from "../services/api";
import { Item, Rental } from "../types";
import { Item, Rental, ConditionCheck } from "../types";
import { rentalAPI, conditionCheckAPI } from "../services/api";
import { getPublicImageUrl } from "../services/uploadService";
import ReviewRenterModal from "../components/ReviewRenterModal";
import RentalCancellationModal from "../components/RentalCancellationModal";
import DeclineRentalModal from "../components/DeclineRentalModal";
import ConditionCheckModal from "../components/ConditionCheckModal";
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
import ReturnStatusModal from "../components/ReturnStatusModal";
const Owning: React.FC = () => {
@@ -63,6 +64,11 @@ const Owning: React.FC = () => {
checkType: string;
} | null>(null);
const [availableChecks, setAvailableChecks] = useState<any[]>([]);
const [conditionChecks, setConditionChecks] = useState<ConditionCheck[]>([]);
const [showConditionCheckViewer, setShowConditionCheckViewer] =
useState(false);
const [selectedConditionCheck, setSelectedConditionCheck] =
useState<ConditionCheck | null>(null);
const [showReturnStatusModal, setShowReturnStatusModal] = useState(false);
const [rentalForReturn, setRentalForReturn] = useState<Rental | null>(null);
@@ -72,6 +78,12 @@ const Owning: React.FC = () => {
fetchAvailableChecks();
}, [user]);
useEffect(() => {
if (ownerRentals.length > 0) {
fetchConditionChecks();
}
}, [ownerRentals]);
const fetchListings = async () => {
if (!user) return;
@@ -145,6 +157,28 @@ const Owning: React.FC = () => {
}
};
const fetchConditionChecks = async () => {
try {
const allChecks: ConditionCheck[] = [];
for (const rental of ownerRentals) {
try {
const response = await conditionCheckAPI.getConditionChecks(
rental.id
);
if (response.data.conditionChecks) {
allChecks.push(...response.data.conditionChecks);
}
} catch (err) {
// Skip rentals with no condition checks
}
}
setConditionChecks(allChecks);
} catch (err) {
console.error("Failed to fetch condition checks:", err);
setConditionChecks([]);
}
};
// Owner functionality handlers
const handleAcceptRental = async (rentalId: string) => {
try {
@@ -252,7 +286,12 @@ const Owning: React.FC = () => {
const handleConditionCheckSuccess = () => {
fetchAvailableChecks();
alert("Condition check submitted successfully!");
fetchConditionChecks();
};
const handleViewConditionCheck = (check: ConditionCheck) => {
setSelectedConditionCheck(check);
setShowConditionCheckViewer(true);
};
const getAvailableChecksForRental = (rentalId: string) => {
@@ -263,6 +302,11 @@ const Owning: React.FC = () => {
);
};
const getCompletedChecksForRental = (rentalId: string) => {
if (!Array.isArray(conditionChecks)) return [];
return conditionChecks.filter((check) => check.rentalId === rentalId);
};
// Filter owner rentals - exclude cancelled (shown in Rental History)
const allOwnerRentals = ownerRentals
.filter((r) => ["pending", "confirmed", "active"].includes(r.status))
@@ -307,12 +351,17 @@ const Owning: React.FC = () => {
{allOwnerRentals.map((rental) => (
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
<div className="card h-100">
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
{rental.item?.imageFilenames &&
rental.item.imageFilenames[0] && (
<img
src={getPublicImageUrl(rental.item.imageFilenames[0])}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
style={{
height: "200px",
objectFit: "contain",
backgroundColor: "#f8f9fa",
}}
/>
)}
<div className="card-body">
@@ -472,6 +521,34 @@ const Owning: React.FC = () => {
)}
</div>
{/* Condition Check Status */}
{getCompletedChecksForRental(rental.id).length > 0 && (
<div className="mb-2">
{getCompletedChecksForRental(rental.id).map(
(check) => (
<button
key={`${rental.id}-${check.checkType}-status`}
className="btn btn-link text-success small p-0 text-decoration-none d-block"
onClick={() => handleViewConditionCheck(check)}
>
{check.checkType === "pre_rental_owner"
? "Pre-Rental Condition"
: check.checkType === "rental_start_renter"
? "Rental Start Condition"
: check.checkType === "rental_end_renter"
? "Rental End Condition"
: "Post-Rental Condition"}
<small className="text-muted ms-2">
{new Date(
check.createdAt
).toLocaleDateString()}
</small>
</button>
)
)}
</div>
)}
{/* Condition Check Buttons */}
{getAvailableChecksForRental(rental.id).map((check) => (
<button
@@ -483,8 +560,8 @@ const Owning: React.FC = () => {
>
<i className="bi bi-camera me-2" />
{check.checkType === "pre_rental_owner"
? "Submit Pre-Rental Check"
: "Submit Post-Rental Check"}
? "Submit Pre-Rental Condition"
: "Submit Post-Rental Condition"}
</button>
))}
</div>
@@ -533,7 +610,11 @@ const Owning: React.FC = () => {
src={getPublicImageUrl(item.imageFilenames[0])}
className="card-img-top"
alt={item.name}
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
style={{
height: "200px",
objectFit: "contain",
backgroundColor: "#f8f9fa",
}}
/>
)}
<div className="card-body">
@@ -712,6 +793,16 @@ const Owning: React.FC = () => {
onSubmitSuccess={handleReturnStatusMarked}
/>
)}
{/* Condition Check Viewer Modal */}
<ConditionCheckViewerModal
show={showConditionCheckViewer}
onHide={() => {
setShowConditionCheckViewer(false);
setSelectedConditionCheck(null);
}}
conditionCheck={selectedConditionCheck}
/>
</div>
);
};

View File

@@ -3,10 +3,11 @@ import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { rentalAPI, conditionCheckAPI } from "../services/api";
import { getPublicImageUrl } from "../services/uploadService";
import { Rental } from "../types";
import { Rental, ConditionCheck } from "../types";
import ReviewItemModal from "../components/ReviewModal";
import RentalCancellationModal from "../components/RentalCancellationModal";
import ConditionCheckModal from "../components/ConditionCheckModal";
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
const Renting: React.FC = () => {
// Helper function to format time
@@ -53,7 +54,11 @@ const Renting: React.FC = () => {
checkType: string;
} | null>(null);
const [availableChecks, setAvailableChecks] = useState<any[]>([]);
const [conditionChecks, setConditionChecks] = useState<any[]>([]);
const [conditionChecks, setConditionChecks] = useState<ConditionCheck[]>([]);
const [showConditionCheckViewer, setShowConditionCheckViewer] =
useState(false);
const [selectedConditionCheck, setSelectedConditionCheck] =
useState<ConditionCheck | null>(null);
useEffect(() => {
fetchRentals();
@@ -149,7 +154,11 @@ const Renting: React.FC = () => {
const handleConditionCheckSuccess = () => {
fetchAvailableChecks();
fetchConditionChecks();
alert("Condition check submitted successfully!");
};
const handleViewConditionCheck = (check: ConditionCheck) => {
setSelectedConditionCheck(check);
setShowConditionCheckViewer(true);
};
const getAvailableChecksForRental = (rentalId: string) => {
@@ -231,12 +240,17 @@ const Renting: React.FC = () => {
className="card h-100"
style={{ cursor: rental.item ? "pointer" : "default" }}
>
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
{rental.item?.imageFilenames &&
rental.item.imageFilenames[0] && (
<img
src={getPublicImageUrl(rental.item.imageFilenames[0])}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "contain", backgroundColor: "#f8f9fa" }}
style={{
height: "200px",
objectFit: "contain",
backgroundColor: "#f8f9fa",
}}
/>
)}
<div className="card-body">
@@ -367,20 +381,20 @@ const Renting: React.FC = () => {
<div className="mb-2">
{getCompletedChecksForRental(rental.id).map(
(check) => (
<div
<button
key={`${rental.id}-${check.checkType}-status`}
className="text-success small"
className="btn btn-link text-success small p-0 text-decoration-none d-block"
onClick={() => handleViewConditionCheck(check)}
>
<i className="bi bi-camera-fill me-1"></i>
{check.checkType === "rental_start_renter"
? "Start Check Completed"
: "End Check Completed"}
? "Rental Start Condition Noted"
: "Rental End Condition Noted"}
<small className="text-muted ms-2">
{new Date(
check.createdAt
).toLocaleDateString()}
</small>
</div>
</button>
)
)}
</div>
@@ -397,8 +411,8 @@ const Renting: React.FC = () => {
>
<i className="bi bi-camera me-2" />
{check.checkType === "rental_start_renter"
? "Submit Start Check"
: "Submit End Check"}
? "Submit Rental Start Condition"
: "Submit Rental End Condition"}
</button>
))}
@@ -472,6 +486,16 @@ const Renting: React.FC = () => {
onSuccess={handleConditionCheckSuccess}
/>
)}
{/* Condition Check Viewer Modal */}
<ConditionCheckViewerModal
show={showConditionCheckViewer}
onHide={() => {
setShowConditionCheckViewer(false);
setSelectedConditionCheck(null);
}}
conditionCheck={selectedConditionCheck}
/>
</div>
);
};

View File

@@ -332,10 +332,10 @@ export const mapsAPI = {
};
export const conditionCheckAPI = {
submitConditionCheck: (rentalId: string, formData: FormData) =>
api.post(`/condition-checks/${rentalId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
}),
submitConditionCheck: (
rentalId: string,
data: { checkType: string; imageFilenames: string[]; notes?: string }
) => api.post(`/condition-checks/${rentalId}`, data),
getConditionChecks: (rentalId: string) =>
api.get(`/condition-checks/${rentalId}`),
getConditionCheckTimeline: (rentalId: string) =>