Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
990
frontend/src/pages/OwnerScheduler.tsx
Normal file
990
frontend/src/pages/OwnerScheduler.tsx
Normal file
@@ -0,0 +1,990 @@
|
||||
/**
|
||||
* Owner Scheduler - Horizontal timeline view for owner/manager/staff users
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Appointment, User, Business } from '../types';
|
||||
import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket';
|
||||
import Portal from '../components/Portal';
|
||||
|
||||
// Time settings
|
||||
const START_HOUR = 0; // Midnight
|
||||
const END_HOUR = 24; // Midnight next day
|
||||
const PIXELS_PER_MINUTE = 2.5;
|
||||
const HEADER_HEIGHT = 48;
|
||||
const SIDEBAR_WIDTH = 250;
|
||||
|
||||
// Format duration as hours and minutes when >= 60 min
|
||||
const formatDuration = (minutes: number): string => {
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
return `${minutes} min`;
|
||||
};
|
||||
|
||||
// Layout settings
|
||||
const MIN_ROW_HEIGHT = 104;
|
||||
const EVENT_HEIGHT = 88;
|
||||
const EVENT_GAP = 8;
|
||||
|
||||
interface OwnerSchedulerProps {
|
||||
user: User;
|
||||
business: Business;
|
||||
}
|
||||
|
||||
const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
type ViewMode = 'day' | 'week' | 'month';
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
||||
const [viewDate, setViewDate] = useState(new Date());
|
||||
|
||||
// Calculate date range for fetching appointments based on current view
|
||||
const dateRange = useMemo(() => {
|
||||
const getStartOfWeek = (date: Date): Date => {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
d.setDate(d.getDate() - day);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
if (viewMode === 'day') {
|
||||
const start = new Date(viewDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return { startDate: start, endDate: end };
|
||||
} else if (viewMode === 'week') {
|
||||
const start = getStartOfWeek(viewDate);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
return { startDate: start, endDate: end };
|
||||
} else {
|
||||
// Month view
|
||||
const start = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
|
||||
const end = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1);
|
||||
return { startDate: start, endDate: end };
|
||||
}
|
||||
}, [viewMode, viewDate]);
|
||||
|
||||
// Fetch only appointments in the visible date range (plus pending ones)
|
||||
const { data: appointments = [] } = useAppointments(dateRange);
|
||||
const { data: resources = [] } = useResources();
|
||||
const { data: services = [] } = useServices();
|
||||
const updateMutation = useUpdateAppointment();
|
||||
const deleteMutation = useDeleteAppointment();
|
||||
|
||||
// Connect to WebSocket for real-time updates
|
||||
useAppointmentWebSocket();
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [draggedAppointmentId, setDraggedAppointmentId] = useState<string | null>(null);
|
||||
const [dragOffsetMinutes, setDragOffsetMinutes] = useState<number>(0); // Track where on appointment drag started
|
||||
const [previewState, setPreviewState] = useState<{ resourceId: string; startTime: Date; } | null>(null);
|
||||
const [resizeState, setResizeState] = useState<{ appointmentId: string; direction: 'start' | 'end'; startX: number; originalStart: Date; originalDuration: number; newStart?: Date; newDuration?: number; } | null>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
// State for editing appointments
|
||||
const [editDateTime, setEditDateTime] = useState('');
|
||||
const [editResource, setEditResource] = useState('');
|
||||
const [editDuration, setEditDuration] = useState(0);
|
||||
|
||||
// Update edit state when selected appointment changes
|
||||
useEffect(() => {
|
||||
if (selectedAppointment) {
|
||||
setEditDateTime(new Date(selectedAppointment.startTime).toISOString().slice(0, 16));
|
||||
setEditResource(selectedAppointment.resourceId || '');
|
||||
setEditDuration(selectedAppointment.durationMinutes);
|
||||
}
|
||||
}, [selectedAppointment]);
|
||||
|
||||
// Undo/Redo history
|
||||
type HistoryAction = {
|
||||
type: 'move' | 'resize';
|
||||
appointmentId: string;
|
||||
before: { startTime: Date; resourceId: string | null; durationMinutes?: number };
|
||||
after: { startTime: Date; resourceId: string | null; durationMinutes?: number };
|
||||
};
|
||||
const [history, setHistory] = useState<HistoryAction[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keyboard shortcuts for undo/redo
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [historyIndex, history]);
|
||||
|
||||
// Scroll to current time on mount (centered in view)
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(viewDate);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const nowDay = new Date(now);
|
||||
nowDay.setHours(0, 0, 0, 0);
|
||||
|
||||
// Only scroll if today is in the current view
|
||||
if (viewMode === 'day' && nowDay.getTime() !== today.getTime()) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const containerWidth = container.clientWidth;
|
||||
|
||||
// Calculate current time offset in pixels
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(START_HOUR, 0, 0, 0);
|
||||
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
|
||||
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE * zoomLevel;
|
||||
|
||||
// Scroll so current time is centered
|
||||
const scrollPosition = currentTimeOffset - (containerWidth / 2);
|
||||
container.scrollLeft = Math.max(0, scrollPosition);
|
||||
}, []);
|
||||
|
||||
const addToHistory = (action: HistoryAction) => {
|
||||
// Remove any history after current index (when doing new action after undo)
|
||||
const newHistory = history.slice(0, historyIndex + 1);
|
||||
newHistory.push(action);
|
||||
// Limit history to 50 actions
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift();
|
||||
} else {
|
||||
setHistoryIndex(historyIndex + 1);
|
||||
}
|
||||
setHistory(newHistory);
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (historyIndex < 0) return;
|
||||
const action = history[historyIndex];
|
||||
const appointment = appointments.find(a => a.id === action.appointmentId);
|
||||
if (!appointment) return;
|
||||
|
||||
// Revert to "before" state
|
||||
updateMutation.mutate({
|
||||
id: action.appointmentId,
|
||||
updates: {
|
||||
startTime: action.before.startTime,
|
||||
resourceId: action.before.resourceId,
|
||||
...(action.before.durationMinutes !== undefined && { durationMinutes: action.before.durationMinutes })
|
||||
}
|
||||
});
|
||||
setHistoryIndex(historyIndex - 1);
|
||||
};
|
||||
|
||||
const redo = () => {
|
||||
if (historyIndex >= history.length - 1) return;
|
||||
const action = history[historyIndex + 1];
|
||||
const appointment = appointments.find(a => a.id === action.appointmentId);
|
||||
if (!appointment) return;
|
||||
|
||||
// Apply "after" state
|
||||
updateMutation.mutate({
|
||||
id: action.appointmentId,
|
||||
updates: {
|
||||
startTime: action.after.startTime,
|
||||
resourceId: action.after.resourceId,
|
||||
...(action.after.durationMinutes !== undefined && { durationMinutes: action.after.durationMinutes })
|
||||
}
|
||||
});
|
||||
setHistoryIndex(historyIndex + 1);
|
||||
};
|
||||
|
||||
// Date navigation helpers
|
||||
const getStartOfWeek = (date: Date): Date => {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day; // Sunday as start of week
|
||||
return new Date(d.setDate(diff));
|
||||
};
|
||||
|
||||
const getStartOfMonth = (date: Date): Date => {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
};
|
||||
|
||||
const getEndOfMonth = (date: Date): Date => {
|
||||
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||||
};
|
||||
|
||||
const navigateDate = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(viewDate);
|
||||
|
||||
if (viewMode === 'day') {
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||
} else if (viewMode === 'week') {
|
||||
newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7));
|
||||
} else if (viewMode === 'month') {
|
||||
newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1));
|
||||
}
|
||||
|
||||
setViewDate(newDate);
|
||||
};
|
||||
|
||||
const getDateRangeLabel = (): string => {
|
||||
if (viewMode === 'day') {
|
||||
return viewDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||
} else if (viewMode === 'week') {
|
||||
const weekStart = getStartOfWeek(viewDate);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
return `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${weekEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
||||
} else {
|
||||
return viewDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
// Get the date range for filtering appointments
|
||||
const getDateRange = (): { start: Date; end: Date; days: Date[] } => {
|
||||
if (viewMode === 'day') {
|
||||
const start = new Date(viewDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return { start, end, days: [start] };
|
||||
} else if (viewMode === 'week') {
|
||||
const start = getStartOfWeek(viewDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
const days = Array.from({ length: 7 }, (_, i) => {
|
||||
const day = new Date(start);
|
||||
day.setDate(day.getDate() + i);
|
||||
return day;
|
||||
});
|
||||
return { start, end, days };
|
||||
} else {
|
||||
const start = getStartOfMonth(viewDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(getEndOfMonth(viewDate));
|
||||
end.setDate(end.getDate() + 1);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
const daysInMonth = end.getDate() - start.getDate();
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const day = new Date(start);
|
||||
day.setDate(day.getDate() + i);
|
||||
return day;
|
||||
});
|
||||
return { start, end, days };
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
appointment: Appointment,
|
||||
direction: 'start' | 'end'
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeState({
|
||||
appointmentId: appointment.id,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalStart: new Date(appointment.startTime),
|
||||
originalDuration: appointment.durationMinutes,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeState) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const pixelDelta = e.clientX - resizeState.startX;
|
||||
const minuteDelta = pixelDelta / (PIXELS_PER_MINUTE * zoomLevel);
|
||||
const snappedMinutes = Math.round(minuteDelta / 15) * 15;
|
||||
if (snappedMinutes === 0 && resizeState.direction === 'end') return;
|
||||
|
||||
const appointment = appointments.find(apt => apt.id === resizeState.appointmentId);
|
||||
if (!appointment) return;
|
||||
|
||||
let newStart = new Date(resizeState.originalStart);
|
||||
let newDuration = resizeState.originalDuration;
|
||||
if (resizeState.direction === 'end') {
|
||||
newDuration = Math.max(15, resizeState.originalDuration + snappedMinutes);
|
||||
} else {
|
||||
if (resizeState.originalDuration - snappedMinutes >= 15) {
|
||||
newStart = new Date(resizeState.originalStart.getTime() + snappedMinutes * 60000);
|
||||
newDuration = resizeState.originalDuration - snappedMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
setResizeState(prev => prev ? { ...prev, newStart, newDuration } : null);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (resizeState && 'newStart' in resizeState && 'newDuration' in resizeState) {
|
||||
const appointment = appointments.find(a => a.id === resizeState.appointmentId);
|
||||
if (appointment) {
|
||||
// Add to history
|
||||
addToHistory({
|
||||
type: 'resize',
|
||||
appointmentId: resizeState.appointmentId,
|
||||
before: {
|
||||
startTime: resizeState.originalStart,
|
||||
resourceId: appointment.resourceId,
|
||||
durationMinutes: resizeState.originalDuration
|
||||
},
|
||||
after: {
|
||||
startTime: resizeState.newStart as Date,
|
||||
resourceId: appointment.resourceId,
|
||||
durationMinutes: resizeState.newDuration as number
|
||||
}
|
||||
});
|
||||
|
||||
updateMutation.mutate({
|
||||
id: resizeState.appointmentId,
|
||||
updates: {
|
||||
startTime: resizeState.newStart as Date,
|
||||
durationMinutes: resizeState.newDuration as number
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
setResizeState(null);
|
||||
// Reset isResizing after a brief delay to prevent click handler from firing
|
||||
setTimeout(() => setIsResizing(false), 100);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [resizeState, zoomLevel, appointments, updateMutation]);
|
||||
|
||||
const getOffset = (date: Date) => {
|
||||
const { days } = getDateRange();
|
||||
|
||||
// Find which day this appointment belongs to
|
||||
const appointmentDate = new Date(date);
|
||||
appointmentDate.setHours(0, 0, 0, 0);
|
||||
|
||||
let dayIndex = 0;
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const day = new Date(days[i]);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
if (day.getTime() === appointmentDate.getTime()) {
|
||||
dayIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate offset within the day
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(START_HOUR, 0, 0, 0);
|
||||
const diffMinutes = (date.getTime() - startOfDay.getTime()) / (1000 * 60);
|
||||
const offsetWithinDay = Math.max(0, diffMinutes * (PIXELS_PER_MINUTE * zoomLevel));
|
||||
|
||||
// Add the day offset
|
||||
const dayOffset = dayIndex * dayWidth;
|
||||
return dayOffset + offsetWithinDay;
|
||||
};
|
||||
|
||||
const getWidth = (durationMinutes: number) => durationMinutes * (PIXELS_PER_MINUTE * zoomLevel);
|
||||
|
||||
const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => {
|
||||
if (status === 'COMPLETED' || status === 'NO_SHOW') return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
|
||||
if (status === 'CANCELLED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400';
|
||||
const now = new Date();
|
||||
if (now > endTime) return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200';
|
||||
if (now >= startTime && now <= endTime) return 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200';
|
||||
return 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200';
|
||||
};
|
||||
|
||||
// Filter appointments by date range (but include all pending requests regardless of date)
|
||||
const { start: rangeStart, end: rangeEnd } = getDateRange();
|
||||
const filteredAppointments = useMemo(() => {
|
||||
return appointments.filter(apt => {
|
||||
// Always include pending requests (no resourceId)
|
||||
if (!apt.resourceId) return true;
|
||||
|
||||
// Filter scheduled appointments by date range
|
||||
const aptDate = new Date(apt.startTime);
|
||||
return aptDate >= rangeStart && aptDate < rangeEnd;
|
||||
});
|
||||
}, [appointments, rangeStart, rangeEnd]);
|
||||
|
||||
const resourceLayouts = useMemo(() => {
|
||||
return resources.map(resource => {
|
||||
const allResourceApps = filteredAppointments.filter(a => a.resourceId === resource.id);
|
||||
const layoutApps = allResourceApps.filter(a => a.id !== draggedAppointmentId);
|
||||
|
||||
// Add preview for dragged appointment
|
||||
if (previewState && previewState.resourceId === resource.id && draggedAppointmentId) {
|
||||
const original = filteredAppointments.find(a => a.id === draggedAppointmentId);
|
||||
if (original) {
|
||||
layoutApps.push({ ...original, startTime: previewState.startTime, id: 'PREVIEW' });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply resize state to appointments for live preview
|
||||
const layoutAppsWithResize = layoutApps.map(apt => {
|
||||
if (resizeState && apt.id === resizeState.appointmentId && resizeState.newStart && resizeState.newDuration) {
|
||||
return { ...apt, startTime: resizeState.newStart, durationMinutes: resizeState.newDuration };
|
||||
}
|
||||
return apt;
|
||||
});
|
||||
|
||||
layoutAppsWithResize.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() || b.durationMinutes - a.durationMinutes);
|
||||
|
||||
const lanes: number[] = [];
|
||||
const visibleAppointments = layoutAppsWithResize.map(apt => {
|
||||
const start = new Date(apt.startTime).getTime();
|
||||
const end = start + apt.durationMinutes * 60000;
|
||||
let laneIndex = -1;
|
||||
|
||||
for (let i = 0; i < lanes.length; i++) {
|
||||
if (lanes[i] <= start) {
|
||||
laneIndex = i;
|
||||
lanes[i] = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (laneIndex === -1) {
|
||||
lanes.push(end);
|
||||
laneIndex = lanes.length - 1;
|
||||
}
|
||||
return { ...apt, laneIndex };
|
||||
});
|
||||
|
||||
const laneCount = Math.max(1, lanes.length);
|
||||
const requiredHeight = Math.max(MIN_ROW_HEIGHT, (laneCount * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP);
|
||||
const finalAppointments = [...visibleAppointments, ...allResourceApps.filter(a => a.id === draggedAppointmentId).map(a => ({ ...a, laneIndex: 0 }))];
|
||||
|
||||
return { resource, height: requiredHeight, appointments: finalAppointments, laneCount };
|
||||
});
|
||||
}, [filteredAppointments, draggedAppointmentId, previewState, resources, resizeState]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, appointmentId: string) => {
|
||||
if (resizeState) return e.preventDefault();
|
||||
setIsDragging(true);
|
||||
e.dataTransfer.setData('appointmentId', appointmentId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
// Calculate where on the appointment the drag started (relative to appointment, not timeline)
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left; // Just the offset within the appointment itself
|
||||
const offsetMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
|
||||
setDragOffsetMinutes(offsetMinutes);
|
||||
|
||||
setTimeout(() => setDraggedAppointmentId(appointmentId), 0);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedAppointmentId(null);
|
||||
setPreviewState(null);
|
||||
// Reset isDragging after a short delay to allow click detection
|
||||
setTimeout(() => setIsDragging(false), 100);
|
||||
};
|
||||
|
||||
const handleAppointmentClick = (appointment: Appointment) => {
|
||||
// Only open modal if we didn't actually drag or resize
|
||||
if (!isDragging && !isResizing) {
|
||||
setSelectedAppointment(appointment);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAppointment = () => {
|
||||
if (!selectedAppointment) return;
|
||||
|
||||
// Validate duration is at least 15 minutes
|
||||
const validDuration = editDuration >= 15 ? editDuration : 15;
|
||||
|
||||
const updates: any = {
|
||||
startTime: new Date(editDateTime),
|
||||
durationMinutes: validDuration,
|
||||
};
|
||||
|
||||
if (editResource) {
|
||||
updates.resourceId = editResource;
|
||||
}
|
||||
|
||||
updateMutation.mutate({
|
||||
id: selectedAppointment.id,
|
||||
updates
|
||||
});
|
||||
|
||||
setSelectedAppointment(null);
|
||||
};
|
||||
|
||||
const handleTimelineDragOver = (e: React.DragEvent) => {
|
||||
if (resizeState) return;
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||
if (!scrollContainerRef.current || !draggedAppointmentId) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left + container.scrollLeft;
|
||||
const offsetY = e.clientY - rect.top + container.scrollTop - HEADER_HEIGHT;
|
||||
if (offsetY < 0) return;
|
||||
|
||||
let targetResourceId: string | null = null;
|
||||
for (let i = 0, currentTop = 0; i < resourceLayouts.length; i++) {
|
||||
if (offsetY >= currentTop && offsetY < currentTop + resourceLayouts[i].height) {
|
||||
targetResourceId = resourceLayouts[i].resource.id; break;
|
||||
}
|
||||
currentTop += resourceLayouts[i].height;
|
||||
}
|
||||
if (!targetResourceId) return;
|
||||
|
||||
// Calculate new start time, accounting for where on the appointment the drag started
|
||||
const mouseMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
|
||||
const newStartMinutes = mouseMinutes - dragOffsetMinutes;
|
||||
const newStartTime = new Date(viewDate);
|
||||
newStartTime.setHours(START_HOUR, 0, 0, 0);
|
||||
newStartTime.setTime(newStartTime.getTime() + newStartMinutes * 60000);
|
||||
|
||||
if (!previewState || previewState.resourceId !== targetResourceId || previewState.startTime.getTime() !== newStartTime.getTime()) {
|
||||
setPreviewState({ resourceId: targetResourceId, startTime: newStartTime });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimelineDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault(); if (resizeState) return;
|
||||
const appointmentId = e.dataTransfer.getData('appointmentId');
|
||||
if (appointmentId && previewState) {
|
||||
const appointment = appointments.find(a => a.id === appointmentId);
|
||||
if (appointment) {
|
||||
// Add to history
|
||||
addToHistory({
|
||||
type: 'move',
|
||||
appointmentId,
|
||||
before: {
|
||||
startTime: new Date(appointment.startTime),
|
||||
resourceId: appointment.resourceId,
|
||||
durationMinutes: appointment.durationMinutes
|
||||
},
|
||||
after: {
|
||||
startTime: previewState.startTime,
|
||||
resourceId: previewState.resourceId,
|
||||
durationMinutes: appointment.durationMinutes
|
||||
}
|
||||
});
|
||||
|
||||
updateMutation.mutate({
|
||||
id: appointmentId,
|
||||
updates: {
|
||||
startTime: previewState.startTime,
|
||||
durationMinutes: appointment.durationMinutes,
|
||||
resourceId: previewState.resourceId,
|
||||
status: appointment.status === 'PENDING' ? 'CONFIRMED' : appointment.status
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
setDraggedAppointmentId(null); setPreviewState(null);
|
||||
};
|
||||
|
||||
const handleDropToPending = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const appointmentId = e.dataTransfer.getData('appointmentId');
|
||||
if (appointmentId) {
|
||||
updateMutation.mutate({
|
||||
id: appointmentId,
|
||||
updates: { resourceId: null, status: 'PENDING' }
|
||||
});
|
||||
}
|
||||
setDraggedAppointmentId(null); setPreviewState(null);
|
||||
};
|
||||
|
||||
const handleDropToArchive = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const appointmentId = e.dataTransfer.getData('appointmentId');
|
||||
if (appointmentId) {
|
||||
deleteMutation.mutate(appointmentId);
|
||||
}
|
||||
setDraggedAppointmentId(null); setPreviewState(null);
|
||||
};
|
||||
|
||||
const handleSidebarDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||
if (previewState) setPreviewState(null);
|
||||
};
|
||||
|
||||
const { days } = getDateRange();
|
||||
const dayWidth = (END_HOUR - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel);
|
||||
const timelineWidth = dayWidth * days.length;
|
||||
const timeMarkers = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
|
||||
const pendingAppointments = filteredAppointments.filter(a => !a.resourceId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden select-none bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm shrink-0 z-10 transition-colors duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Date Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigateDate('prev')}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Previous"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-md text-gray-700 dark:text-gray-200 font-medium transition-colors duration-200 w-[320px] justify-center">
|
||||
<CalendarIcon size={16} />
|
||||
<span className="text-center">{getDateRangeLabel()}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigateDate('next')}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Next"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Mode Switcher */}
|
||||
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
|
||||
<button
|
||||
onClick={() => setViewMode('day')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'day'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Day
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('week')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'week'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('month')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'month'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" onClick={() => setZoomLevel(Math.max(0.5, zoomLevel - 0.25))}>-</button>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Zoom</span>
|
||||
<button className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors" onClick={() => setZoomLevel(Math.min(2, zoomLevel + 0.25))}>+</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={historyIndex < 0}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
historyIndex < 0
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={redo}
|
||||
disabled={historyIndex >= history.length - 1}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
historyIndex >= history.length - 1
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors shadow-sm">
|
||||
+ New Appointment
|
||||
</button>
|
||||
<button className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 transition-colors">
|
||||
<Filter size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: SIDEBAR_WIDTH }}>
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: HEADER_HEIGHT }}>Resources</div>
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{resourceLayouts.map(layout => (
|
||||
<div key={layout.resource.id} className="flex items-center px-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group" style={{ height: layout.height }}>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0"><GripVertical size={16} /></div>
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resource.name}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">{layout.resource.type.toLowerCase()} {layout.laneCount > 1 && <span className="text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/50 px-1 rounded text-[10px]">{layout.laneCount} lanes</span>}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToPending}>
|
||||
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0"><Clock size={12} /> Pending Requests ({pendingAppointments.length})</h3>
|
||||
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
|
||||
{pendingAppointments.length === 0 && !draggedAppointmentId && (<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>)}
|
||||
{draggedAppointmentId && (<div className="border-2 border-dashed border-blue-300 dark:border-blue-700 rounded-lg p-4 text-center mb-2 bg-blue-50 dark:bg-blue-900/30"><span className="text-sm text-blue-600 dark:text-blue-400 font-medium">Drop here to unassign</span></div>)}
|
||||
{pendingAppointments.map(apt => {
|
||||
const service = services.find(s => s.id === apt.serviceId);
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
className={`p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${draggedAppointmentId === apt.id ? 'opacity-50' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, apt.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleAppointmentClick(apt)}
|
||||
>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-white">{apt.customerName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{service?.name}</p>
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<Clock size={10} /> {formatDuration(apt.durationMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={`shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 transition-all duration-200 ${draggedAppointmentId ? 'opacity-100 translate-y-0' : 'opacity-50 translate-y-0'}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToArchive}><div className={`flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed transition-colors ${draggedAppointmentId ? 'border-red-300 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400' : 'border-gray-200 dark:border-gray-700 bg-transparent text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-500'}`}><Trash2 size={16} /><span className="text-xs font-medium">Drop here to archive</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 relative transition-colors duration-200">
|
||||
<div className="flex-1 overflow-auto timeline-scroll" ref={scrollContainerRef} onDragOver={handleTimelineDragOver} onDrop={handleTimelineDrop}>
|
||||
<div style={{ width: timelineWidth, minWidth: '100%' }} className="relative min-h-full">
|
||||
{/* Timeline Header */}
|
||||
<div className="sticky top-0 z-10 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
|
||||
{viewMode !== 'day' && (
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{days.map((day, dayIndex) => (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 text-center bg-gray-100 dark:bg-gray-700/50"
|
||||
style={{ width: dayWidth }}
|
||||
>
|
||||
{day.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex" style={{ height: HEADER_HEIGHT }}>
|
||||
{days.map((day, dayIndex) => (
|
||||
<div key={dayIndex} className="flex flex-shrink-0" style={{ width: dayWidth }}>
|
||||
{timeMarkers.map(hour => (
|
||||
<div
|
||||
key={`${dayIndex}-${hour}`}
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-xs font-medium text-gray-400 select-none"
|
||||
style={{ width: 60 * (PIXELS_PER_MINUTE * zoomLevel) }}
|
||||
>
|
||||
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Current time indicator - only show if current day is in view */}
|
||||
{days.some(day => {
|
||||
const today = new Date();
|
||||
const dayDate = new Date(day);
|
||||
return today.toDateString() === dayDate.toDateString();
|
||||
}) && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 border-l-2 border-red-500 z-30 pointer-events-none"
|
||||
style={{ left: getOffset(new Date()), marginTop: viewMode === 'day' ? HEADER_HEIGHT : HEADER_HEIGHT * 2 }}
|
||||
>
|
||||
<div className="absolute -top-1 -left-1.5 w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
{/* Vertical grid lines for each day */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{days.map((day, dayIndex) => (
|
||||
<React.Fragment key={dayIndex}>
|
||||
{timeMarkers.map(hour => (
|
||||
<div
|
||||
key={`${dayIndex}-${hour}`}
|
||||
className="absolute top-0 bottom-0 border-r border-dashed border-gray-100 dark:border-gray-800"
|
||||
style={{ left: (dayIndex * dayWidth) + ((hour - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel)) }}
|
||||
></div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{resourceLayouts.map(layout => (<div key={layout.resource.id} className="relative border-b border-gray-100 dark:border-gray-800 transition-colors" style={{ height: layout.height }}>{layout.appointments.map(apt => {
|
||||
const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP;
|
||||
const service = services.find(s => s.id === apt.serviceId);
|
||||
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}>
|
||||
{!isPreview && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
|
||||
<div className="font-semibold text-sm truncate pointer-events-none">{apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0">•</span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
|
||||
</div>);
|
||||
})}</div>))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Detail/Edit Modal */}
|
||||
{selectedAppointment && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setSelectedAppointment(null)}>
|
||||
<div className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Edit Appointment'}
|
||||
</h3>
|
||||
<button onClick={() => setSelectedAppointment(null)} className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Customer Info */}
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center">
|
||||
<UserIcon size={20} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Customer</p>
|
||||
<p className="text-lg font-semibold text-gray-900 dark:text-white">{selectedAppointment.customerName}</p>
|
||||
{selectedAppointment.customerEmail && (
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
<Mail size={14} />
|
||||
<span>{selectedAppointment.customerEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedAppointment.customerPhone && (
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
<Phone size={14} />
|
||||
<span>{selectedAppointment.customerPhone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Service</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{services.find(s => s.id === selectedAppointment.serviceId)?.name}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Status</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white capitalize">{selectedAppointment.status.toLowerCase().replace('_', ' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable Fields */}
|
||||
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Schedule Details</h4>
|
||||
|
||||
{/* Date & Time Picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Date & Time
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editDateTime}
|
||||
onChange={(e) => setEditDateTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource Selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Assign to Resource {!selectedAppointment.resourceId && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<select
|
||||
value={editResource}
|
||||
onChange={(e) => setEditResource(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{resources.map(resource => (
|
||||
<option key={resource.id} value={resource.id}>
|
||||
{resource.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Duration Input */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Duration (minutes) {!selectedAppointment.resourceId && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="15"
|
||||
step="15"
|
||||
value={editDuration || 15}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
setEditDuration(value >= 15 ? value : 15);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{selectedAppointment.notes && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Notes</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-200">{selectedAppointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 flex justify-end gap-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setSelectedAppointment(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveAppointment}
|
||||
disabled={!selectedAppointment.resourceId && (!editResource || !editDuration || editDuration < 15)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{!selectedAppointment.resourceId ? 'Schedule Appointment' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OwnerScheduler;
|
||||
Reference in New Issue
Block a user