diff --git a/be/app/api/v1/endpoints/chores.py b/be/app/api/v1/endpoints/chores.py index bb2ac5a..8e77254 100644 --- a/be/app/api/v1/endpoints/chores.py +++ b/be/app/api/v1/endpoints/chores.py @@ -241,7 +241,7 @@ async def create_group_chore( chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group}) try: - return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id, group_id=group_id) + return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id) except GroupNotFoundError as e: logger.warning(f"Group {e.group_id} not found for chore creation by user {current_user.email}.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail) @@ -397,7 +397,7 @@ async def list_my_assignments( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve assignments") @router.get( - "/chores/{chore_id}/assignments", + "/{chore_id}/assignments", response_model=PyList[ChoreAssignmentPublic], summary="List Chore Assignments", tags=["Chore Assignments"] diff --git a/be/app/crud/chore.py b/be/app/crud/chore.py index d7ed793..f260b45 100644 --- a/be/app/crud/chore.py +++ b/be/app/crud/chore.py @@ -70,24 +70,37 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]: async def create_chore( db: AsyncSession, chore_in: ChoreCreate, - user_id: int, - group_id: Optional[int] = None + user_id: int ) -> Chore: - """Creates a new chore, either personal or within a specific group.""" + """Creates a new chore, and if specified, an assignment for it.""" async with db.begin_nested() if db.in_transaction() else db.begin(): + # Validate chore type and group if chore_in.type == ChoreTypeEnum.group: - if not group_id: + if not chore_in.group_id: raise ValueError("group_id is required for group chores") - group = await get_group_by_id(db, group_id) + group = await get_group_by_id(db, chore_in.group_id) if not group: - raise GroupNotFoundError(group_id) - if not await is_user_member(db, group_id, user_id): - raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}") + raise GroupNotFoundError(chore_in.group_id) + if not await is_user_member(db, chore_in.group_id, user_id): + raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore_in.group_id}") else: # personal chore - if group_id: + if chore_in.group_id: raise ValueError("group_id must be None for personal chores") - chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'}) + # Validate assigned user if provided + if chore_in.assigned_to_user_id: + if chore_in.type == ChoreTypeEnum.group: + # For group chores, assigned user must be a member of the group + if not await is_user_member(db, chore_in.group_id, chore_in.assigned_to_user_id): + raise PermissionDeniedError(detail=f"Assigned user {chore_in.assigned_to_user_id} is not a member of group {chore_in.group_id}") + else: # Personal chore + # For personal chores, you can only assign it to yourself + if chore_in.assigned_to_user_id != user_id: + raise PermissionDeniedError(detail="Personal chores can only be assigned to the creator.") + + assigned_user_id = chore_in.assigned_to_user_id + chore_data = chore_in.model_dump(exclude_unset=True, exclude={'assigned_to_user_id'}) + if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']: parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id']) if not parent_chore: @@ -95,7 +108,6 @@ async def create_chore( db_chore = Chore( **chore_data, - group_id=group_id, created_by_id=user_id, ) @@ -105,6 +117,24 @@ async def create_chore( db.add(db_chore) await db.flush() + # Create an assignment if a user was specified + if assigned_user_id: + assignment = ChoreAssignment( + chore_id=db_chore.id, + assigned_to_user_id=assigned_user_id, + due_date=db_chore.next_due_date, + is_complete=False + ) + db.add(assignment) + await db.flush() # Flush to get the assignment ID + await create_assignment_history_entry( + db, + assignment_id=assignment.id, + event_type=ChoreHistoryEventTypeEnum.ASSIGNED, + changed_by_user_id=user_id, + event_data={'assigned_to': assigned_user_id} + ) + await create_chore_history_entry( db, chore_id=db_chore.id, diff --git a/be/app/schemas/chore.py b/be/app/schemas/chore.py index 99ab229..808af2b 100644 --- a/be/app/schemas/chore.py +++ b/be/app/schemas/chore.py @@ -45,6 +45,7 @@ class ChoreBase(BaseModel): class ChoreCreate(ChoreBase): group_id: Optional[int] = None parent_chore_id: Optional[int] = None + assigned_to_user_id: Optional[int] = None @model_validator(mode='after') def validate_group_id_with_type(self): diff --git a/fe/src/components/ChoreItem.vue b/fe/src/components/ChoreItem.vue index cf9a1f5..783bc0e 100644 --- a/fe/src/components/ChoreItem.vue +++ b/fe/src/components/ChoreItem.vue @@ -27,8 +27,9 @@
-
diff --git a/fe/src/pages/ChoresPage.vue b/fe/src/pages/ChoresPage.vue index 944d0ef..ae6cb7e 100644 --- a/fe/src/pages/ChoresPage.vue +++ b/fe/src/pages/ChoresPage.vue @@ -11,6 +11,7 @@ import ChoreItem from '@/components/ChoreItem.vue'; import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore'; import { storeToRefs } from 'pinia'; import { useAuthStore } from '@/stores/auth'; +import type { UserPublic } from '@/types/user'; const { t } = useI18n() @@ -28,6 +29,7 @@ interface ChoreFormData { type: 'personal' | 'group'; group_id: number | undefined; parent_chore_id?: number | null; + assigned_to_user_id?: number | null; } const notificationStore = useNotificationStore() @@ -45,6 +47,10 @@ const selectedChoreHistory = ref([]) const selectedChoreAssignments = ref([]) const loadingHistory = ref(false) const loadingAssignments = ref(false) +const groupMembers = ref([]) +const loadingMembers = ref(false) +const choreFormGroupMembers = ref([]) +const loadingChoreFormMembers = ref(false) const cachedChores = useStorage('cached-chores-v2', []) const cachedTimestamp = useStorage('cached-chores-timestamp-v2', 0) @@ -59,13 +65,14 @@ const initialChoreFormState: ChoreFormData = { type: 'personal', group_id: undefined, parent_chore_id: null, + assigned_to_user_id: null, } const choreForm = ref({ ...initialChoreFormState }) const isLoading = ref(true) const authStore = useAuthStore(); -const { isGuest } = storeToRefs(authStore); +const { isGuest, user } = storeToRefs(authStore); const timeEntryStore = useTimeEntryStore(); const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore); @@ -89,17 +96,28 @@ const loadChores = async () => { try { const fetchedChores = await choreService.getAllChores() + const currentUserId = user.value?.id ? Number(user.value.id) : null; + const mappedChores = fetchedChores.map(c => { - const currentAssignment = c.assignments && c.assignments.length > 0 ? c.assignments[0] : null; + // Prefer the assignment that belongs to the current user, otherwise fallback to the first assignment + const userAssignment = c.assignments?.find(a => a.assigned_to_user_id === currentUserId) ?? null; + const displayAssignment = userAssignment ?? (c.assignments?.[0] ?? null); + return { ...c, - current_assignment_id: currentAssignment?.id ?? null, - is_completed: currentAssignment?.is_complete ?? false, - completed_at: currentAssignment?.completed_at ?? null, - assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown', - completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown', + current_assignment_id: userAssignment?.id ?? null, + is_completed: userAssignment?.is_complete ?? false, + completed_at: userAssignment?.completed_at ?? null, + assigned_user_name: + displayAssignment?.assigned_user?.name || + displayAssignment?.assigned_user?.email || + 'Unknown', + completed_by_name: + displayAssignment?.assigned_user?.name || + displayAssignment?.assigned_user?.email || + 'Unknown', updating: false, - } + } as ChoreWithCompletion; }); chores.value = mappedChores; cachedChores.value = mappedChores; @@ -136,6 +154,30 @@ watch(() => choreForm.value.type, (newType) => { } }) +// Fetch group members when a group is selected in the form +watch(() => choreForm.value.group_id, async (newGroupId) => { + if (newGroupId && choreForm.value.type === 'group') { + loadingChoreFormMembers.value = true; + try { + choreFormGroupMembers.value = await groupService.getGroupMembers(newGroupId); + } catch (error) { + console.error('Failed to load group members for form:', error); + choreFormGroupMembers.value = []; + } finally { + loadingChoreFormMembers.value = false; + } + } else { + choreFormGroupMembers.value = []; + } +}); + +// Reload chores once the user information becomes available (e.g., after login refresh) +watch(user, (newUser, oldUser) => { + if (newUser && !oldUser) { + loadChores().then(loadTimeEntries); + } +}); + onMounted(() => { loadChores().then(loadTimeEntries); loadGroups() @@ -300,6 +342,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => { type: chore.type, group_id: chore.group_id ?? undefined, parent_chore_id: chore.parent_chore_id, + assigned_to_user_id: chore.assigned_to_user_id, } showChoreModal.value = true } @@ -415,6 +458,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => { const openChoreDetailModal = async (chore: ChoreWithCompletion) => { selectedChore.value = chore; showChoreDetailModal.value = true; + groupMembers.value = []; // Reset // Load assignments for this chore loadingAssignments.value = true; @@ -429,6 +473,22 @@ const openChoreDetailModal = async (chore: ChoreWithCompletion) => { } finally { loadingAssignments.value = false; } + + // If it's a group chore, load members + if (chore.type === 'group' && chore.group_id) { + loadingMembers.value = true; + try { + groupMembers.value = await groupService.getGroupMembers(chore.group_id); + } catch (error) { + console.error('Failed to load group members:', error); + notificationStore.addNotification({ + message: 'Failed to load group members.', + type: 'error' + }); + } finally { + loadingMembers.value = false; + } + } }; const openHistoryModal = async (chore: ChoreWithCompletion) => { @@ -494,6 +554,46 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => { await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId); } }; + +const handleAssignChore = async (userId: number) => { + if (!selectedChore.value) return; + try { + await choreService.createAssignment({ + chore_id: selectedChore.value.id, + assigned_to_user_id: userId, + due_date: selectedChore.value.next_due_date + }); + notificationStore.addNotification({ message: 'Chore assigned successfully!', type: 'success' }); + // Refresh assignments + selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id); + await loadChores(); // Also reload all chores to update main list + } catch (error) { + console.error('Failed to assign chore:', error); + notificationStore.addNotification({ message: 'Failed to assign chore.', type: 'error' }); + } +}; + +const handleUnassignChore = async (assignmentId: number) => { + if (!selectedChore.value) return; + try { + await choreService.deleteAssignment(assignmentId); + notificationStore.addNotification({ message: 'Chore unassigned successfully!', type: 'success' }); + selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id); + await loadChores(); + } catch (error) { + console.error('Failed to unassign chore:', error); + notificationStore.addNotification({ message: 'Failed to unassign chore.', type: 'error' }); + } +} + +const isUserAssigned = (userId: number) => { + return selectedChoreAssignments.value.some(a => a.assigned_to_user_id === userId); +}; + +const getAssignmentIdForUser = (userId: number): number | null => { + const assignment = selectedChoreAssignments.value.find(a => a.assigned_to_user_id === userId); + return assignment ? assignment.id : null; +};