From 28b805a01f82a3c31346844f6ad1e6894901e4f6 Mon Sep 17 00:00:00 2001 From: Robert Isaacs Date: Fri, 3 Jan 2025 10:30:02 -0500 Subject: [PATCH 1/3] removed duplicate FSM state (#152) --- .../ai-automation/web/src/app/api/ai/route.ts | 48 +------------------ 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/tools/ai-automation/web/src/app/api/ai/route.ts b/tools/ai-automation/web/src/app/api/ai/route.ts index e1c17840..1b65c059 100644 --- a/tools/ai-automation/web/src/app/api/ai/route.ts +++ b/tools/ai-automation/web/src/app/api/ai/route.ts @@ -464,53 +464,7 @@ async function handleAIRequest(rawMessages: LocalMessage[]) { currentState = transitionState(currentState, 'TOOL_EXECUTING'); break; } - - case 'TOOL_REQUESTED': { - // We have a tool block that we want to execute - if (ctx.currentToolIndex === null || !ctx.toolUseBlocks.has(ctx.currentToolIndex)) { - currentState = transitionState(currentState, 'READING_STREAM'); - break; - } - - // Validate JSON - const currentTool = ctx.toolUseBlocks.get(ctx.currentToolIndex)!; - try { - const inputObject = ctx.currentInput.trim() === '' ? {} : JSON.parse(ctx.currentInput); - currentTool.input = inputObject; - currentTool.id = ctx.toolCallId; - currentTool.status = 'executing'; - } catch (err) { - console.error('Failed to parse tool input JSON:', { - err, - input: ctx.accumulatedInput, - }); - currentTool.status = 'complete'; - ctx.toolUseBlocks.delete(ctx.currentToolIndex); - currentState = transitionState(currentState, 'READING_STREAM'); - break; - } - - // Add assistant message with the tool call - const assistantMessage: LocalMessage = { - role: 'assistant', - content: null, - tool_calls: [ - { - id: ctx.toolCallId, - type: 'function', - function: { - name: currentTool.name, - arguments: JSON.stringify(currentTool.input), - }, - }, - ], - }; - ctx.currentMessages.push(assistantMessage); - - // Transition to the next state - currentState = transitionState(currentState, 'TOOL_EXECUTING'); - break; - } + case 'TOOL_EXECUTING': { if (ctx.currentToolIndex === null || !ctx.toolUseBlocks.has(ctx.currentToolIndex)) { currentState = transitionState(currentState, 'READING_STREAM'); From ea65a6c279d590149952315a133c65a7e6741338 Mon Sep 17 00:00:00 2001 From: Robert Isaacs Date: Fri, 3 Jan 2025 11:09:19 -0500 Subject: [PATCH 2/3] fix: do not show unsaved dialog without changes (#153) We should consider making change tracking a more fundamental ui service. --- server/src/components/projects/TaskForm.tsx | 61 ++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/server/src/components/projects/TaskForm.tsx b/server/src/components/projects/TaskForm.tsx index b2f5a25d..490c9356 100644 --- a/server/src/components/projects/TaskForm.tsx +++ b/server/src/components/projects/TaskForm.tsx @@ -76,7 +76,7 @@ export default function TaskForm({ const [taskResources, setTaskResources] = useState(task?.task_id ? [] : []); const [tempTaskResources, setTempTaskResources] = useState([]); const [showAgentPicker, setShowAgentPicker] = useState(false); - const [pendingTicketLinks, setPendingTicketLinks] = useState([]); + const [pendingTicketLinks, setPendingTicketLinks] = useState(task?.ticket_links || []); const [selectedStatusId, setSelectedStatusId] = useState( task?.project_status_mapping_id || @@ -314,9 +314,66 @@ export default function TaskForm({ } }; + const hasChanges = (): boolean => { + if (mode === 'create') return true; // Always confirm for new tasks + + // Compare all form fields with their original values + if (!task) return false; + + if (taskName !== task.task_name) return true; + if (description !== task.description) return true; + if (selectedPhaseId !== task.phase_id) return true; + if (selectedStatusId !== task.project_status_mapping_id) return true; + if (estimatedHours !== Number(task.estimated_hours)) return true; + if (actualHours !== Number(task.actual_hours)) return true; + if (assignedUser !== task.assigned_to) return true; + + // Compare checklist items + if (checklistItems.length !== task.checklist_items?.length) return true; + for (let i = 0; i < checklistItems.length; i++) { + const current = checklistItems[i]; + const original = task.checklist_items?.[i]; + if (!original) return true; + if (current.item_name !== original.item_name) return true; + if (current.completed !== original.completed) return true; + } + + // Compare resources + const currentResources = task.task_id ? taskResources : tempTaskResources; + const initialResourcesLength = task.task_id ? taskResources.length : 0; + if (currentResources.length !== initialResourcesLength) return true; + + if (task.task_id && taskResources.length > 0) { + const sortedCurrentResources = [...taskResources].sort((a, b) => + a.additional_user_id.localeCompare(b.additional_user_id) + ); + const sortedInitialResources = [...taskResources].sort((a, b) => + a.additional_user_id.localeCompare(b.additional_user_id) + ); + for (let i = 0; i < sortedCurrentResources.length; i++) { + if (sortedCurrentResources[i].additional_user_id !== sortedInitialResources[i].additional_user_id) return true; + } + } + + // Compare ticket links - only compare ticket IDs since other fields might differ in format + const currentTicketIds = new Set(pendingTicketLinks.map(link => link.ticket_id)); + const originalTicketIds = new Set(task.ticket_links?.map(link => link.ticket_id) || []); + + if (currentTicketIds.size !== originalTicketIds.size) return true; + for (const id of currentTicketIds) { + if (!originalTicketIds.has(id)) return true; + } + + return false; + }; + const handleCancelClick = (e?: React.MouseEvent) => { e?.preventDefault(); - setShowCancelConfirm(true); + if (hasChanges()) { + setShowCancelConfirm(true); + } else { + onClose(); + } }; const handleCancelConfirm = () => { From 2d38da0b6636f35fe5a6dae7c3e8dcfab11c2c8d Mon Sep 17 00:00:00 2001 From: Robert Isaacs Date: Fri, 3 Jan 2025 12:48:13 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20ad-hoc=20?= =?UTF-8?q?schedule=20entries=20=F0=9F=95=92=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made work_item_id nullable in the database schema Added ad_hoc as a valid work_item_type Updated UI components to handle ad-hoc entries Enhanced validation logic for ad-hoc entries Improved logging and error handling "Curiouser and curiouser! 🐇 Now you can schedule ad-hoc entries, just like the White Rabbit's ever-changing appointments. Time is a funny thing, isn't it? ⏳" --- docs/scheduling.md | 2 +- ...50103172553_add_adhoc_schedule_entries.cjs | 52 ++++++ .../components/time-management/EntryPopup.tsx | 140 ++++++++++------ .../time-management/ScheduleCalendar.tsx | 151 ++++++++++++------ .../time-management/SelectedWorkItem.tsx | 4 +- .../time-management/WorkItemPicker.tsx | 20 ++- server/src/interfaces/schedule.interfaces.ts | 2 +- server/src/interfaces/workItem.interfaces.ts | 2 +- server/src/lib/actions/scheduleActions.ts | 27 +++- server/src/lib/models/scheduleEntry.ts | 27 +++- server/src/lib/schemas/scheduleSchemas.ts | 49 ++++-- 11 files changed, 344 insertions(+), 132 deletions(-) create mode 100644 server/migrations/20250103172553_add_adhoc_schedule_entries.cjs diff --git a/docs/scheduling.md b/docs/scheduling.md index 9aded78a..c9483bfa 100644 --- a/docs/scheduling.md +++ b/docs/scheduling.md @@ -20,7 +20,7 @@ The scheduling system provides a comprehensive solution for managing appointment ### Core Files * Models: - - `server/src/lib/models/scheduleEntry.ts`: Core scheduling logic + - `@`: Core scheduling logic - `server/src/interfaces/schedule.interfaces.ts`: TypeScript interfaces - `server/src/lib/schemas/scheduleSchemas.ts`: Zod validation schemas diff --git a/server/migrations/20250103172553_add_adhoc_schedule_entries.cjs b/server/migrations/20250103172553_add_adhoc_schedule_entries.cjs new file mode 100644 index 00000000..23afaf8a --- /dev/null +++ b/server/migrations/20250103172553_add_adhoc_schedule_entries.cjs @@ -0,0 +1,52 @@ +/** + * Add support for ad-hoc schedule entries + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function(knex) { + // First, make work_item_id nullable + await knex.schema.alterTable('schedule_entries', table => { + table.uuid('work_item_id').alter().nullable(); + }); + + // Then update the work_item_type check constraint + await knex.raw(` + ALTER TABLE schedule_entries + DROP CONSTRAINT IF EXISTS schedule_entries_work_item_type_check; + `); + + await knex.raw(` + ALTER TABLE schedule_entries + ADD CONSTRAINT schedule_entries_work_item_type_check + CHECK (work_item_type = ANY (ARRAY['project_task'::text, 'ticket'::text, 'ad_hoc'::text])); + `); +}; + +/** + * Revert support for ad-hoc schedule entries + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + // First, ensure no ad-hoc entries exist + await knex('schedule_entries') + .where({ work_item_type: 'ad_hoc' }) + .delete(); + + // Then make work_item_id required again + await knex.schema.alterTable('schedule_entries', table => { + table.uuid('work_item_id').alter().notNullable(); + }); + + // Finally, restore the original work_item_type check constraint + await knex.raw(` + ALTER TABLE schedule_entries + DROP CONSTRAINT IF EXISTS schedule_entries_work_item_type_check; + `); + + await knex.raw(` + ALTER TABLE schedule_entries + ADD CONSTRAINT schedule_entries_work_item_type_check + CHECK (work_item_type = ANY (ARRAY['project_task'::text, 'ticket'::text])); + `); +}; diff --git a/server/src/components/time-management/EntryPopup.tsx b/server/src/components/time-management/EntryPopup.tsx index 5f225f9e..8a61e1b2 100644 --- a/server/src/components/time-management/EntryPopup.tsx +++ b/server/src/components/time-management/EntryPopup.tsx @@ -34,51 +34,16 @@ const EntryPopup: React.FC = ({ loading = false, error = null }) => { - const [entryData, setEntryData] = useState>({ - entry_id: '', - title: '', - scheduled_start: new Date(), - scheduled_end: new Date(), - notes: '', - created_at: new Date(), - updated_at: new Date(), - work_item_id: '', - status: '', - work_item_type: 'project_task', - assigned_user_ids: [], - }); - const [selectedWorkItem, setSelectedWorkItem] = useState | null>(null); - const [recurrencePattern, setRecurrencePattern] = useState(null); - const [isEditingWorkItem, setIsEditingWorkItem] = useState(false); - - useEffect(() => { + const [entryData, setEntryData] = useState>(() => { if (event) { - setEntryData({ + return { ...event, scheduled_start: new Date(event.scheduled_start), scheduled_end: new Date(event.scheduled_end), assigned_user_ids: event.assigned_user_ids, - }); - - // Load recurrence pattern if it exists - if (event.recurrence_pattern) { - setRecurrencePattern({ - ...event.recurrence_pattern, - startDate: new Date(event.recurrence_pattern.startDate), - endDate: event.recurrence_pattern.endDate ? new Date(event.recurrence_pattern.endDate) : undefined, - }); - } - - // Fetch work item information if editing an existing entry - if (event.work_item_id && event.work_item_type) { - getWorkItemById(event.work_item_id, event.work_item_type).then((workItem) => { - if (workItem) { - setSelectedWorkItem(workItem); - } - }); - } + }; } else if (slot) { - setEntryData({ + return { entry_id: '', title: '', scheduled_start: new Date(slot.start), @@ -86,12 +51,77 @@ const EntryPopup: React.FC = ({ notes: '', created_at: new Date(), updated_at: new Date(), - work_item_id: '', - status: '', - work_item_type: 'project_task', + work_item_id: null, + status: 'scheduled', + work_item_type: 'ad_hoc', assigned_user_ids: [], - }); + }; + } else { + return { + entry_id: '', + title: '', + scheduled_start: new Date(), + scheduled_end: new Date(), + notes: '', + created_at: new Date(), + updated_at: new Date(), + work_item_id: null, + status: 'scheduled', + work_item_type: 'ad_hoc', + assigned_user_ids: [], + }; } + }); + const [selectedWorkItem, setSelectedWorkItem] = useState | null>(null); + const [recurrencePattern, setRecurrencePattern] = useState(null); + const [isEditingWorkItem, setIsEditingWorkItem] = useState(false); + + useEffect(() => { + const initializeData = () => { + if (event) { + setEntryData({ + ...event, + scheduled_start: new Date(event.scheduled_start), + scheduled_end: new Date(event.scheduled_end), + assigned_user_ids: event.assigned_user_ids, + work_item_id: event.work_item_id, + }); + + // Load recurrence pattern if it exists + if (event.recurrence_pattern) { + setRecurrencePattern({ + ...event.recurrence_pattern, + startDate: new Date(event.recurrence_pattern.startDate), + endDate: event.recurrence_pattern.endDate ? new Date(event.recurrence_pattern.endDate) : undefined, + }); + } + + // Fetch work item information if editing an existing entry + if (event.work_item_id && event.work_item_type !== 'ad_hoc') { + getWorkItemById(event.work_item_id, event.work_item_type).then((workItem) => { + if (workItem) { + setSelectedWorkItem(workItem); + } + }); + } + } else if (slot) { + setEntryData({ + entry_id: '', + title: '', + scheduled_start: new Date(slot.start), + scheduled_end: new Date(slot.end), + notes: '', + created_at: new Date(), + updated_at: new Date(), + work_item_id: null, + status: 'scheduled', + work_item_type: 'ad_hoc', + assigned_user_ids: [], + }); + } + }; + + initializeData(); }, [event, slot]); const recurrenceOptions = [ @@ -134,9 +164,9 @@ const EntryPopup: React.FC = ({ setSelectedWorkItem(workItem); setEntryData(prev => ({ ...prev, - work_item_id: workItem ? workItem.work_item_id : '', + work_item_id: workItem ? workItem.work_item_id : null, title: workItem ? workItem.name : prev.title, - work_item_type: workItem?.type as "ticket" | "project_task" | "non_billable_category" + work_item_type: workItem?.type || 'ad_hoc' })); setIsEditingWorkItem(false); }; @@ -160,15 +190,25 @@ const EntryPopup: React.FC = ({ }; const handleSave = () => { + // Ensure required fields are present + if (!entryData.title) { + alert('Title is required'); + return; + } + + // Prepare entry data const savedEntryData = { ...entryData, - recurrence_pattern: recurrencePattern || null // Use null instead of undefined + recurrence_pattern: recurrencePattern || null, + // For ad-hoc entries, ensure work_item_id is null and type is 'ad_hoc' + work_item_id: entryData.work_item_type === 'ad_hoc' ? null : entryData.work_item_id, + status: entryData.status || 'scheduled', + // Ensure assigned_user_ids is an array + assigned_user_ids: Array.isArray(entryData.assigned_user_ids) ? entryData.assigned_user_ids : [] }; - // If there's no recurrence pattern, ensure it's explicitly set to null - if (!recurrencePattern) { - savedEntryData.recurrence_pattern = null; - } + // Log the data being saved + console.log('Saving schedule entry:', savedEntryData); onSave(savedEntryData); }; diff --git a/server/src/components/time-management/ScheduleCalendar.tsx b/server/src/components/time-management/ScheduleCalendar.tsx index dee44841..c3624a42 100644 --- a/server/src/components/time-management/ScheduleCalendar.tsx +++ b/server/src/components/time-management/ScheduleCalendar.tsx @@ -34,13 +34,15 @@ const ScheduleCalendar: React.FC = () => { const workItemColors: Record = { ticket: 'rgb(var(--color-primary-100))', project_task: 'rgb(var(--color-secondary-100))', - non_billable_category: 'rgb(var(--color-accent-100))' + non_billable_category: 'rgb(var(--color-accent-100))', + ad_hoc: 'rgb(var(--color-border-200))' }; const workItemHoverColors: Record = { ticket: 'rgb(var(--color-primary-200))', project_task: 'rgb(var(--color-secondary-200))', - non_billable_category: 'rgb(var(--color-accent-200))' + non_billable_category: 'rgb(var(--color-accent-200))', + ad_hoc: 'rgb(var(--color-border-300))' }; const Legend = () => ( @@ -52,7 +54,7 @@ const ScheduleCalendar: React.FC = () => { style={{ backgroundColor: color }} > - {type.replace('_', ' ')} + {type === 'ad_hoc' ? 'Ad-hoc Entry' : type.replace('_', ' ')} ))} @@ -81,16 +83,64 @@ const ScheduleCalendar: React.FC = () => { const fetchEvents = useCallback(async () => { setIsLoading(true); setError(null); - const rangeStart = new Date(date.getFullYear(), date.getMonth(), 1); - const rangeEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0); + + // Calculate date range based on current view + let rangeStart, rangeEnd; + + if (view === 'month') { + // For month view, include the entire visible range (which might span multiple months) + const firstDay = new Date(date.getFullYear(), date.getMonth(), 1); + const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0); + + // Adjust for days from previous/next month that are visible + rangeStart = new Date(firstDay); + rangeStart.setDate(1 - firstDay.getDay()); // Start from the first day of the week + + rangeEnd = new Date(lastDay); + rangeEnd.setDate(lastDay.getDate() + (6 - lastDay.getDay())); // End on the last day of the week + + // Set times to include full days + rangeStart.setHours(0, 0, 0, 0); + rangeEnd.setHours(23, 59, 59, 999); + } else { + // For week/day views, use the exact visible range + rangeStart = new Date(date); + rangeEnd = new Date(date); + + if (view === 'week') { + rangeStart.setDate(date.getDate() - date.getDay()); + rangeEnd.setDate(rangeStart.getDate() + 6); + } + + rangeStart.setHours(0, 0, 0, 0); + rangeEnd.setHours(23, 59, 59, 999); + } + + console.log('Fetching schedule entries:', { + view, + rangeStart: rangeStart.toISOString(), + rangeEnd: rangeEnd.toISOString() + }); + const result = await getCurrentUserScheduleEntries(rangeStart, rangeEnd); if (result.success) { + console.log('Fetched entries:', { + count: result.entries.length, + entries: result.entries.map(e => ({ + id: e.entry_id, + title: e.title, + type: e.work_item_type, + start: e.scheduled_start, + end: e.scheduled_end + })) + }); setEvents(result.entries); } else { + console.error('Failed to fetch schedule entries:', result.error); setError(result.error || 'An unknown error occurred'); } setIsLoading(false); - }, [date]); + }, [date, view]); useEffect(() => { fetchEvents(); @@ -113,53 +163,56 @@ const ScheduleCalendar: React.FC = () => { }; const handleEntryPopupSave = async (entryData: IScheduleEntry) => { - let updatedEntry; - if (selectedEvent) { - // Ensure we're using the correct entry ID and maintaining virtual instance relationship - const entryToUpdate = { - ...entryData, - recurrence_pattern: entryData.recurrence_pattern || null, - assigned_user_ids: entryData.assigned_user_ids, - // Only preserve original_entry_id if this is actually a virtual instance - ...(selectedEvent.entry_id.includes('_') ? { original_entry_id: selectedEvent.original_entry_id } : {}) - }; - - // Use the virtual instance's ID if it exists, otherwise use the master entry's ID - const entryId = selectedEvent.entry_id; - const result = await updateScheduleEntry(entryId, entryToUpdate); - if (result.success && result.entry) { - updatedEntry = result.entry; - } - } else { - const result = await addScheduleEntry({ - ...entryData, - recurrence_pattern: entryData.recurrence_pattern || null, - }); - if (result.success && result.entry) { - updatedEntry = result.entry; + try { + console.log('Saving entry:', entryData); + let updatedEntry; + if (selectedEvent) { + // Ensure we're using the correct entry ID and maintaining virtual instance relationship + const entryToUpdate = { + ...entryData, + recurrence_pattern: entryData.recurrence_pattern || null, + assigned_user_ids: entryData.assigned_user_ids, + // Only preserve original_entry_id if this is actually a virtual instance + ...(selectedEvent.entry_id.includes('_') ? { original_entry_id: selectedEvent.original_entry_id } : {}) + }; + + // Use the virtual instance's ID if it exists, otherwise use the master entry's ID + const entryId = selectedEvent.entry_id; + const result = await updateScheduleEntry(entryId, entryToUpdate); + if (result.success && result.entry) { + updatedEntry = result.entry; + console.log('Updated entry:', updatedEntry); + } else { + console.error('Failed to update entry:', result.error); + alert('Failed to update schedule entry: ' + result.error); + return; + } + } else { + const result = await addScheduleEntry({ + ...entryData, + recurrence_pattern: entryData.recurrence_pattern || null, + }); + if (result.success && result.entry) { + updatedEntry = result.entry; + console.log('Added new entry:', updatedEntry); + } else { + console.error('Failed to add entry:', result.error); + alert('Failed to add schedule entry: ' + result.error); + return; + } } - } - if (updatedEntry) { - // If this is a recurring entry, refresh all events to get updated virtual instances - if (updatedEntry.recurrence_pattern || (selectedEvent?.recurrence_pattern && !updatedEntry.recurrence_pattern)) { + if (updatedEntry) { + // Always refresh events to ensure we have the latest data await fetchEvents(); - } else { - // For non-recurring entries, just update the local state - setEvents(prevEvents => { - if (selectedEvent) { - return prevEvents.map((event):IScheduleEntry => - event.entry_id === updatedEntry.entry_id ? updatedEntry : event - ); - } else { - return [...prevEvents, updatedEntry]; - } - }); } - } - setShowEntryPopup(false); - setSelectedEvent(null); + setShowEntryPopup(false); + setSelectedEvent(null); + } catch (error) { + console.error('Error saving schedule entry:', error); + alert('An error occurred while saving the schedule entry'); + } }; // Pass canAssignMultipleAgents to EntryPopup @@ -387,7 +440,6 @@ const ScheduleCalendar: React.FC = () => { } .rbc-time-gutter { position: relative; - } .rbc-day-slot { position: relative; @@ -400,7 +452,6 @@ const ScheduleCalendar: React.FC = () => { bottom: 0; width: 1px; background: rgb(var(--color-border-200)); - } `} diff --git a/server/src/components/time-management/SelectedWorkItem.tsx b/server/src/components/time-management/SelectedWorkItem.tsx index 2ef274de..15067877 100644 --- a/server/src/components/time-management/SelectedWorkItem.tsx +++ b/server/src/components/time-management/SelectedWorkItem.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Button } from '../ui/Button'; -import { IWorkItem } from '@/interfaces/workItem.interfaces'; +import { IWorkItem } from '../../interfaces/workItem.interfaces'; interface SelectedWorkItemProps { workItem: Omit | null; @@ -13,7 +13,7 @@ const SelectedWorkItem: React.FC = ({ workItem, onEdit }) if (!workItem) { return (
- No work item selected + Ad-hoc entry (no work item) diff --git a/server/src/components/time-management/WorkItemPicker.tsx b/server/src/components/time-management/WorkItemPicker.tsx index 0ba88cc2..6f29e5bf 100644 --- a/server/src/components/time-management/WorkItemPicker.tsx +++ b/server/src/components/time-management/WorkItemPicker.tsx @@ -2,14 +2,15 @@ import { useState, useEffect, useCallback } from 'react'; import { Input } from '../ui/Input'; import { SwitchWithLabel } from '../ui/SwitchWithLabel'; -import { IWorkItem, IExtendedWorkItem } from '@/interfaces/workItem.interfaces'; -import { searchWorkItems } from '@/lib/actions/workItemActions'; +import { IWorkItem, IExtendedWorkItem, WorkItemType } from '../../interfaces/workItem.interfaces'; +import { searchWorkItems } from '../../lib/actions/workItemActions'; +import { Button } from '../ui/Button'; interface WorkItemPickerProps { - onSelect: (workItem: IWorkItem) => void; + onSelect: (workItem: IWorkItem | null) => void; existingWorkItems: IWorkItem[]; - initialWorkItemId?: string; - initialWorkItemType?: 'ticket' | 'project_task' | 'non_billable_category'; + initialWorkItemId?: string | null; + initialWorkItemType?: WorkItemType; } interface WorkItemWithStatus extends Omit { @@ -139,6 +140,15 @@ export function WorkItemPicker({ onSelect, existingWorkItems }: WorkItemPickerPr return (
+
+ +
= @@ -49,11 +50,27 @@ export async function addScheduleEntry( } ) { try { - // Validate work item ID if provided - if (entry.work_item_id === '') { - return { - success: false, - error: 'Work item ID cannot be empty. Please select a valid work item or remove the work item reference.' + // Validate work item ID based on type + if (entry.work_item_type === 'ad_hoc') { + // For ad-hoc entries, ensure work_item_id is null + entry.work_item_id = null; + entry.status = entry.status || 'scheduled'; // Ensure status is set for ad-hoc entries + } else if (!entry.work_item_id) { + return { + success: false, + error: 'Non-ad-hoc entries must have a valid work item ID' + }; + } + + // Ensure at least one user is assigned + if (!options?.assignedUserIds || options.assignedUserIds.length === 0) { + const user = await getCurrentUser(); + if (!user) { + throw new Error('No authenticated user found'); + } + options = { + ...options, + assignedUserIds: [user.user_id] }; } diff --git a/server/src/lib/models/scheduleEntry.ts b/server/src/lib/models/scheduleEntry.ts index 7f77e3bc..5f2f0e3e 100644 --- a/server/src/lib/models/scheduleEntry.ts +++ b/server/src/lib/models/scheduleEntry.ts @@ -65,7 +65,13 @@ class ScheduleEntry { this.whereBetween('scheduled_start', [start, end]) .orWhereBetween('scheduled_end', [start, end]); }) - .select('*') as unknown as IScheduleEntry[]; + .select('*') + .orderBy('scheduled_start', 'asc') as unknown as IScheduleEntry[]; + + console.log('[ScheduleEntry.getAll] Query parameters:', { + start: start.toISOString(), + end: end.toISOString() + }); console.log('[ScheduleEntry.getAll] Regular entries:', { count: regularEntries.length, @@ -178,22 +184,31 @@ class ScheduleEntry { try { const entry_id = uuidv4(); - // Create main entry with only valid columns - const [createdEntry] = await trx('schedule_entries').insert({ + // Prepare entry data + const entryData = { entry_id, title: entry.title, scheduled_start: entry.scheduled_start, scheduled_end: entry.scheduled_end, notes: entry.notes, - status: entry.status, - work_item_id: entry.work_item_id, + status: entry.status || 'scheduled', + work_item_id: entry.work_item_type === 'ad_hoc' ? null : entry.work_item_id, work_item_type: entry.work_item_type, tenant: tenant || '', recurrence_pattern: (entry.recurrence_pattern && typeof entry.recurrence_pattern === 'object' && Object.keys(entry.recurrence_pattern).length > 0) ? JSON.stringify(entry.recurrence_pattern) : null, is_recurring: !!(entry.recurrence_pattern && typeof entry.recurrence_pattern === 'object' && Object.keys(entry.recurrence_pattern).length > 0) - }).returning('*'); + }; + + console.log('Creating schedule entry:', entryData); + + // Create main entry with only valid columns + const [createdEntry] = await trx('schedule_entries') + .insert(entryData) + .returning('*'); + + console.log('Created schedule entry:', createdEntry); // Create assignee records await this.updateAssignees(trx, tenant || '', createdEntry.entry_id, options.assignedUserIds); diff --git a/server/src/lib/schemas/scheduleSchemas.ts b/server/src/lib/schemas/scheduleSchemas.ts index d0b411d8..11c5e7b0 100644 --- a/server/src/lib/schemas/scheduleSchemas.ts +++ b/server/src/lib/schemas/scheduleSchemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { tenantSchema } from '../utils/validation'; -export const workItemTypeSchema = z.enum(['ticket', 'project_task', 'non_billable_category']); +export const workItemTypeSchema = z.enum(['ticket', 'project_task', 'non_billable_category', 'ad_hoc']); export const recurrencePatternSchema = z.object({ @@ -16,9 +16,10 @@ export const recurrencePatternSchema = z.object({ count: z.number().positive().optional() }); -export const scheduleEntrySchema = tenantSchema.extend({ +// Base schema without validation +const baseScheduleEntrySchema = tenantSchema.extend({ entry_id: z.string().optional(), // Optional for creation - work_item_id: z.string(), + work_item_id: z.string().nullable(), assigned_user_ids: z.array(z.string()).min(1), // At least one assigned user required scheduled_start: z.date(), scheduled_end: z.date(), @@ -33,15 +34,41 @@ export const scheduleEntrySchema = tenantSchema.extend({ originalEntryId: z.string().optional() }); -// Input schema omits system-managed fields -export const scheduleEntryInputSchema = scheduleEntrySchema.omit({ - entry_id: true, - created_at: true, - updated_at: true -}); +// Validation function +const validateWorkItemId = (data: any, ctx: z.RefinementCtx) => { + if (data.work_item_type === 'ad_hoc') { + if (data.work_item_id !== null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Ad-hoc entries must not have a work item ID", + path: ["work_item_id"] + }); + } + } else if (!data.work_item_id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Non-ad-hoc entries must have a work item ID", + path: ["work_item_id"] + }); + } +}; + +// Main schema with validation +export const scheduleEntrySchema = baseScheduleEntrySchema.superRefine(validateWorkItemId); + +// Input schema omits system-managed fields and includes validation +export const scheduleEntryInputSchema = baseScheduleEntrySchema + .omit({ + entry_id: true, + created_at: true, + updated_at: true + }) + .superRefine(validateWorkItemId); -// Update schema makes all fields optional -export const scheduleEntryUpdateSchema = scheduleEntrySchema.partial(); +// Update schema makes all fields optional and includes validation +export const scheduleEntryUpdateSchema = baseScheduleEntrySchema + .partial() + .superRefine(validateWorkItemId); // Query schemas export const getScheduleEntriesSchema = z.object({