condition check gallery
This commit is contained in:
@@ -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%",
|
||||
}}
|
||||
/>
|
||||
|
||||
232
frontend/src/components/ConditionCheckViewerModal.tsx
Normal file
232
frontend/src/components/ConditionCheckViewerModal.tsx
Normal 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;
|
||||
@@ -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%",
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user