Files
rentall-app/frontend/src/components/ReturnStatusModal.tsx
2025-10-06 15:41:48 -04:00

955 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;