email plus return item statuses

This commit is contained in:
jackiettran
2025-10-06 15:41:48 -04:00
parent 67cc997ddc
commit 5c3d505988
28 changed files with 5861 additions and 259 deletions

View 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;