import React, { useRef, useState, useEffect, useMemo, createContext, useContext } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { Reservation } from '@/types/types';
import { useDragContext } from './DragContext';
import { useReservations } from './ReservationsProvider';
import 'react-resizable/css/styles.css';
import { FiUsers, FiClock, FiCheckCircle, FiClock as FiPending, FiMoreVertical, FiEdit, FiTrash, FiCheck, FiX } from 'react-icons/fi';
// Create a context to manage active dropdown
const DropdownContext = createContext<{
activeDropdownId: string | null;
setActiveDropdownId: (id: string | null) => void;
}>({
activeDropdownId: null,
setActiveDropdownId: () => {},
});
// Provider component for dropdown management
export const DropdownProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [activeDropdownId, setActiveDropdownId] = useState<string | null>(null);
return (
<DropdownContext.Provider value={{ activeDropdownId, setActiveDropdownId }}>
{children}
</DropdownContext.Provider>
);
};
// Hook to use the dropdown context
export const useDropdown = () => useContext(DropdownContext);
// Constants shared with ReservationsKanban
const HOUR_HEIGHT = 50;
const DAY_START = 10;
// Utility function shared with ReservationsKanban
const roundToNearest15Min = (date: Date) => {
const minutes = date.getMinutes();
const remainder = minutes % 15;
const roundedMinutes = remainder < 8 ? minutes - remainder : minutes + (15 - remainder);
const rounded = new Date(date);
rounded.setMinutes(roundedMinutes);
rounded.setSeconds(0);
rounded.setMilliseconds(0);
return rounded;
};
type BaseReservationCardProps = {
reservation: Reservation;
variant: 'kanban' | 'list';
};
type KanbanCardProps = BaseReservationCardProps & {
variant: 'kanban';
style: React.CSSProperties;
onClick: (e: React.MouseEvent) => void;
};
type ListCardProps = BaseReservationCardProps & {
variant: 'list';
onEdit: (reservation: Reservation) => void;
onDelete: (id: number) => void;
onApprove: (id: string) => void;
onUnapprove: (id: string) => void;
};
type ReservationCardProps = KanbanCardProps | ListCardProps;
const getStatusColor = (reservation: Reservation): string => {
if (reservation.approved) return 'text-emerald-500';
return 'text-yellow-500';
};
export default function ReservationCard({
reservation,
variant,
...props
}: ReservationCardProps) {
const { draggedId, setDraggedId } = useDragContext();
const { updateReservation } = useReservations();
const { activeDropdownId, setActiveDropdownId } = useDropdown();
// Move all hooks out of conditional blocks to the top level
const resizeRef = useRef<HTMLDivElement | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [startY, setStartY] = useState<number | null>(null);
const [wasResizing, setWasResizing] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const dropdownRef = useRef<HTMLDivElement>(null);
const { fromDate, toDate } = useMemo(() => {
return {
fromDate: new Date(reservation.from_date),
toDate: new Date(reservation.to_date)
};
}, [reservation.from_date, reservation.to_date]);
// Extract onClick from props when in kanban mode
const { onClick } = variant === 'kanban' ? props as KanbanCardProps : { onClick: undefined };
// Move drag and drop hooks out of conditional blocks
const [{ isDragging }, drag, dragPreview] = useDrag(() => ({
type: 'RESERVATION',
item: (monitor) => {
const initialClientOffset = monitor.getInitialClientOffset();
const initialSourceClientOffset = monitor.getInitialSourceClientOffset();
setDraggedId(reservation.id.toString());
return {
...reservation,
offsetY: initialClientOffset && initialSourceClientOffset
? initialClientOffset.y - initialSourceClientOffset.y
: 0
};
},
canDrag: () => variant === 'kanban' && !isResizing,
end: () => setDraggedId(null),
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
}), [reservation.id, isResizing, variant]);
const [{ isOver }, drop] = useDrop(() => ({
accept: 'RESERVATION',
drop: (item: any, monitor) => {
if (variant !== 'kanban') return;
const dropOffset = monitor.getClientOffset();
if (!dropOffset || !resizeRef.current) return;
const rect = resizeRef.current.getBoundingClientRect();
const offsetY = dropOffset.y - rect.top - item.offsetY;
const minute = Math.floor((offsetY / HOUR_HEIGHT) * 60);
const newFrom = new Date(fromDate);
newFrom.setMinutes(minute);
const roundedFrom = roundToNearest15Min(newFrom); // round start time
const duration = toDate.getTime() - fromDate.getTime();
const newToCandidate = new Date(roundedFrom.getTime() + duration);
const roundedTo = roundToNearest15Min(newToCandidate); // round end time
updateReservation(reservation.id, {
from_date: roundedFrom.toISOString(),
to_date: roundedTo.toISOString(),
});
},
canDrop: () => variant === 'kanban',
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}), [variant, fromDate, toDate, updateReservation]);
const formatTime = (date: string) => {
return new Date(date).toLocaleTimeString('bg-BG', {
hour: '2-digit',
minute: '2-digit'
});
};
// Calculate if this card's dropdown should be shown
const showActionsDropdown = activeDropdownId === reservation.id.toString();
// Modify the handleStatusClick function to toggle the dropdown in list view and store position
const handleStatusClick = (e: React.MouseEvent) => {
// Stop propagation to prevent the card's onClick from firing
e.stopPropagation();
e.preventDefault();
if (variant === 'list') {
// Get the position from the closest div, not just the target element
// This helps when clicking on the icon itself which is a child of the div
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 5,
left: rect.left
});
// Toggle the dropdown - close if it's already open, or open and close others
if (activeDropdownId === reservation.id.toString()) {
// If dropdown is already open for this reservation, close it
setActiveDropdownId(null);
} else {
// Otherwise open this dropdown and close any others
setActiveDropdownId(reservation.id.toString());
}
} else if (!reservation.approved) {
updateReservation(reservation.id, { approved: true });
}
};
const handleMouseDown = (e: React.MouseEvent) => {
if (variant !== 'kanban') return;
if (e.button !== 0) return; // Only handle left click
setIsResizing(true);
setStartY(e.clientY);
e.preventDefault();
e.stopPropagation();
};
// Move useEffect outside of conditionals
useEffect(() => {
if (!isResizing || variant !== 'kanban') return;
const handleMouseMove = (e: MouseEvent) => {
if (!startY || !resizeRef.current) return;
const deltaY = e.clientY - startY;
const newHeight = ((toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60)) * HOUR_HEIGHT + deltaY;
const durationInHours = newHeight / HOUR_HEIGHT;
const newEndTime = new Date(fromDate);
newEndTime.setHours(fromDate.getHours() + Math.floor(durationInHours));
newEndTime.setMinutes(fromDate.getMinutes() + Math.round((durationInHours % 1) * 60));
if (newEndTime <= fromDate) return; // Prevent negative duration
const newToDate = roundToNearest15Min(newEndTime);
resizeRef.current.style.height = `${newHeight}px`;
setWasResizing(true);
};
const handleMouseUp = (e: MouseEvent) => {
setIsResizing(false);
setStartY(null);
if (wasResizing && resizeRef.current) {
const height = parseFloat(resizeRef.current.style.height);
const durationInHours = height / HOUR_HEIGHT;
const newEndTime = new Date(fromDate);
newEndTime.setHours(fromDate.getHours() + Math.floor(durationInHours));
newEndTime.setMinutes(fromDate.getMinutes() + Math.round((durationInHours % 1) * 60));
if (newEndTime > fromDate) {
const roundedTo = roundToNearest15Min(newEndTime);
updateReservation(reservation.id, {
to_date: roundedTo.toISOString()
});
}
setWasResizing(false);
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, startY, fromDate, toDate, updateReservation, reservation.id, wasResizing, variant]);
// Add an effect to close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Find the status icon for this reservation
const statusIcon = document.querySelector(`[data-reservation-id="${reservation.id}"]`);
// Don't close if clicking on the status icon (that's handled by its own click handler)
if (statusIcon && statusIcon.contains(event.target as Node)) {
return;
}
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
activeDropdownId === reservation.id.toString()
) {
setActiveDropdownId(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [activeDropdownId, reservation.id, setActiveDropdownId]);
const cardContent = (
<div className="relative w-full h-full">
{/* Badge for persons count */}
<div className="absolute top-2 right-2 inline-flex items-center gap-1 text-xs font-medium text-gray-700 bg-white/80 rounded-full px-1.5 py-0.5 border shadow-sm">
<FiUsers className="w-3 h-3" />
{reservation.persons}
</div>
{/* Status indicator - modified with onClick */}
<div
className={`absolute top-2 left-2 inline-flex items-center cursor-pointer hover:scale-110 transition-transform ${showActionsDropdown ? 'ring-2 ring-blue-400 rounded-full' : ''}`}
onClick={handleStatusClick}
title={variant === 'list' ? "Click to toggle actions menu" : reservation.approved ? "Confirmed" : "Click to approve"}
style={{ zIndex: 0 }}
data-reservation-id={reservation.id.toString()}
>
{reservation.approved ? (
<FiCheckCircle className="w-4 h-4 text-emerald-500" />
) : (
<FiPending className="w-4 h-4 text-amber-500" />
)}
</div>
{/* Main content */}
<div className="pt-6 pb-1 px-0">
<div className="font-medium text-sm truncate">{reservation.person_name}</div>
<div className="flex items-center gap-1 text-xs text-gray-600">
<FiClock className="w-3 h-3 flex-shrink-0" />
<span className="truncate">
{formatTime(reservation.from_date)} - {formatTime(reservation.to_date)}
</span>
</div>
</div>
</div>
);
if (variant === 'kanban') {
const height = ((toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60)) * HOUR_HEIGHT;
const top = ((fromDate.getHours() - DAY_START) * 60 + fromDate.getMinutes()) * (HOUR_HEIGHT / 60);
// Apply the refs conditionally, but the hooks are declared at the top level
const elementRef = (el: HTMLDivElement) => {
resizeRef.current = el;
drag(el);
drop(el);
dragPreview(el);
};
return (
<div
ref={elementRef}
className={`absolute cursor-grab ${isDragging ? 'opacity-50' : 'opacity-100'} ${isOver ? 'z-10' : ''}`}
style={{
width: 'calc(100% - 8px)',
height: `${height}px`,
top: `${top}px`,
// Combine with props style
...(props as KanbanCardProps).style
}}
onClick={onClick}
>
<div
className="relative h-full overflow-hidden rounded-md p-2 shadow-sm bg-white"
>
{cardContent}
{/* Resize handle at the bottom */}
<div
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize bg-gray-300/50 hover:bg-gray-300/80 transition-colors"
onMouseDown={handleMouseDown}
/>
</div>
</div>
);
}
// List variant
const { onEdit, onDelete, onApprove, onUnapprove } = props as ListCardProps;
return (
<div
className="rounded-md p-3 transition-all shadow-sm bg-white cursor-pointer"
onClick={(e) => {
// Check if we're clicking on the status icon - if so, don't do anything here
// as that's handled by the icon's own click handler
const statusIcon = document.querySelector(`[data-reservation-id="${reservation.id}"]`);
if (statusIcon && (statusIcon === e.target || statusIcon.contains(e.target as Node))) {
e.stopPropagation();
return;
}
// Don't trigger card click if we're clicking on or inside the dropdown
if (
showActionsDropdown &&
dropdownRef.current &&
(dropdownRef.current.contains(e.target as Node) ||
e.target === dropdownRef.current)
) {
e.stopPropagation();
return;
}
// Close any open dropdown
setActiveDropdownId(null);
// Open edit dialog when clicking on the card
onEdit(reservation);
}}
>
<div className="flex justify-between items-start">
<div className="flex-1">
{cardContent}
{/* Additional info for list view */}
<div className="mt-1 text-xs text-gray-500">
{reservation.phone && <div>{reservation.phone}</div>}
{reservation.description && <div>{reservation.description}</div>}
</div>
</div>
{/* Only render the dropdown when this card's status is clicked */}
<div className="relative">
{showActionsDropdown && (
<div
ref={dropdownRef}
className="fixed dropdown dropdown-end dropdown-open"
style={{
zIndex: 50,
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
pointerEvents: 'auto'
}}
onClick={(e) => e.stopPropagation()}
>
<ul className="dropdown-content menu p-2 shadow bg-white rounded-box w-52">
<li>
<a onClick={(e) => {
e.stopPropagation();
onEdit(reservation);
setActiveDropdownId(null);
}}>
<FiEdit className="w-4 h-4" /> Edit
</a>
</li>
<li>
<a onClick={(e) => {
e.stopPropagation();
onDelete(reservation.id);
setActiveDropdownId(null);
}}>
<FiTrash className="w-4 h-4" /> Delete
</a>
</li>
<li>
{reservation.approved ? (
<a onClick={(e) => {
e.stopPropagation();
onUnapprove(reservation.id.toString());
setActiveDropdownId(null);
}}>
<FiX className="w-4 h-4" /> Unapprove
</a>
) : (
<a onClick={(e) => {
e.stopPropagation();
onApprove(reservation.id.toString());
setActiveDropdownId(null);
}}>
<FiCheck className="w-4 h-4" /> Approve
</a>
)}
</li>
</ul>
</div>
)}
</div>
</div>
</div>
);
}