feat: implement Phase 2 Core features for AutoScheduler GTD System
- ConnectWise Manage Integration: - ConnectWiseModule with service, controller, entity - API endpoints for connection CRUD and sync - Syncs service tickets, project tickets, zero-ticket projects - Stores ConnectWise priority/SLA in task metadata - Intelligent Calendar Scheduling: - CalendarModule with connection and event entities - Support for CalDAV, Microsoft Graph, Google Calendar providers - CalendarService with sync methods for all providers - SchedulingModule with automatic scheduling engine - Finds available slots respecting working hours - Groups tasks by context, respects priority and due dates - Interactive Calendar Week View: - FullCalendar with timeGridWeek view - Drag-and-drop task rescheduling - Tasks auto-lock when manually moved - Color-coded by context - Regenerate Schedule button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,11 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@nick-tracker/shared-types": "*",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { InboxPage } from './pages/InboxPage';
|
||||
import { CalendarPage } from './pages/CalendarPage';
|
||||
import { ProjectsPage } from './pages/ProjectsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { Toaster } from './components/ui/Toaster';
|
||||
import { ToastProvider } from './components/ui/Toaster';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((state) => state.token);
|
||||
@@ -19,7 +19,7 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
@@ -38,8 +38,7 @@ function App() {
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,63 @@ import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitive.Provider;
|
||||
interface ToastData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: ToastData[];
|
||||
addToast: (toast: Omit<ToastData, 'id'>) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = React.createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<ToastData[]>([]);
|
||||
|
||||
const addToast = React.useCallback((toast: Omit<ToastData, 'id'>) => {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
setToasts((prev) => [...prev, { ...toast, id }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const removeToast = React.useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
<ToastPrimitive.Provider>
|
||||
{children}
|
||||
{toasts.map((toast) => (
|
||||
<Toast key={toast.id} variant={toast.variant}>
|
||||
<div className="grid gap-1">
|
||||
<ToastTitle>{toast.title}</ToastTitle>
|
||||
{toast.description && <ToastDescription>{toast.description}</ToastDescription>}
|
||||
</div>
|
||||
<ToastClose onClick={() => removeToast(toast.id)} />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastPrimitive.Provider>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
|
||||
@@ -81,9 +137,9 @@ ToastDescription.displayName = ToastPrimitive.Description.displayName;
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ToastPrimitive.Provider>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
</ToastPrimitive.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,3 +97,42 @@ export const userApi = {
|
||||
getPreferences: () => api.get('/users/preferences'),
|
||||
updatePreferences: (data: Record<string, unknown>) => api.patch('/users/preferences', data),
|
||||
};
|
||||
|
||||
// Calendar API
|
||||
export const calendarApi = {
|
||||
getConnections: () => api.get('/connections/calendar'),
|
||||
createConnection: (data: {
|
||||
provider: string;
|
||||
calendarUrl?: string;
|
||||
credentials?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
}) => api.post('/connections/calendar', data),
|
||||
deleteConnection: (id: string) => api.delete(`/connections/calendar/${id}`),
|
||||
syncConnection: (id: string) => api.post(`/connections/calendar/${id}/sync`),
|
||||
getEvents: (start: string, end: string) =>
|
||||
api.get('/calendar/events', { params: { start, end } }),
|
||||
};
|
||||
|
||||
// Schedule API
|
||||
export const scheduleApi = {
|
||||
regenerate: () => api.post('/schedule/regenerate'),
|
||||
clear: () => api.delete('/schedule/clear'),
|
||||
};
|
||||
|
||||
// ConnectWise API
|
||||
export const connectWiseApi = {
|
||||
getConnections: () => api.get('/connections/connectwise'),
|
||||
createConnection: (data: {
|
||||
companyId: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
apiUrl: string;
|
||||
memberId: string;
|
||||
}) => api.post('/connections/connectwise', data),
|
||||
deleteConnection: (id: string) => api.delete(`/connections/connectwise/${id}`),
|
||||
syncConnection: (id: string) => api.post(`/connections/connectwise/${id}/sync`),
|
||||
};
|
||||
|
||||
@@ -1,16 +1,270 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { EventDropArg, EventInput } from '@fullcalendar/core';
|
||||
import { startOfWeek, endOfWeek, addDays } from 'date-fns';
|
||||
import { tasksApi, calendarApi, scheduleApi } from '../lib/api';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { useToast } from '../components/ui/Toaster';
|
||||
import type { TaskResponseDto, CalendarEventResponseDto } from '@nick-tracker/shared-types';
|
||||
|
||||
interface EventResizeArg {
|
||||
event: {
|
||||
extendedProps: { taskId?: string; [key: string]: unknown };
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
};
|
||||
revert: () => void;
|
||||
}
|
||||
|
||||
const CONTEXT_COLORS: Record<string, string> = {
|
||||
DESK: '#3b82f6',
|
||||
PHONE: '#22c55e',
|
||||
ERRAND: '#f59e0b',
|
||||
HOMELAB: '#8b5cf6',
|
||||
ANYWHERE: '#64748b',
|
||||
};
|
||||
|
||||
export function CalendarPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const weekStart = useMemo(() => startOfWeek(currentDate, { weekStartsOn: 1 }), [currentDate]);
|
||||
const weekEnd = useMemo(() => endOfWeek(currentDate, { weekStartsOn: 1 }), [currentDate]);
|
||||
|
||||
const { data: tasks = [] } = useQuery<TaskResponseDto[]>({
|
||||
queryKey: ['tasks', 'scheduled'],
|
||||
queryFn: async () => {
|
||||
const response = await tasksApi.getAll({ status: 'NEXT_ACTION' });
|
||||
return response.data.filter((t: TaskResponseDto) => t.scheduledStart);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: calendarEvents = [] } = useQuery<CalendarEventResponseDto[]>({
|
||||
queryKey: ['calendar-events', weekStart.toISOString(), weekEnd.toISOString()],
|
||||
queryFn: async () => {
|
||||
const response = await calendarApi.getEvents(
|
||||
weekStart.toISOString(),
|
||||
addDays(weekEnd, 1).toISOString(),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const updateTaskMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
scheduledStart,
|
||||
scheduledEnd,
|
||||
}: {
|
||||
id: string;
|
||||
scheduledStart: string;
|
||||
scheduledEnd: string;
|
||||
}) => {
|
||||
await tasksApi.update(id, { scheduledStart, scheduledEnd, isLocked: true });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
addToast({
|
||||
title: 'Task rescheduled',
|
||||
description: 'Task has been moved and locked',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
addToast({
|
||||
title: 'Error',
|
||||
description: 'Failed to reschedule task',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: () => scheduleApi.regenerate(),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
addToast({
|
||||
title: 'Schedule regenerated',
|
||||
description: `${response.data.scheduledCount} tasks scheduled`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
addToast({
|
||||
title: 'Error',
|
||||
description: 'Failed to regenerate schedule',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleEventDrop = useCallback(
|
||||
(info: EventDropArg) => {
|
||||
const taskId = info.event.extendedProps.taskId;
|
||||
if (!taskId) {
|
||||
info.revert();
|
||||
return;
|
||||
}
|
||||
|
||||
const start = info.event.start;
|
||||
const end = info.event.end;
|
||||
|
||||
if (start && end) {
|
||||
updateTaskMutation.mutate({
|
||||
id: taskId,
|
||||
scheduledStart: start.toISOString(),
|
||||
scheduledEnd: end.toISOString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateTaskMutation],
|
||||
);
|
||||
|
||||
const handleEventResize = useCallback(
|
||||
(info: EventResizeArg) => {
|
||||
const taskId = info.event.extendedProps.taskId;
|
||||
if (!taskId) {
|
||||
info.revert();
|
||||
return;
|
||||
}
|
||||
|
||||
const start = info.event.start;
|
||||
const end = info.event.end;
|
||||
|
||||
if (start && end) {
|
||||
updateTaskMutation.mutate({
|
||||
id: taskId,
|
||||
scheduledStart: start.toISOString(),
|
||||
scheduledEnd: end.toISOString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateTaskMutation],
|
||||
);
|
||||
|
||||
const events: EventInput[] = useMemo(() => {
|
||||
const taskEvents: EventInput[] = tasks.map((task) => ({
|
||||
id: `task-${task.id}`,
|
||||
title: `${task.isLocked ? '🔒 ' : ''}${task.title}`,
|
||||
start: task.scheduledStart,
|
||||
end: task.scheduledEnd,
|
||||
backgroundColor: CONTEXT_COLORS[task.context || 'ANYWHERE'],
|
||||
borderColor: CONTEXT_COLORS[task.context || 'ANYWHERE'],
|
||||
editable: true,
|
||||
extendedProps: {
|
||||
taskId: task.id,
|
||||
context: task.context,
|
||||
isLocked: task.isLocked,
|
||||
type: 'task',
|
||||
},
|
||||
}));
|
||||
|
||||
const calEvents: EventInput[] = calendarEvents.map((event) => ({
|
||||
id: `cal-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.startTime,
|
||||
end: event.endTime,
|
||||
backgroundColor: '#e2e8f0',
|
||||
borderColor: '#94a3b8',
|
||||
textColor: '#475569',
|
||||
editable: false,
|
||||
extendedProps: {
|
||||
type: 'calendar',
|
||||
source: event.source,
|
||||
},
|
||||
}));
|
||||
|
||||
return [...taskEvents, ...calEvents];
|
||||
}, [tasks, calendarEvents]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your scheduled tasks.
|
||||
</p>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Calendar</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage your scheduled tasks. Drag to reschedule.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
disabled={regenerateMutation.isPending}
|
||||
>
|
||||
{regenerateMutation.isPending ? 'Scheduling...' : 'Regenerate Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted rounded-lg p-12 text-center text-muted-foreground">
|
||||
Calendar view will be implemented in Phase 2 with FullCalendar integration.
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: CONTEXT_COLORS.DESK }}
|
||||
/>
|
||||
<span className="text-sm">@desk</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: CONTEXT_COLORS.PHONE }}
|
||||
/>
|
||||
<span className="text-sm">@phone</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: CONTEXT_COLORS.ERRAND }}
|
||||
/>
|
||||
<span className="text-sm">@errand</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: CONTEXT_COLORS.HOMELAB }}
|
||||
/>
|
||||
<span className="text-sm">@homelab</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: CONTEXT_COLORS.ANYWHERE }}
|
||||
/>
|
||||
<span className="text-sm">@anywhere</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: '#e2e8f0' }}
|
||||
/>
|
||||
<span className="text-sm">Calendar events</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg border p-4">
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="timeGridWeek"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay',
|
||||
}}
|
||||
events={events}
|
||||
editable={true}
|
||||
droppable={true}
|
||||
eventDrop={handleEventDrop}
|
||||
eventResize={handleEventResize}
|
||||
slotMinTime="06:00:00"
|
||||
slotMaxTime="22:00:00"
|
||||
allDaySlot={false}
|
||||
weekends={true}
|
||||
nowIndicator={true}
|
||||
height="auto"
|
||||
datesSet={(dateInfo) => setCurrentDate(dateInfo.start)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user