email plus return item statuses
This commit is contained in:
262
frontend/src/components/ConditionCheckModal.tsx
Normal file
262
frontend/src/components/ConditionCheckModal.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState } from "react";
|
||||
import { conditionCheckAPI } from "../services/api";
|
||||
|
||||
interface ConditionCheckModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
rentalId: string;
|
||||
checkType: string;
|
||||
itemName: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ConditionCheckModal: React.FC<ConditionCheckModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
rentalId,
|
||||
checkType,
|
||||
itemName,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [photos, setPhotos] = useState<File[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getCheckTypeInfo = () => {
|
||||
const types = {
|
||||
pre_rental_owner: {
|
||||
title: "Pre-Rental Condition",
|
||||
description:
|
||||
"Document the current condition of your item before the rental begins. Take clear photos showing all sides and any existing wear or damage",
|
||||
},
|
||||
rental_start_renter: {
|
||||
title: "Rental Start Condition",
|
||||
description:
|
||||
"Document the condition of the item when you receive it. Take photos of any damage or issues you notice upon receiving the item",
|
||||
},
|
||||
rental_end_renter: {
|
||||
title: "Rental End Condition",
|
||||
description:
|
||||
"Document the condition of the item before returning it. Take photos showing the item's condition before you return it",
|
||||
},
|
||||
post_rental_owner: {
|
||||
title: "Post-Rental Condition",
|
||||
description:
|
||||
"Document the condition of your item after it's been returned. Take photos of the returned item including any damage or issues",
|
||||
},
|
||||
};
|
||||
return (
|
||||
types[checkType as keyof typeof types] || {
|
||||
title: "Condition Check",
|
||||
description: "Document the item's condition",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const typeInfo = getCheckTypeInfo();
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
if (selectedFiles.length + photos.length > 20) {
|
||||
setError("Maximum 20 photos allowed");
|
||||
return;
|
||||
}
|
||||
setPhotos((prev) => [...prev, ...selectedFiles]);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removePhoto = (index: number) => {
|
||||
setPhotos((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (photos.length === 0) {
|
||||
setError("Please upload at least one photo");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("checkType", checkType);
|
||||
if (notes.trim()) {
|
||||
formData.append("notes", notes.trim());
|
||||
}
|
||||
|
||||
photos.forEach((photo, index) => {
|
||||
formData.append("photos", photo);
|
||||
});
|
||||
|
||||
await conditionCheckAPI.submitConditionCheck(rentalId, formData);
|
||||
|
||||
// Reset form
|
||||
setPhotos([]);
|
||||
setNotes("");
|
||||
onSuccess();
|
||||
onHide();
|
||||
} catch (error: any) {
|
||||
setError(
|
||||
error.response?.data?.error || "Failed to submit condition check"
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPhotos([]);
|
||||
setNotes("");
|
||||
setError(null);
|
||||
onHide();
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal show d-block"
|
||||
tabIndex={-1}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<div className="modal-dialog modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
<i className="bi bi-camera me-2" />
|
||||
{typeInfo.title}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="mb-4">
|
||||
<h6 className="text-center text-dark">{itemName}</h6>
|
||||
<p className="text-muted mb-2">{typeInfo.description}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
Photos <span className="text-danger">*</span>
|
||||
<small className="text-muted ms-2">(Maximum 20 photos)</small>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
Selected Photos ({photos.length})
|
||||
</label>
|
||||
<div className="row">
|
||||
{photos.map((photo, index) => (
|
||||
<div key={index} className="col-md-3 mb-2">
|
||||
<div className="position-relative">
|
||||
<img
|
||||
src={URL.createObjectURL(photo)}
|
||||
alt={`Photo ${index + 1}`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
height: "100px",
|
||||
objectFit: "cover",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm position-absolute top-0 end-0"
|
||||
onClick={() => removePhoto(index)}
|
||||
disabled={submitting}
|
||||
style={{ transform: "translate(50%, -50%)" }}
|
||||
>
|
||||
<i className="bi bi-x" />
|
||||
</button>
|
||||
</div>
|
||||
<small className="text-muted d-block text-center mt-1">
|
||||
{photo.name.length > 15
|
||||
? `${photo.name.substring(0, 15)}...`
|
||||
: photo.name}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Notes (Optional)</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any additional notes about the item's condition"
|
||||
maxLength={500}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="form-text">{notes.length}/500 characters</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || photos.length === 0}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<div
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-check-lg me-2" />
|
||||
Submit Condition Check
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionCheckModal;
|
||||
954
frontend/src/components/ReturnStatusModal.tsx
Normal file
954
frontend/src/components/ReturnStatusModal.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||
import { Rental } from "../types";
|
||||
|
||||
interface ReturnStatusModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
rental: Rental;
|
||||
onReturnMarked: (updatedRental: Rental) => void;
|
||||
onSubmitSuccess?: (updatedRental: Rental) => void;
|
||||
}
|
||||
|
||||
const ReturnStatusModal: React.FC<ReturnStatusModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
rental,
|
||||
onReturnMarked,
|
||||
onSubmitSuccess,
|
||||
}) => {
|
||||
const [statusOptions, setStatusOptions] = useState({
|
||||
returned: false,
|
||||
returned_late: false,
|
||||
damaged: false,
|
||||
lost: false,
|
||||
});
|
||||
const [actualReturnDateTime, setActualReturnDateTime] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [conditionNotes, setConditionNotes] = useState("");
|
||||
const [photos, setPhotos] = useState<File[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lateFeeCalculation, setLateFeeCalculation] = useState<{
|
||||
lateHours: number;
|
||||
lateFee: number;
|
||||
isLate: boolean;
|
||||
pricingType?: "hourly" | "daily";
|
||||
billableDays?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Damage assessment fields
|
||||
const [canBeFixed, setCanBeFixed] = useState<boolean | null>(null);
|
||||
const [repairCost, setRepairCost] = useState("");
|
||||
const [needsReplacement, setNeedsReplacement] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
const [replacementCost, setReplacementCost] = useState("");
|
||||
const [proofOfOwnership, setProofOfOwnership] = useState<File[]>([]);
|
||||
|
||||
// Initialize form when modal opens
|
||||
useEffect(() => {
|
||||
if (show && rental) {
|
||||
setStatusOptions({
|
||||
returned: false,
|
||||
returned_late: false,
|
||||
damaged: false,
|
||||
lost: false,
|
||||
});
|
||||
setActualReturnDateTime("");
|
||||
setNotes("");
|
||||
setConditionNotes("");
|
||||
setPhotos([]);
|
||||
setError(null);
|
||||
setLateFeeCalculation(null);
|
||||
setCanBeFixed(null);
|
||||
setRepairCost("");
|
||||
setNeedsReplacement(null);
|
||||
setReplacementCost("");
|
||||
setProofOfOwnership([]);
|
||||
}
|
||||
}, [show, rental]);
|
||||
|
||||
const formatCurrency = (amount: number | string | undefined) => {
|
||||
const numAmount = Number(amount) || 0;
|
||||
return `$${numAmount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatDateTime = (date: Date) => {
|
||||
// Format for datetime-local input in local timezone
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// Calculate late fee when actual return date/time changes
|
||||
useEffect(() => {
|
||||
const fetchLateFeeCalculation = async () => {
|
||||
if (statusOptions.returned_late && actualReturnDateTime && rental) {
|
||||
try {
|
||||
const response = await rentalAPI.getLateFeePreview(
|
||||
rental.id,
|
||||
actualReturnDateTime
|
||||
);
|
||||
setLateFeeCalculation(response.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching late fee calculation:", error);
|
||||
setLateFeeCalculation(null);
|
||||
}
|
||||
} else {
|
||||
setLateFeeCalculation(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLateFeeCalculation();
|
||||
}, [actualReturnDateTime, statusOptions.returned_late, rental]);
|
||||
|
||||
const handleStatusChange = (
|
||||
statusType: "returned" | "returned_late" | "damaged" | "lost",
|
||||
checked: boolean
|
||||
) => {
|
||||
setStatusOptions((prev) => {
|
||||
const newOptions = { ...prev };
|
||||
|
||||
// Apply the change
|
||||
newOptions[statusType] = checked;
|
||||
|
||||
// Apply mutual exclusion logic
|
||||
if (statusType === "returned" && checked) {
|
||||
newOptions.returned_late = false;
|
||||
newOptions.lost = false;
|
||||
}
|
||||
if (statusType === "returned_late" && checked) {
|
||||
newOptions.returned = false;
|
||||
newOptions.lost = false;
|
||||
// Set default return time for late returns
|
||||
if (!actualReturnDateTime) {
|
||||
setActualReturnDateTime(formatDateTime(new Date()));
|
||||
}
|
||||
}
|
||||
if (statusType === "damaged" && checked) {
|
||||
newOptions.lost = false;
|
||||
}
|
||||
if (statusType === "lost" && checked) {
|
||||
// If item is lost, uncheck all other options
|
||||
newOptions.returned = false;
|
||||
newOptions.returned_late = false;
|
||||
newOptions.damaged = false;
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
if (selectedFiles.length + photos.length > 20) {
|
||||
setError("Maximum 20 photos allowed");
|
||||
return;
|
||||
}
|
||||
setPhotos((prev) => [...prev, ...selectedFiles]);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removePhoto = (index: number) => {
|
||||
setPhotos((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleProofOfOwnershipChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
if (selectedFiles.length + proofOfOwnership.length > 5) {
|
||||
setError("Maximum 5 proof of ownership files allowed");
|
||||
return;
|
||||
}
|
||||
setProofOfOwnership((prev) => [...prev, ...selectedFiles]);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeProofOfOwnership = (index: number) => {
|
||||
setProofOfOwnership((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!rental) return;
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
// Check if at least one option is selected
|
||||
const hasSelection = Object.values(statusOptions).some(
|
||||
(option) => option
|
||||
);
|
||||
if (!hasSelection) {
|
||||
setError("Please select at least one return status option");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (statusOptions.returned_late && !actualReturnDateTime) {
|
||||
setError("Please provide the actual return date and time");
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate damage assessment fields if damaged is selected
|
||||
if (statusOptions.damaged) {
|
||||
if (!conditionNotes.trim() || conditionNotes.trim().length < 10) {
|
||||
setError(
|
||||
"Please provide a detailed damage description (at least 10 characters)"
|
||||
);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canBeFixed === null) {
|
||||
setError("Please specify if the item can be fixed");
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canBeFixed && (!repairCost || parseFloat(repairCost) <= 0)) {
|
||||
setError("Please provide a repair cost estimate");
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsReplacement === null) {
|
||||
setError("Please specify if the item needs replacement");
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
needsReplacement &&
|
||||
(!replacementCost || parseFloat(replacementCost) <= 0)
|
||||
) {
|
||||
setError("Please provide a replacement cost");
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
// If damaged is selected, use reportDamage API
|
||||
if (statusOptions.damaged) {
|
||||
const damageFormData = new FormData();
|
||||
damageFormData.append("description", conditionNotes.trim());
|
||||
damageFormData.append("canBeFixed", canBeFixed?.toString() || "false");
|
||||
damageFormData.append(
|
||||
"needsReplacement",
|
||||
needsReplacement?.toString() || "false"
|
||||
);
|
||||
|
||||
if (canBeFixed) {
|
||||
damageFormData.append("repairCost", repairCost);
|
||||
}
|
||||
|
||||
if (needsReplacement) {
|
||||
damageFormData.append("replacementCost", replacementCost);
|
||||
}
|
||||
|
||||
if (actualReturnDateTime) {
|
||||
damageFormData.append("actualReturnDateTime", actualReturnDateTime);
|
||||
}
|
||||
|
||||
// Add condition photos
|
||||
photos.forEach((photo) => {
|
||||
damageFormData.append("photos", photo);
|
||||
});
|
||||
|
||||
// Add proof of ownership files
|
||||
proofOfOwnership.forEach((file) => {
|
||||
damageFormData.append("proofOfOwnership", file);
|
||||
});
|
||||
|
||||
response = await rentalAPI.reportDamage(rental.id, damageFormData);
|
||||
} else {
|
||||
// Non-damaged returns: use existing flow
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
await conditionCheckAPI.submitConditionCheck(
|
||||
rental.id,
|
||||
conditionCheckFormData
|
||||
);
|
||||
}
|
||||
|
||||
// Determine primary status for API call
|
||||
let primaryStatus = "returned";
|
||||
if (statusOptions.returned_late) {
|
||||
primaryStatus = "returned_late";
|
||||
} else if (statusOptions.lost) {
|
||||
primaryStatus = "lost";
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
status: primaryStatus,
|
||||
statusOptions, // Send all selected options
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
|
||||
if (statusOptions.returned_late) {
|
||||
data.actualReturnDateTime = actualReturnDateTime;
|
||||
}
|
||||
|
||||
response = await rentalAPI.markReturn(rental.id, data);
|
||||
}
|
||||
|
||||
// Call success callback and close modal immediately
|
||||
if (onSubmitSuccess) {
|
||||
onSubmitSuccess(response.data.rental);
|
||||
}
|
||||
onHide();
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.error || "Failed to mark return status");
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset all states
|
||||
setStatusOptions({
|
||||
returned: false,
|
||||
returned_late: false,
|
||||
damaged: false,
|
||||
lost: false,
|
||||
});
|
||||
setActualReturnDateTime("");
|
||||
setNotes("");
|
||||
setConditionNotes("");
|
||||
setPhotos([]);
|
||||
setError(null);
|
||||
setLateFeeCalculation(null);
|
||||
setCanBeFixed(null);
|
||||
setRepairCost("");
|
||||
setNeedsReplacement(null);
|
||||
setReplacementCost("");
|
||||
setProofOfOwnership([]);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [show, handleKeyDown]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal d-block"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Item Return</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && (
|
||||
<div className="alert alert-danger mb-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Rental Information</h5>
|
||||
<div className="bg-light p-3 rounded">
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<p className="mb-2">
|
||||
<strong>Item:</strong> {rental.item?.name}
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong>Renter:</strong> {rental.renter?.firstName}{" "}
|
||||
{rental.renter?.lastName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<p className="mb-2">
|
||||
<strong>Scheduled End:</strong>
|
||||
<br />
|
||||
{new Date(rental.endDateTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<div className="mb-4">
|
||||
<label className="form-label">
|
||||
<strong>Return Status</strong>
|
||||
</label>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-md-6">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="returned"
|
||||
checked={statusOptions.returned}
|
||||
onChange={(e) =>
|
||||
handleStatusChange("returned", e.target.checked)
|
||||
}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="returned">
|
||||
<i className="bi bi-check-circle me-2 text-success" />
|
||||
Item Returned On Time
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="returned_late"
|
||||
checked={statusOptions.returned_late}
|
||||
onChange={(e) =>
|
||||
handleStatusChange("returned_late", e.target.checked)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="returned_late"
|
||||
>
|
||||
<i className="bi bi-clock me-2 text-warning" />
|
||||
Item Returned Late
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="damaged"
|
||||
checked={statusOptions.damaged}
|
||||
onChange={(e) =>
|
||||
handleStatusChange("damaged", e.target.checked)
|
||||
}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="damaged">
|
||||
<i className="bi bi-exclamation-triangle me-2 text-warning" />
|
||||
Item Damaged
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="lost"
|
||||
checked={statusOptions.lost}
|
||||
onChange={(e) =>
|
||||
handleStatusChange("lost", e.target.checked)
|
||||
}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="lost">
|
||||
<i className="bi bi-x-circle me-2 text-danger" />
|
||||
Item Lost
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusOptions.returned_late && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="form-label"
|
||||
htmlFor="actualReturnDateTime"
|
||||
>
|
||||
<strong>Actual Return Date & Time</strong>
|
||||
</label>
|
||||
<div className="form-text mb-2">
|
||||
When was the item actually returned to you?
|
||||
</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="actualReturnDateTime"
|
||||
className="form-control"
|
||||
value={actualReturnDateTime}
|
||||
onChange={(e) => setActualReturnDateTime(e.target.value)}
|
||||
max={formatDateTime(new Date())}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{actualReturnDateTime && lateFeeCalculation && (
|
||||
<div className="alert alert-warning mb-4">
|
||||
<h6>
|
||||
<i className="bi bi-exclamation-circle me-2" />
|
||||
Late Fee Calculation
|
||||
</h6>
|
||||
{lateFeeCalculation.isLate ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<strong>Time Overdue:</strong>{" "}
|
||||
{lateFeeCalculation.lateHours < 24
|
||||
? `${lateFeeCalculation.lateHours.toFixed(
|
||||
1
|
||||
)} hours`
|
||||
: `${Math.floor(
|
||||
lateFeeCalculation.lateHours / 24
|
||||
)} days ${Math.floor(
|
||||
lateFeeCalculation.lateHours % 24
|
||||
)} hours`}
|
||||
</div>
|
||||
{lateFeeCalculation.pricingType === "hourly" && (
|
||||
<div className="mb-2">
|
||||
<strong>Calculation:</strong>{" "}
|
||||
{lateFeeCalculation.lateHours.toFixed(1)} hours ×{" "}
|
||||
{formatCurrency(rental.item?.pricePerHour)} per
|
||||
hour
|
||||
</div>
|
||||
)}
|
||||
{lateFeeCalculation.pricingType === "daily" &&
|
||||
lateFeeCalculation.billableDays && (
|
||||
<div className="mb-2">
|
||||
<strong>Calculation:</strong>{" "}
|
||||
{lateFeeCalculation.billableDays} billable day
|
||||
{lateFeeCalculation.billableDays > 1
|
||||
? "s"
|
||||
: ""}{" "}
|
||||
× {formatCurrency(rental.item?.pricePerDay)} per
|
||||
day
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2">
|
||||
<strong>Estimated Late Fee:</strong>{" "}
|
||||
{formatCurrency(lateFeeCalculation.lateFee)}
|
||||
</div>
|
||||
<small className="text-muted">
|
||||
Customer service will contact the renter to confirm
|
||||
the late return. If the renter agrees or does not
|
||||
respond within 48 hours, the late fee will be
|
||||
charged manually.
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<p className="mb-0 text-success">
|
||||
<i className="bi bi-check-circle me-2" />
|
||||
Item was returned on time - no late fee.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!statusOptions.lost && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<label className="form-label">
|
||||
<strong>Post-Rental Condition</strong>{" "}
|
||||
<small className="text-muted ms-2">(Optional)</small>
|
||||
</label>
|
||||
<div className="form-text mb-2">
|
||||
Document the condition of the item
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{photos.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<label className="form-label">
|
||||
Selected Photos ({photos.length})
|
||||
</label>
|
||||
<div className="row">
|
||||
{photos.map((photo, index) => (
|
||||
<div key={index} className="col-md-3 mb-2">
|
||||
<div className="position-relative">
|
||||
<img
|
||||
src={URL.createObjectURL(photo)}
|
||||
alt={`Photo ${index + 1}`}
|
||||
className="img-fluid rounded"
|
||||
style={{
|
||||
height: "100px",
|
||||
objectFit: "cover",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm position-absolute top-0 end-0"
|
||||
onClick={() => removePhoto(index)}
|
||||
disabled={processing}
|
||||
style={{ transform: "translate(50%, -50%)" }}
|
||||
>
|
||||
<i className="bi bi-x" />
|
||||
</button>
|
||||
</div>
|
||||
<small className="text-muted d-block text-center mt-1">
|
||||
{photo.name.length > 15
|
||||
? `${photo.name.substring(0, 15)}...`
|
||||
: photo.name}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="form-label" htmlFor="conditionNotes">
|
||||
<strong>
|
||||
{statusOptions.damaged
|
||||
? "Damage Description"
|
||||
: "Condition Notes"}
|
||||
</strong>
|
||||
{statusOptions.damaged ? (
|
||||
<span className="text-danger ms-1">*</span>
|
||||
) : (
|
||||
<span className="text-muted ms-1">(Optional)</span>
|
||||
)}
|
||||
</label>
|
||||
<textarea
|
||||
id="conditionNotes"
|
||||
className="form-control"
|
||||
rows={3}
|
||||
value={conditionNotes}
|
||||
onChange={(e) => setConditionNotes(e.target.value)}
|
||||
placeholder={
|
||||
statusOptions.damaged
|
||||
? "Describe the damage in detail..."
|
||||
: "Add any notes about the item's condition..."
|
||||
}
|
||||
maxLength={500}
|
||||
disabled={processing}
|
||||
required={statusOptions.damaged}
|
||||
/>
|
||||
<div className="form-text">
|
||||
{conditionNotes.length}/500 characters
|
||||
{statusOptions.damaged && " (minimum 10 required)"}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{statusOptions.lost && (
|
||||
<div className="mb-4">
|
||||
<div className="alert alert-danger">
|
||||
<h6>
|
||||
<i className="bi bi-exclamation-triangle me-2" />
|
||||
Lost Item Claim
|
||||
</h6>
|
||||
<div className="mb-3">
|
||||
<strong>Replacement Cost:</strong>{" "}
|
||||
{formatCurrency(rental.item?.replacementCost)}
|
||||
</div>
|
||||
<p className="mb-2">
|
||||
Customer service will review this lost item claim and
|
||||
contact both you and the renter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusOptions.damaged && (
|
||||
<div className="mb-4">
|
||||
<div className="alert alert-warning">
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
<strong>Can item be fixed?</strong>
|
||||
</label>
|
||||
<div>
|
||||
<div className="form-check form-check-inline">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="canBeFixed"
|
||||
id="canBeFixedYes"
|
||||
checked={canBeFixed === true}
|
||||
onChange={() => setCanBeFixed(true)}
|
||||
disabled={processing}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="canBeFixedYes"
|
||||
>
|
||||
Yes
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check form-check-inline">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="canBeFixed"
|
||||
id="canBeFixedNo"
|
||||
checked={canBeFixed === false}
|
||||
onChange={() => setCanBeFixed(false)}
|
||||
disabled={processing}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="canBeFixedNo"
|
||||
>
|
||||
No
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canBeFixed === true && (
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="repairCost">
|
||||
<strong>Repair Cost</strong>{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
id="repairCost"
|
||||
className="form-control"
|
||||
value={repairCost}
|
||||
onChange={(e) => setRepairCost(e.target.value)}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={processing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
<strong>Does item need replacement?</strong>
|
||||
</label>
|
||||
<div>
|
||||
<div className="form-check form-check-inline">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="needsReplacement"
|
||||
id="needsReplacementYes"
|
||||
checked={needsReplacement === true}
|
||||
onChange={() => setNeedsReplacement(true)}
|
||||
disabled={processing}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="needsReplacementYes"
|
||||
>
|
||||
Yes
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check form-check-inline">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="needsReplacement"
|
||||
id="needsReplacementNo"
|
||||
checked={needsReplacement === false}
|
||||
onChange={() => setNeedsReplacement(false)}
|
||||
disabled={processing}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="needsReplacementNo"
|
||||
>
|
||||
No
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsReplacement === true && (
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="replacementCost">
|
||||
<strong>Replacement Cost</strong>{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
id="replacementCost"
|
||||
className="form-control"
|
||||
value={replacementCost}
|
||||
onChange={(e) => setReplacementCost(e.target.value)}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={processing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
<strong>Proof of Ownership</strong>{" "}
|
||||
<small className="text-muted">(Optional)</small>
|
||||
</label>
|
||||
<div className="form-text mb-2">
|
||||
Upload receipts, invoices, or other documents showing
|
||||
proof of ownership
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="form-control"
|
||||
multiple
|
||||
accept="image/*,.pdf"
|
||||
onChange={handleProofOfOwnershipChange}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{proofOfOwnership.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
Proof of Ownership Files ({proofOfOwnership.length})
|
||||
</label>
|
||||
<ul className="list-group">
|
||||
{proofOfOwnership.map((file, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="list-group-item d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<span>
|
||||
<i className="bi bi-file-earmark me-2" />
|
||||
{file.name.length > 40
|
||||
? `${file.name.substring(0, 40)}...`
|
||||
: file.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => removeProofOfOwnership(index)}
|
||||
disabled={processing}
|
||||
>
|
||||
<i className="bi bi-x" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-info-circle me-1" />
|
||||
Customer service will review this damage claim and contact
|
||||
you if additional information is needed.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleClose}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
processing ||
|
||||
(statusOptions.returned_late && !actualReturnDateTime)
|
||||
}
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReturnStatusModal;
|
||||
Reference in New Issue
Block a user