vkashti / app / admin / reservations / ReservationCard.tsx
ReservationCard.tsx
Raw
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>
  );
}