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:
@@ -96,11 +96,36 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
||||
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
||||
setCookie('access_token', response.data.token, 7);
|
||||
|
||||
// Invalidate queries to refetch user data
|
||||
await queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['currentBusiness'] });
|
||||
// Fetch user data to determine redirect
|
||||
const userResponse = await apiClient.get('/api/auth/me/');
|
||||
const userData = userResponse.data;
|
||||
|
||||
// Reload page to trigger auth flow
|
||||
// Determine the correct subdomain based on user role
|
||||
const currentHostname = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
let targetSubdomain: string | null = null;
|
||||
|
||||
// Platform users (superuser, platform_manager, platform_support)
|
||||
if (['superuser', 'platform_manager', 'platform_support'].includes(userData.role)) {
|
||||
targetSubdomain = 'platform';
|
||||
}
|
||||
// Business users - redirect to their business subdomain
|
||||
else if (userData.business_subdomain) {
|
||||
targetSubdomain = userData.business_subdomain;
|
||||
}
|
||||
|
||||
// Check if we need to redirect to a different subdomain
|
||||
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
|
||||
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
|
||||
|
||||
if (needsRedirect) {
|
||||
// Redirect to the correct subdomain
|
||||
const portStr = currentPort ? `:${currentPort}` : '';
|
||||
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Already on correct subdomain - just reload to update auth state
|
||||
window.location.reload();
|
||||
} catch (error: any) {
|
||||
console.error('Quick login failed:', error);
|
||||
|
||||
@@ -36,7 +36,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
|
||||
const baseClasses = `flex items-center gap-3 py-3 text-sm font-medium rounded-lg transition-colors`;
|
||||
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
|
||||
const activeClasses = 'bg-opacity-10 text-white bg-white';
|
||||
const activeClasses = 'bg-white/10 text-white';
|
||||
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
|
||||
const disabledClasses = 'text-white/30 cursor-not-allowed';
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ interface UseAppointmentWebSocketOptions {
|
||||
onError?: (error: Event) => void;
|
||||
}
|
||||
|
||||
// WebSocket is not yet implemented in the backend - disable for now
|
||||
const WEBSOCKET_ENABLED = false;
|
||||
|
||||
/**
|
||||
* Transform backend appointment format to frontend format
|
||||
*/
|
||||
@@ -60,6 +63,9 @@ function transformAppointment(data: WebSocketMessage['appointment']): Appointmen
|
||||
*/
|
||||
export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions = {}) {
|
||||
const { enabled = true, onConnected, onDisconnected, onError } = options;
|
||||
|
||||
// Early return if WebSocket is globally disabled
|
||||
const effectivelyEnabled = enabled && WEBSOCKET_ENABLED;
|
||||
const queryClient = useQueryClient();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -138,7 +144,7 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
||||
// Main effect to manage WebSocket connection
|
||||
// Only depends on `enabled` - other values are read from refs or called as functions
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
if (!effectivelyEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -285,7 +291,7 @@ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions
|
||||
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [enabled]); // Only re-run when enabled changes
|
||||
}, [effectivelyEnabled]); // Only re-run when enabled changes
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
isCleaningUpRef.current = false;
|
||||
|
||||
@@ -30,8 +30,8 @@ export const useCurrentBusiness = () => {
|
||||
id: String(data.id),
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
primaryColor: data.primary_color,
|
||||
secondaryColor: data.secondary_color,
|
||||
primaryColor: data.primary_color || '#3B82F6', // Blue-500 default
|
||||
secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default
|
||||
logoUrl: data.logo_url,
|
||||
whitelabelEnabled: data.whitelabel_enabled,
|
||||
plan: data.tier, // Map tier to plan
|
||||
|
||||
@@ -43,6 +43,7 @@ export const useCustomers = (filters?: CustomerFilters) => {
|
||||
user_data: c.user_data, // Include user_data for masquerading
|
||||
}));
|
||||
},
|
||||
retry: false, // Don't retry on 404 - endpoint may not exist yet
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const useServices = () => {
|
||||
description: s.description || '',
|
||||
}));
|
||||
},
|
||||
retry: false, // Don't retry on 404 - endpoint may not exist yet
|
||||
});
|
||||
};
|
||||
|
||||
@@ -45,6 +46,7 @@ export const useService = (id: string) => {
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -24,20 +24,20 @@ const VerifyEmail: React.FC = () => {
|
||||
setStatus('loading');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/api/auth/emails/verify/${token}/`);
|
||||
const response = await apiClient.post('/api/auth/email/verify/', { token });
|
||||
|
||||
// Immediately clear auth cookies to log out
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
|
||||
if (response.data.message === 'Email is already verified') {
|
||||
if (response.data.detail === 'Email already verified.') {
|
||||
setStatus('already_verified');
|
||||
} else {
|
||||
setStatus('success');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
setErrorMessage(err.response?.data?.detail || 'Failed to verify email');
|
||||
setErrorMessage(err.response?.data?.error || 'Failed to verify email');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user