Add scheduler improvements, API endpoints, and month calendar view

Backend:
- Add /api/customers/ endpoint (CustomerViewSet, CustomerSerializer)
- Add /api/services/ endpoint with Service model and migrations
- Add Resource.type field (STAFF, ROOM, EQUIPMENT) with migration
- Fix EventSerializer to return resource_id, customer_id, service_id
- Add date range filtering to EventViewSet (start_date, end_date params)
- Add create_demo_appointments management command
- Set default brand colors in business API

Frontend:
- Add calendar grid view for month mode in OwnerScheduler
- Fix sidebar navigation active link contrast (bg-white/10)
- Add default primaryColor/secondaryColor fallbacks in useBusiness
- Disable WebSocket (backend not implemented) to stop reconnect loop
- Fix Resource.type.toLowerCase() error by adding type to backend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-27 20:09:04 -05:00
parent 38c43d3f27
commit 373257469b
38 changed files with 977 additions and 2111 deletions

View File

@@ -224,6 +224,56 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
};
// Generate calendar grid data for month view
const getMonthCalendarData = () => {
const firstDay = getStartOfMonth(viewDate);
const lastDay = getEndOfMonth(viewDate);
const startDayOfWeek = firstDay.getDay(); // 0 = Sunday
const daysInMonth = lastDay.getDate();
// Create array of week rows
const weeks: (Date | null)[][] = [];
let currentWeek: (Date | null)[] = [];
// Add empty cells for days before the first of the month
for (let i = 0; i < startDayOfWeek; i++) {
currentWeek.push(null);
}
// Add all days of the month
for (let day = 1; day <= daysInMonth; day++) {
currentWeek.push(new Date(viewDate.getFullYear(), viewDate.getMonth(), day));
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
}
// Add empty cells for remaining days after the last of the month
if (currentWeek.length > 0) {
while (currentWeek.length < 7) {
currentWeek.push(null);
}
weeks.push(currentWeek);
}
return weeks;
};
// Get appointments for a specific day (for month view)
const getAppointmentsForDay = (date: Date) => {
const dayStart = new Date(date);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(date);
dayEnd.setHours(23, 59, 59, 999);
return filteredAppointments.filter(apt => {
if (!apt.resourceId) return false; // Exclude pending
const aptDate = new Date(apt.startTime);
return aptDate >= dayStart && aptDate <= dayEnd;
}).sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
};
const navigateDate = (direction: 'prev' | 'next') => {
const newDate = new Date(viewDate);
@@ -685,11 +735,13 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
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>
{viewMode !== 'month' && (
<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}
@@ -727,6 +779,108 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
</div>
</div>
{/* Month View - Calendar Grid */}
{viewMode === 'month' && (
<div className="flex flex-1 overflow-hidden">
{/* Pending Sidebar for Month View */}
<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={`flex-1 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 flex flex-col transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`}>
<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 && (<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</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-pointer hover:shadow-md transition-all"
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>
</div>
{/* Calendar Grid */}
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 transition-colors duration-200">
<div className="flex-1 overflow-auto p-4">
{/* Day headers */}
<div className="grid grid-cols-7 gap-px bg-gray-200 dark:bg-gray-700 rounded-t-lg overflow-hidden">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="bg-gray-50 dark:bg-gray-800 px-2 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{day}
</div>
))}
</div>
{/* Calendar weeks */}
<div className="grid grid-cols-7 gap-px bg-gray-200 dark:bg-gray-700 rounded-b-lg overflow-hidden">
{getMonthCalendarData().flat().map((date, index) => {
const isToday = date && new Date().toDateString() === date.toDateString();
const dayAppointments = date ? getAppointmentsForDay(date) : [];
const displayedAppointments = dayAppointments.slice(0, 3);
const remainingCount = dayAppointments.length - 3;
return (
<div
key={index}
className={`bg-white dark:bg-gray-900 min-h-[120px] p-2 transition-colors ${
date ? 'hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer' : 'bg-gray-50 dark:bg-gray-800/50'
}`}
onClick={() => { if (date) { setViewDate(date); setViewMode('day'); } }}
>
{date && (
<>
<div className={`text-sm font-medium mb-1 ${
isToday
? 'w-7 h-7 flex items-center justify-center rounded-full bg-brand-500 text-white'
: 'text-gray-700 dark:text-gray-300'
}`}>
{date.getDate()}
</div>
<div className="space-y-1">
{displayedAppointments.map(apt => {
const service = services.find(s => s.id === apt.serviceId);
const resource = resources.find(r => r.id === apt.resourceId);
const startTime = new Date(apt.startTime);
return (
<div
key={apt.id}
className="text-xs p-1.5 rounded bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-200 truncate cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-800/50 transition-colors"
onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }}
title={`${apt.customerName} - ${service?.name} with ${resource?.name}`}
>
<span className="font-medium">{startTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
{' '}{apt.customerName}
</div>
);
})}
{remainingCount > 0 && (
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium pl-1">
+{remainingCount} more
</div>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
)}
{/* Day/Week View - Timeline */}
{viewMode !== 'month' && (
<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>
@@ -849,6 +1003,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
</div>
</div>
</div>
)}
{/* Appointment Detail/Edit Modal */}
{selectedAppointment && (