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;
|
||||
@@ -6,7 +6,12 @@ import React, {
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { User } from "../types";
|
||||
import { authAPI, userAPI, fetchCSRFToken, resetCSRFToken, hasAuthIndicators } from "../services/api";
|
||||
import {
|
||||
authAPI,
|
||||
userAPI,
|
||||
fetchCSRFToken,
|
||||
resetCSRFToken,
|
||||
} from "../services/api";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
@@ -39,13 +44,14 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// The axios interceptor will automatically handle token refresh if needed
|
||||
const response = await userAPI.getProfile();
|
||||
setUser(response.data);
|
||||
} catch (error: any) {
|
||||
// Only log actual errors, not "user not logged in"
|
||||
if (error.response?.data?.code !== "NO_TOKEN") {
|
||||
console.error("Auth check failed:", error);
|
||||
}
|
||||
// If we get here, either:
|
||||
// 1. User is not logged in (expected for public browsing)
|
||||
// 2. Token refresh failed (user needs to login again)
|
||||
// In both cases, silently set user to null without logging errors
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
@@ -54,26 +60,11 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
// Initialize authentication
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
// Check if we have any auth indicators before making API call
|
||||
if (hasAuthIndicators()) {
|
||||
// Only check auth if we have some indication of being logged in
|
||||
// This avoids unnecessary 401 errors in the console
|
||||
await checkAuth();
|
||||
} else {
|
||||
// No auth indicators - skip the API call
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
// Always fetch CSRF token for subsequent requests
|
||||
await fetchCSRFToken();
|
||||
// Check if user is already authenticated
|
||||
await checkAuth();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize auth:", error);
|
||||
// Even on error, try to get CSRF token for non-authenticated requests
|
||||
try {
|
||||
await fetchCSRFToken();
|
||||
} catch (csrfError) {
|
||||
console.error("Failed to fetch CSRF token:", csrfError);
|
||||
}
|
||||
console.error("Failed to initialize authentication:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import api from "../services/api";
|
||||
import { Item, Rental } from "../types";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||
import ConditionCheckModal from "../components/ConditionCheckModal";
|
||||
import ReturnStatusModal from "../components/ReturnStatusModal";
|
||||
|
||||
const MyListings: React.FC = () => {
|
||||
// Helper function to format time
|
||||
@@ -24,8 +26,17 @@ const MyListings: React.FC = () => {
|
||||
|
||||
// Helper function to format date and time together
|
||||
const formatDateTime = (dateTimeString: string) => {
|
||||
const date = new Date(dateTimeString).toLocaleDateString();
|
||||
return date;
|
||||
const date = new Date(dateTimeString);
|
||||
return date
|
||||
.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(",", "");
|
||||
};
|
||||
|
||||
const { user } = useAuth();
|
||||
@@ -42,12 +53,28 @@ const MyListings: React.FC = () => {
|
||||
const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null);
|
||||
const [isProcessingPayment, setIsProcessingPayment] = useState<string>("");
|
||||
const [processingSuccess, setProcessingSuccess] = useState<string>("");
|
||||
const [showConditionCheckModal, setShowConditionCheckModal] = useState(false);
|
||||
const [conditionCheckData, setConditionCheckData] = useState<{
|
||||
rental: Rental;
|
||||
checkType: string;
|
||||
} | null>(null);
|
||||
const [availableChecks, setAvailableChecks] = useState<any[]>([]);
|
||||
const [conditionChecks, setConditionChecks] = useState<any[]>([]);
|
||||
const [showReturnStatusModal, setShowReturnStatusModal] = useState(false);
|
||||
const [rentalForReturn, setRentalForReturn] = useState<Rental | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyListings();
|
||||
fetchOwnerRentals();
|
||||
fetchAvailableChecks();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerRentals.length > 0) {
|
||||
fetchConditionChecks();
|
||||
}
|
||||
}, [ownerRentals]);
|
||||
|
||||
const fetchMyListings = async () => {
|
||||
if (!user) return;
|
||||
|
||||
@@ -108,6 +135,44 @@ const MyListings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailableChecks = async () => {
|
||||
try {
|
||||
const response = await conditionCheckAPI.getAvailableChecks();
|
||||
const checks = Array.isArray(response.data.availableChecks)
|
||||
? response.data.availableChecks
|
||||
: [];
|
||||
setAvailableChecks(checks);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch available checks:", err);
|
||||
setAvailableChecks([]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConditionChecks = async () => {
|
||||
try {
|
||||
// Fetch condition checks for all owner rentals
|
||||
const allChecks: any[] = [];
|
||||
for (const rental of ownerRentals) {
|
||||
try {
|
||||
const response = await conditionCheckAPI.getConditionChecks(
|
||||
rental.id
|
||||
);
|
||||
const checks = Array.isArray(response.data.conditionChecks)
|
||||
? response.data.conditionChecks
|
||||
: [];
|
||||
allChecks.push(...checks);
|
||||
} catch (err) {
|
||||
// Continue even if one rental fails
|
||||
console.error(`Failed to fetch checks for rental ${rental.id}:`, err);
|
||||
}
|
||||
}
|
||||
setConditionChecks(allChecks);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch condition checks:", err);
|
||||
setConditionChecks([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Owner functionality handlers
|
||||
const handleAcceptRental = async (rentalId: string) => {
|
||||
try {
|
||||
@@ -127,6 +192,7 @@ const MyListings: React.FC = () => {
|
||||
}
|
||||
|
||||
fetchOwnerRentals();
|
||||
fetchAvailableChecks(); // Refresh available checks after rental confirmation
|
||||
} catch (err: any) {
|
||||
console.error("Failed to accept rental request:", err);
|
||||
|
||||
@@ -155,21 +221,27 @@ const MyListings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteClick = async (rental: Rental) => {
|
||||
try {
|
||||
await rentalAPI.markAsCompleted(rental.id);
|
||||
const handleCompleteClick = (rental: Rental) => {
|
||||
setRentalForReturn(rental);
|
||||
setShowReturnStatusModal(true);
|
||||
};
|
||||
|
||||
setSelectedRentalForReview(rental);
|
||||
setShowReviewRenterModal(true);
|
||||
const handleReturnStatusMarked = async (updatedRental: Rental) => {
|
||||
// Update the rental in the list
|
||||
setOwnerRentals((prev) =>
|
||||
prev.map((rental) =>
|
||||
rental.id === updatedRental.id ? updatedRental : rental
|
||||
)
|
||||
);
|
||||
|
||||
fetchOwnerRentals();
|
||||
} catch (err: any) {
|
||||
console.error("Error marking rental as completed:", err);
|
||||
alert(
|
||||
"Failed to mark rental as completed: " +
|
||||
(err.response?.data?.error || err.message)
|
||||
);
|
||||
}
|
||||
// Close the return status modal
|
||||
setShowReturnStatusModal(false);
|
||||
setRentalForReturn(null);
|
||||
|
||||
// Show review modal (rental is already marked as completed by return status endpoint)
|
||||
setSelectedRentalForReview(updatedRental);
|
||||
setShowReviewRenterModal(true);
|
||||
fetchOwnerRentals();
|
||||
};
|
||||
|
||||
const handleReviewRenterSuccess = () => {
|
||||
@@ -192,6 +264,35 @@ const MyListings: React.FC = () => {
|
||||
setRentalToCancel(null);
|
||||
};
|
||||
|
||||
const handleConditionCheck = (rental: Rental, checkType: string) => {
|
||||
setConditionCheckData({ rental, checkType });
|
||||
setShowConditionCheckModal(true);
|
||||
};
|
||||
|
||||
const handleConditionCheckSuccess = () => {
|
||||
fetchAvailableChecks();
|
||||
fetchConditionChecks();
|
||||
alert("Condition check submitted successfully!");
|
||||
};
|
||||
|
||||
const getAvailableChecksForRental = (rentalId: string) => {
|
||||
if (!Array.isArray(availableChecks)) return [];
|
||||
return availableChecks.filter(
|
||||
(check) =>
|
||||
check.rentalId === rentalId && check.checkType === "pre_rental_owner" // Only pre-rental; post-rental is in return modal
|
||||
);
|
||||
};
|
||||
|
||||
const getCompletedChecksForRental = (rentalId: string) => {
|
||||
if (!Array.isArray(conditionChecks)) return [];
|
||||
return conditionChecks.filter(
|
||||
(check) =>
|
||||
check.rentalId === rentalId &&
|
||||
(check.checkType === "pre_rental_owner" ||
|
||||
check.checkType === "post_rental_owner")
|
||||
);
|
||||
};
|
||||
|
||||
// Filter owner rentals
|
||||
const allOwnerRentals = ownerRentals
|
||||
.filter((r) =>
|
||||
@@ -331,73 +432,115 @@ const MyListings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
{rental.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleAcceptRental(rental.id)}
|
||||
disabled={isProcessingPayment === rental.id}
|
||||
>
|
||||
{isProcessingPayment === rental.id ? (
|
||||
<>
|
||||
<div
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
>
|
||||
<span className="visually-hidden">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
Processing Payment...
|
||||
</>
|
||||
) : processingSuccess === rental.id ? (
|
||||
<>
|
||||
<i className="bi bi-check-circle me-1"></i>
|
||||
Payment Success!
|
||||
</>
|
||||
) : (
|
||||
"Accept"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleRejectRental(rental.id)}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => handleCancelClick(rental)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{rental.status === "confirmed" && (
|
||||
<>
|
||||
<div className="d-flex flex-column gap-2 mt-3">
|
||||
<div className="d-flex gap-2">
|
||||
{rental.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleAcceptRental(rental.id)}
|
||||
disabled={isProcessingPayment === rental.id}
|
||||
>
|
||||
{isProcessingPayment === rental.id ? (
|
||||
<>
|
||||
<div
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
>
|
||||
<span className="visually-hidden">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
Processing Payment...
|
||||
</>
|
||||
) : processingSuccess === rental.id ? (
|
||||
<>
|
||||
<i className="bi bi-check-circle me-1"></i>
|
||||
Payment Success!
|
||||
</>
|
||||
) : (
|
||||
"Accept"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleRejectRental(rental.id)}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => handleCancelClick(rental)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{rental.status === "confirmed" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleCompleteClick(rental)}
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => handleCancelClick(rental)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{rental.status === "active" && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleCompleteClick(rental)}
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => handleCancelClick(rental)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Condition Check Status */}
|
||||
{getCompletedChecksForRental(rental.id).length > 0 && (
|
||||
<div className="mb-2">
|
||||
{getCompletedChecksForRental(rental.id).map(
|
||||
(check) => (
|
||||
<div
|
||||
key={`${rental.id}-${check.checkType}-status`}
|
||||
className="text-success small"
|
||||
>
|
||||
<i className="bi bi-camera-fill me-1"></i>
|
||||
{check.checkType === "pre_rental_owner"
|
||||
? "Pre-Rental Check Completed"
|
||||
: "Post-Rental Check Completed"}
|
||||
<small className="text-muted ms-2">
|
||||
{new Date(
|
||||
check.createdAt
|
||||
).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{rental.status === "active" && (
|
||||
|
||||
{/* Condition Check Buttons */}
|
||||
{getAvailableChecksForRental(rental.id).map((check) => (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleCompleteClick(rental)}
|
||||
key={`${rental.id}-${check.checkType}`}
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() =>
|
||||
handleConditionCheck(rental, check.checkType)
|
||||
}
|
||||
>
|
||||
Complete
|
||||
<i className="bi bi-camera me-2" />
|
||||
{check.checkType === "pre_rental_owner"
|
||||
? "Submit Pre-Rental Check"
|
||||
: "Submit Post-Rental Check"}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -572,6 +715,35 @@ const MyListings: React.FC = () => {
|
||||
onCancellationComplete={handleCancellationComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Condition Check Modal */}
|
||||
{conditionCheckData && (
|
||||
<ConditionCheckModal
|
||||
show={showConditionCheckModal}
|
||||
onHide={() => {
|
||||
setShowConditionCheckModal(false);
|
||||
setConditionCheckData(null);
|
||||
}}
|
||||
rentalId={conditionCheckData.rental.id}
|
||||
checkType={conditionCheckData.checkType}
|
||||
itemName={conditionCheckData.rental.item?.name || "Item"}
|
||||
onSuccess={handleConditionCheckSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Return Status Modal */}
|
||||
{rentalForReturn && (
|
||||
<ReturnStatusModal
|
||||
show={showReturnStatusModal}
|
||||
onHide={() => {
|
||||
setShowReturnStatusModal(false);
|
||||
setRentalForReturn(null);
|
||||
}}
|
||||
rental={rentalForReturn}
|
||||
onReturnMarked={handleReturnStatusMarked}
|
||||
onSubmitSuccess={handleReturnStatusMarked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||
import { Rental } from "../types";
|
||||
import ReviewItemModal from "../components/ReviewModal";
|
||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||
import ConditionCheckModal from "../components/ConditionCheckModal";
|
||||
|
||||
const MyRentals: React.FC = () => {
|
||||
// Helper function to format time
|
||||
@@ -21,6 +22,21 @@ const MyRentals: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format date and time together
|
||||
const formatDateTime = (dateTimeString: string) => {
|
||||
const date = new Date(dateTimeString);
|
||||
return date
|
||||
.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(",", "");
|
||||
};
|
||||
|
||||
const { user } = useAuth();
|
||||
const [rentals, setRentals] = useState<Rental[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -29,11 +45,25 @@ const MyRentals: React.FC = () => {
|
||||
const [selectedRental, setSelectedRental] = useState<Rental | null>(null);
|
||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||
const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null);
|
||||
const [showConditionCheckModal, setShowConditionCheckModal] = useState(false);
|
||||
const [conditionCheckData, setConditionCheckData] = useState<{
|
||||
rental: Rental;
|
||||
checkType: string;
|
||||
} | null>(null);
|
||||
const [availableChecks, setAvailableChecks] = useState<any[]>([]);
|
||||
const [conditionChecks, setConditionChecks] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRentals();
|
||||
fetchAvailableChecks();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rentals.length > 0) {
|
||||
fetchConditionChecks();
|
||||
}
|
||||
}, [rentals]);
|
||||
|
||||
const fetchRentals = async () => {
|
||||
try {
|
||||
const response = await rentalAPI.getMyRentals();
|
||||
@@ -45,6 +75,44 @@ const MyRentals: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailableChecks = async () => {
|
||||
try {
|
||||
const response = await conditionCheckAPI.getAvailableChecks();
|
||||
const checks = Array.isArray(response.data.availableChecks)
|
||||
? response.data.availableChecks
|
||||
: [];
|
||||
setAvailableChecks(checks);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch available checks:", err);
|
||||
setAvailableChecks([]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConditionChecks = async () => {
|
||||
try {
|
||||
// Fetch condition checks for all rentals
|
||||
const allChecks: any[] = [];
|
||||
for (const rental of rentals) {
|
||||
try {
|
||||
const response = await conditionCheckAPI.getConditionChecks(
|
||||
rental.id
|
||||
);
|
||||
const checks = Array.isArray(response.data.conditionChecks)
|
||||
? response.data.conditionChecks
|
||||
: [];
|
||||
allChecks.push(...checks);
|
||||
} catch (err) {
|
||||
// Continue even if one rental fails
|
||||
console.error(`Failed to fetch checks for rental ${rental.id}:`, err);
|
||||
}
|
||||
}
|
||||
setConditionChecks(allChecks);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch condition checks:", err);
|
||||
setConditionChecks([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelClick = (rental: Rental) => {
|
||||
setRentalToCancel(rental);
|
||||
setShowCancelModal(true);
|
||||
@@ -71,6 +139,37 @@ const MyRentals: React.FC = () => {
|
||||
alert("Thank you for your review!");
|
||||
};
|
||||
|
||||
const handleConditionCheck = (rental: Rental, checkType: string) => {
|
||||
setConditionCheckData({ rental, checkType });
|
||||
setShowConditionCheckModal(true);
|
||||
};
|
||||
|
||||
const handleConditionCheckSuccess = () => {
|
||||
fetchAvailableChecks();
|
||||
fetchConditionChecks();
|
||||
alert("Condition check submitted successfully!");
|
||||
};
|
||||
|
||||
const getAvailableChecksForRental = (rentalId: string) => {
|
||||
if (!Array.isArray(availableChecks)) return [];
|
||||
return availableChecks.filter(
|
||||
(check) =>
|
||||
check.rentalId === rentalId &&
|
||||
(check.checkType === "rental_start_renter" ||
|
||||
check.checkType === "rental_end_renter")
|
||||
);
|
||||
};
|
||||
|
||||
const getCompletedChecksForRental = (rentalId: string) => {
|
||||
if (!Array.isArray(conditionChecks)) return [];
|
||||
return conditionChecks.filter(
|
||||
(check) =>
|
||||
check.rentalId === rentalId &&
|
||||
(check.checkType === "rental_start_renter" ||
|
||||
check.checkType === "rental_end_renter")
|
||||
);
|
||||
};
|
||||
|
||||
// Filter rentals - only show active rentals (pending, confirmed, active)
|
||||
const renterActiveRentals = rentals.filter((r) =>
|
||||
["pending", "confirmed", "active"].includes(r.status)
|
||||
@@ -164,21 +263,13 @@ const MyRentals: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rental.status === "pending" && (
|
||||
<div className="alert alert-info mt-2 mb-2 p-2 small">
|
||||
You'll only be charged if the owner approves your
|
||||
request.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Rental Period:</strong>
|
||||
<br />
|
||||
<strong>Start:</strong>{" "}
|
||||
{new Date(rental.startDateTime).toLocaleString()}
|
||||
{formatDateTime(rental.startDateTime)}
|
||||
<br />
|
||||
<strong>End:</strong>{" "}
|
||||
{new Date(rental.endDateTime).toLocaleString()}
|
||||
<strong>End:</strong> {formatDateTime(rental.endDateTime)}
|
||||
</p>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
@@ -237,26 +328,70 @@ const MyRentals: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2 mt-3">
|
||||
{(rental.status === "pending" ||
|
||||
rental.status === "confirmed") && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleCancelClick(rental)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{rental.status === "active" &&
|
||||
!rental.itemRating &&
|
||||
!rental.itemReviewSubmittedAt && (
|
||||
<div className="d-flex flex-column gap-2 mt-3">
|
||||
<div className="d-flex gap-2">
|
||||
{(rental.status === "pending" ||
|
||||
rental.status === "confirmed") && (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleReviewClick(rental)}
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleCancelClick(rental)}
|
||||
>
|
||||
Review
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{rental.status === "active" &&
|
||||
!rental.itemRating &&
|
||||
!rental.itemReviewSubmittedAt && (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleReviewClick(rental)}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Condition Check Status */}
|
||||
{getCompletedChecksForRental(rental.id).length > 0 && (
|
||||
<div className="mb-2">
|
||||
{getCompletedChecksForRental(rental.id).map(
|
||||
(check) => (
|
||||
<div
|
||||
key={`${rental.id}-${check.checkType}-status`}
|
||||
className="text-success small"
|
||||
>
|
||||
<i className="bi bi-camera-fill me-1"></i>
|
||||
{check.checkType === "rental_start_renter"
|
||||
? "Start Check Completed"
|
||||
: "End Check Completed"}
|
||||
<small className="text-muted ms-2">
|
||||
{new Date(
|
||||
check.createdAt
|
||||
).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition Check Buttons */}
|
||||
{getAvailableChecksForRental(rental.id).map((check) => (
|
||||
<button
|
||||
key={`${rental.id}-${check.checkType}`}
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={() =>
|
||||
handleConditionCheck(rental, check.checkType)
|
||||
}
|
||||
>
|
||||
<i className="bi bi-camera me-2" />
|
||||
{check.checkType === "rental_start_renter"
|
||||
? "Submit Start Check"
|
||||
: "Submit End Check"}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Review Status */}
|
||||
{rental.itemReviewSubmittedAt &&
|
||||
!rental.itemReviewVisible && (
|
||||
<div className="text-info small">
|
||||
@@ -311,6 +446,21 @@ const MyRentals: React.FC = () => {
|
||||
onCancellationComplete={handleCancellationComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Condition Check Modal */}
|
||||
{conditionCheckData && (
|
||||
<ConditionCheckModal
|
||||
show={showConditionCheckModal}
|
||||
onHide={() => {
|
||||
setShowConditionCheckModal(false);
|
||||
setConditionCheckData(null);
|
||||
}}
|
||||
rentalId={conditionCheckData.rental.id}
|
||||
checkType={conditionCheckData.checkType}
|
||||
itemName={conditionCheckData.rental.item?.name || "Item"}
|
||||
onSuccess={handleConditionCheckSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,18 +52,6 @@ export const resetCSRFToken = () => {
|
||||
csrfToken = null;
|
||||
};
|
||||
|
||||
// Check if authentication cookie exists
|
||||
export const hasAuthCookie = (): boolean => {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.some((cookie) => cookie.startsWith("accessToken="));
|
||||
};
|
||||
|
||||
// Check if user has any auth indicators
|
||||
export const hasAuthIndicators = (): boolean => {
|
||||
return hasAuthCookie();
|
||||
};
|
||||
|
||||
api.interceptors.request.use(async (config) => {
|
||||
// Add CSRF token to headers for state-changing requests
|
||||
const method = config.method?.toUpperCase() || "";
|
||||
@@ -119,14 +107,14 @@ api.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
const errorData = error.response?.data as any;
|
||||
|
||||
// Don't redirect for NO_TOKEN on public endpoints
|
||||
if (errorData?.code === "NO_TOKEN") {
|
||||
// Let the app handle this - user simply isn't logged in
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// If token is expired, try to refresh
|
||||
if (errorData?.code === "TOKEN_EXPIRED" && !originalRequest._retry) {
|
||||
// Try to refresh for token errors
|
||||
// Note: We can't check refresh token from JS (httpOnly cookies)
|
||||
// The backend will determine if refresh is possible
|
||||
if (
|
||||
(errorData?.code === "TOKEN_EXPIRED" ||
|
||||
errorData?.code === "NO_TOKEN") &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
// If already refreshing, queue the request
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -152,18 +140,10 @@ api.interceptors.response.use(
|
||||
isRefreshing = false;
|
||||
processQueue(refreshError as AxiosError);
|
||||
|
||||
// Refresh failed, redirect to login
|
||||
window.location.href = "/login";
|
||||
// Refresh failed - let React Router handle redirects via PrivateRoute
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// For other 401 errors, check if we should redirect
|
||||
// Only redirect if this is not a login/register request
|
||||
const isAuthEndpoint = originalRequest.url?.includes("/auth/");
|
||||
if (!isAuthEndpoint && errorData?.error !== "Access token required") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
@@ -223,8 +203,19 @@ export const rentalAPI = {
|
||||
reviewItem: (id: string, data: any) =>
|
||||
api.post(`/rentals/${id}/review-item`, data),
|
||||
getRefundPreview: (id: string) => api.get(`/rentals/${id}/refund-preview`),
|
||||
getLateFeePreview: (id: string, actualReturnDateTime: string) =>
|
||||
api.get(`/rentals/${id}/late-fee-preview`, {
|
||||
params: { actualReturnDateTime },
|
||||
}),
|
||||
cancelRental: (id: string, reason?: string) =>
|
||||
api.post(`/rentals/${id}/cancel`, { reason }),
|
||||
// Return status marking
|
||||
markReturn: (
|
||||
id: string,
|
||||
data: { status: string; actualReturnDateTime?: string; notes?: string }
|
||||
) => api.post(`/rentals/${id}/mark-return`, data),
|
||||
reportDamage: (id: string, data: any) =>
|
||||
api.post(`/rentals/${id}/report-damage`, data),
|
||||
};
|
||||
|
||||
export const messageAPI = {
|
||||
@@ -277,4 +268,35 @@ export const mapsAPI = {
|
||||
getHealth: () => api.get("/maps/health"),
|
||||
};
|
||||
|
||||
export const conditionCheckAPI = {
|
||||
submitConditionCheck: (rentalId: string, formData: FormData) =>
|
||||
api.post(`/condition-checks/${rentalId}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}),
|
||||
getConditionChecks: (rentalId: string) =>
|
||||
api.get(`/condition-checks/${rentalId}`),
|
||||
getConditionCheckTimeline: (rentalId: string) =>
|
||||
api.get(`/condition-checks/${rentalId}/timeline`),
|
||||
getAvailableChecks: () => api.get("/condition-checks"),
|
||||
};
|
||||
|
||||
export const notificationAPI = {
|
||||
getNotifications: (params?: { limit?: number; page?: number }) =>
|
||||
api.get("/notifications", { params }),
|
||||
getUnreadCount: () => api.get("/notifications/unread-count"),
|
||||
markAsRead: (notificationId: string) =>
|
||||
api.patch(`/notifications/${notificationId}/read`),
|
||||
markAllAsRead: () => api.patch("/notifications/mark-all-read"),
|
||||
// Development endpoints
|
||||
createTestNotification: (data: {
|
||||
type?: string;
|
||||
title: string;
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}) => api.post("/notifications/test", data),
|
||||
triggerConditionReminders: () =>
|
||||
api.post("/notifications/test/condition-reminders"),
|
||||
cleanupExpired: () => api.post("/notifications/test/cleanup-expired"),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -107,7 +107,15 @@ export interface Rental {
|
||||
// Fee tracking fields
|
||||
platformFee?: number;
|
||||
payoutAmount?: number;
|
||||
status: "pending" | "confirmed" | "active" | "completed" | "cancelled";
|
||||
status:
|
||||
| "pending"
|
||||
| "confirmed"
|
||||
| "active"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "returned_late"
|
||||
| "damaged"
|
||||
| "lost";
|
||||
paymentStatus: "pending" | "paid" | "refunded";
|
||||
// Refund tracking fields
|
||||
refundAmount?: number;
|
||||
@@ -140,6 +148,13 @@ export interface Rental {
|
||||
// Private messages
|
||||
itemPrivateMessage?: string;
|
||||
renterPrivateMessage?: string;
|
||||
// New condition check and dispute fields
|
||||
actualReturnDateTime?: string;
|
||||
lateFees?: number;
|
||||
damageFees?: number;
|
||||
replacementFees?: number;
|
||||
itemLostReportedAt?: string;
|
||||
damageAssessment?: any;
|
||||
item?: Item;
|
||||
renter?: User;
|
||||
owner?: User;
|
||||
@@ -147,6 +162,82 @@ export interface Rental {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ConditionCheck {
|
||||
id: string;
|
||||
rentalId: string;
|
||||
checkType:
|
||||
| "pre_rental_owner"
|
||||
| "rental_start_renter"
|
||||
| "rental_end_renter"
|
||||
| "post_rental_owner";
|
||||
photos: string[];
|
||||
notes?: string;
|
||||
submittedBy: string;
|
||||
submittedAt: string;
|
||||
metadata: any;
|
||||
submittedByUser?: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LateReturnCalculation {
|
||||
lateHours: number;
|
||||
lateFee: number;
|
||||
isLate: boolean;
|
||||
gracePeriodUsed?: boolean;
|
||||
billableHours?: number;
|
||||
pricingType?: "hourly" | "daily";
|
||||
}
|
||||
|
||||
export interface DamageAssessment {
|
||||
description: string;
|
||||
canBeFixed: boolean;
|
||||
repairCost?: number;
|
||||
needsReplacement: boolean;
|
||||
replacementCost?: number;
|
||||
proofOfOwnership?: string[];
|
||||
photos?: string[];
|
||||
assessedAt: string;
|
||||
assessedBy: string;
|
||||
feeCalculation: {
|
||||
type: "repair" | "replacement" | "assessment";
|
||||
amount: number;
|
||||
originalCost?: number;
|
||||
repairCost?: number;
|
||||
percentage?: number;
|
||||
baseAmount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConditionCheckTimeline {
|
||||
rental: {
|
||||
id: string;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
status: string;
|
||||
};
|
||||
timeline: {
|
||||
[key: string]: {
|
||||
status:
|
||||
| "completed"
|
||||
| "available"
|
||||
| "pending"
|
||||
| "expired"
|
||||
| "not_available";
|
||||
submittedAt?: string;
|
||||
submittedBy?: User;
|
||||
photoCount?: number;
|
||||
hasNotes?: boolean;
|
||||
timeWindow?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
availableFrom?: string;
|
||||
availableUntil?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ItemRequest {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user