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

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;

View File

@@ -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);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

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

View File

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