Skip to content

Commit

Permalink
✨ Add support for ad-hoc schedule entries 🕒 (#154)
Browse files Browse the repository at this point in the history
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? ⏳"
  • Loading branch information
RobertAtNineMinds authored Jan 3, 2025
1 parent ea65a6c commit 2d38da0
Show file tree
Hide file tree
Showing 11 changed files with 344 additions and 132 deletions.
2 changes: 1 addition & 1 deletion docs/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions server/migrations/20250103172553_add_adhoc_schedule_entries.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Add support for ad-hoc schedule entries
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
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<void> }
*/
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]));
`);
};
140 changes: 90 additions & 50 deletions server/src/components/time-management/EntryPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,64 +34,94 @@ const EntryPopup: React.FC<EntryPopupProps> = ({
loading = false,
error = null
}) => {
const [entryData, setEntryData] = useState<Omit<IScheduleEntry, 'tenant'>>({
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<Omit<IWorkItem, 'tenant'> | null>(null);
const [recurrencePattern, setRecurrencePattern] = useState<IRecurrencePattern | null>(null);
const [isEditingWorkItem, setIsEditingWorkItem] = useState(false);

useEffect(() => {
const [entryData, setEntryData] = useState<Omit<IScheduleEntry, 'tenant'>>(() => {
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),
scheduled_end: new Date(slot.end),
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<Omit<IWorkItem, 'tenant'> | null>(null);
const [recurrencePattern, setRecurrencePattern] = useState<IRecurrencePattern | null>(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 = [
Expand Down Expand Up @@ -134,9 +164,9 @@ const EntryPopup: React.FC<EntryPopupProps> = ({
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);
};
Expand All @@ -160,15 +190,25 @@ const EntryPopup: React.FC<EntryPopupProps> = ({
};

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);
};
Expand Down
Loading

0 comments on commit 2d38da0

Please sign in to comment.