feat: Enhance chore creation and assignment functionality
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m37s

This commit introduces significant updates to the chore management system, including:

- Refactoring the `create_chore` function to streamline the creation process and enforce assignment rules based on chore type.
- Adding support for assigning chores to users, ensuring that group chores can only be assigned to group members and personal chores can only be assigned to the creator.
- Updating the API endpoints and frontend components to accommodate the new assignment features, including loading group members for assignment selection.
- Enhancing the user interface to display assignment options and manage chore assignments effectively.

These changes aim to improve the functionality and user experience of the chore management system.
This commit is contained in:
mohamad 2025-06-22 21:27:04 +02:00
parent 1897d48188
commit 5f6f988118
12 changed files with 467 additions and 106 deletions

View File

@ -241,7 +241,7 @@ async def create_group_chore(
chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group}) chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group})
try: 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: except GroupNotFoundError as e:
logger.warning(f"Group {e.group_id} not found for chore creation by user {current_user.email}.") 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) 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") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve assignments")
@router.get( @router.get(
"/chores/{chore_id}/assignments", "/{chore_id}/assignments",
response_model=PyList[ChoreAssignmentPublic], response_model=PyList[ChoreAssignmentPublic],
summary="List Chore Assignments", summary="List Chore Assignments",
tags=["Chore Assignments"] tags=["Chore Assignments"]

View File

@ -70,24 +70,37 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
async def create_chore( async def create_chore(
db: AsyncSession, db: AsyncSession,
chore_in: ChoreCreate, chore_in: ChoreCreate,
user_id: int, user_id: int
group_id: Optional[int] = None
) -> Chore: ) -> 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(): async with db.begin_nested() if db.in_transaction() else db.begin():
# Validate chore type and group
if chore_in.type == ChoreTypeEnum.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") 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: if not group:
raise GroupNotFoundError(group_id) raise GroupNotFoundError(chore_in.group_id)
if not await is_user_member(db, group_id, user_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 {group_id}") raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore_in.group_id}")
else: # personal chore else: # personal chore
if group_id: if chore_in.group_id:
raise ValueError("group_id must be None for personal chores") 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']: 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']) parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
if not parent_chore: if not parent_chore:
@ -95,7 +108,6 @@ async def create_chore(
db_chore = Chore( db_chore = Chore(
**chore_data, **chore_data,
group_id=group_id,
created_by_id=user_id, created_by_id=user_id,
) )
@ -105,6 +117,24 @@ async def create_chore(
db.add(db_chore) db.add(db_chore)
await db.flush() 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( await create_chore_history_entry(
db, db,
chore_id=db_chore.id, chore_id=db_chore.id,

View File

@ -45,6 +45,7 @@ class ChoreBase(BaseModel):
class ChoreCreate(ChoreBase): class ChoreCreate(ChoreBase):
group_id: Optional[int] = None group_id: Optional[int] = None
parent_chore_id: Optional[int] = None parent_chore_id: Optional[int] = None
assigned_to_user_id: Optional[int] = None
@model_validator(mode='after') @model_validator(mode='after')
def validate_group_id_with_type(self): def validate_group_id_with_type(self):

View File

@ -27,8 +27,9 @@
</div> </div>
</label> </label>
<div class="neo-item-actions"> <div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed"> <button class="btn btn-sm btn-neutral" @click="toggleTimer"
{{ isActiveTimer ? 'Stop' : 'Start' }} :disabled="chore.is_completed || !chore.current_assignment_id">
{{ isActiveTimer ? '⏸️' : '▶️' }}
</button> </button>
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details"> <button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
📋 📋
@ -37,10 +38,10 @@
📅 📅
</button> </button>
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)"> <button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
Edit
</button> </button>
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)"> <button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
Delete 🗑
</button> </button>
</div> </div>
</div> </div>

View File

@ -11,6 +11,7 @@ import ChoreItem from '@/components/ChoreItem.vue';
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore'; import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { UserPublic } from '@/types/user';
const { t } = useI18n() const { t } = useI18n()
@ -28,6 +29,7 @@ interface ChoreFormData {
type: 'personal' | 'group'; type: 'personal' | 'group';
group_id: number | undefined; group_id: number | undefined;
parent_chore_id?: number | null; parent_chore_id?: number | null;
assigned_to_user_id?: number | null;
} }
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
@ -45,6 +47,10 @@ const selectedChoreHistory = ref<ChoreHistory[]>([])
const selectedChoreAssignments = ref<ChoreAssignment[]>([]) const selectedChoreAssignments = ref<ChoreAssignment[]>([])
const loadingHistory = ref(false) const loadingHistory = ref(false)
const loadingAssignments = ref(false) const loadingAssignments = ref(false)
const groupMembers = ref<UserPublic[]>([])
const loadingMembers = ref(false)
const choreFormGroupMembers = ref<UserPublic[]>([])
const loadingChoreFormMembers = ref(false)
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', []) const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0) const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
@ -59,13 +65,14 @@ const initialChoreFormState: ChoreFormData = {
type: 'personal', type: 'personal',
group_id: undefined, group_id: undefined,
parent_chore_id: null, parent_chore_id: null,
assigned_to_user_id: null,
} }
const choreForm = ref({ ...initialChoreFormState }) const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true) const isLoading = ref(true)
const authStore = useAuthStore(); const authStore = useAuthStore();
const { isGuest } = storeToRefs(authStore); const { isGuest, user } = storeToRefs(authStore);
const timeEntryStore = useTimeEntryStore(); const timeEntryStore = useTimeEntryStore();
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore); const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
@ -89,17 +96,28 @@ const loadChores = async () => {
try { try {
const fetchedChores = await choreService.getAllChores() const fetchedChores = await choreService.getAllChores()
const currentUserId = user.value?.id ? Number(user.value.id) : null;
const mappedChores = fetchedChores.map(c => { 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 { return {
...c, ...c,
current_assignment_id: currentAssignment?.id ?? null, current_assignment_id: userAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? false, is_completed: userAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? null, completed_at: userAssignment?.completed_at ?? null,
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown', assigned_user_name:
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown', displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
completed_by_name:
displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
updating: false, updating: false,
} } as ChoreWithCompletion;
}); });
chores.value = mappedChores; chores.value = mappedChores;
cachedChores.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(() => { onMounted(() => {
loadChores().then(loadTimeEntries); loadChores().then(loadTimeEntries);
loadGroups() loadGroups()
@ -300,6 +342,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
type: chore.type, type: chore.type,
group_id: chore.group_id ?? undefined, group_id: chore.group_id ?? undefined,
parent_chore_id: chore.parent_chore_id, parent_chore_id: chore.parent_chore_id,
assigned_to_user_id: chore.assigned_to_user_id,
} }
showChoreModal.value = true showChoreModal.value = true
} }
@ -415,6 +458,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
const openChoreDetailModal = async (chore: ChoreWithCompletion) => { const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore; selectedChore.value = chore;
showChoreDetailModal.value = true; showChoreDetailModal.value = true;
groupMembers.value = []; // Reset
// Load assignments for this chore // Load assignments for this chore
loadingAssignments.value = true; loadingAssignments.value = true;
@ -429,6 +473,22 @@ const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
} finally { } finally {
loadingAssignments.value = false; 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) => { const openHistoryModal = async (chore: ChoreWithCompletion) => {
@ -494,6 +554,46 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId); 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;
};
</script> </script>
<template> <template>
@ -579,7 +679,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
</div> </div>
<div v-if="choreForm.frequency === 'custom'" class="form-group"> <div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)') <label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label> }}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days" <input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1"> class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div> </div>
@ -600,14 +700,25 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
</div> </div>
<div v-if="choreForm.type === 'group'" class="form-group"> <div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group') <label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label> }}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input"> <select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option> <option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select> </select>
</div> </div>
<div v-if="choreForm.type === 'group' && choreForm.group_id" class="form-group">
<label class="form-label" for="chore-assign-user">{{ t('choresPage.form.assignTo', 'Assign To (Optional)')
}}</label>
<div v-if="loadingChoreFormMembers">Loading members...</div>
<select v-else id="chore-assign-user" v-model="choreForm.assigned_to_user_id" class="form-input">
<option :value="null">Don't assign now</option>
<option v-for="member in choreFormGroupMembers" :key="member.id" :value="member.id">
{{ member.name || member.email }}
</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore') <label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
}}</label> }}</label>
<select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input"> <select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input">
<option :value="null">None</option> <option :value="null">None</option>
<option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id"> <option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id">
@ -619,7 +730,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{ <button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel') t('choresPage.form.cancel', 'Cancel')
}}</button> }}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') : <button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button> t('choresPage.form.create', 'Create') }}</button>
</div> </div>
@ -644,7 +755,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button> t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{ <button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete') t('choresPage.deleteConfirm.delete', 'Delete')
}}</button> }}</button>
</div> </div>
</div> </div>
</div> </div>
@ -669,7 +780,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
<div class="detail-item"> <div class="detail-item">
<span class="label">Created by:</span> <span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown' <span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span> }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="label">Due date:</span> <span class="label">Due date:</span>
@ -701,7 +812,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item"> <div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main"> <div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email <span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span> }}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }"> <span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }} {{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span> </span>
@ -715,6 +826,45 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
</div> </div>
</div> </div>
</div> </div>
<!-- Assign Chore Section -->
<div class="detail-section" v-if="selectedChore.type === 'group' && selectedChore.group_id">
<h4>Assign to Member</h4>
<div v-if="loadingMembers" class="loading-spinner">Loading members...</div>
<div v-else-if="groupMembers.length === 0" class="no-data">No other members in this group.</div>
<div v-else class="member-list">
<div v-for="member in groupMembers" :key="member.id" class="member-item">
<div class="member-info">
<span class="member-name">{{ member.name || member.email }}</span>
<span v-if="member.id === user?.id" class="badge badge-you">You</span>
</div>
<button v-if="isUserAssigned(member.id)" class="btn btn-sm btn-danger"
@click="handleUnassignChore(getAssignmentIdForUser(member.id)!)">
Unassign
</button>
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(member.id)">
Assign
</button>
</div>
</div>
</div>
<div class="detail-section" v-if="selectedChore.type === 'personal' && user?.id">
<h4>Assign to Me</h4>
<div class="member-list">
<div class="member-item">
<span class="member-name">{{ user.name || user.email }}</span>
<button v-if="isUserAssigned(Number(user.id))" class="btn btn-sm btn-danger"
@click="handleUnassignChore(getAssignmentIdForUser(Number(user.id))!)">
Unassign
</button>
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(Number(user.id))">
Assign to Me
</button>
</div>
</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button> <button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
@ -1045,6 +1195,12 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
color: white; color: white;
} }
.badge-you {
background-color: var(--secondary);
color: white;
margin-left: 0.5rem;
}
.chore-description { .chore-description {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--dark); color: var(--dark);
@ -1202,4 +1358,38 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
opacity: 0.7; opacity: 0.7;
font-style: italic; font-style: italic;
} }
.assignments-list,
.member-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-item,
.member-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #f9fafb;
display: flex;
justify-content: space-between;
align-items: center;
}
.member-info {
display: flex;
align-items: center;
}
.member-name {
font-weight: 500;
}
.assignment-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
</style> </style>

View File

@ -7,6 +7,14 @@
</button> </button>
</header> </header>
<div class="mb-4 flex items-center gap-2 justify-between" v-if="!loading && !error">
<div class="text-sm font-medium" v-if="authStore.getUser">
Your outstanding balance: <span class="font-mono">{{ formatCurrency(userOutstanding, 'USD') }}</span>
</div>
<label class="flex items-center text-sm"><input type="checkbox" v-model="showRecurringOnly"
class="mr-2">Show recurring only</label>
</div>
<div v-if="loading" class="flex justify-center"> <div v-if="loading" class="flex justify-center">
<div class="spinner-dots"> <div class="spinner-dots">
<span></span> <span></span>
@ -38,6 +46,12 @@
<div class="neo-item-content"> <div class="neo-item-content">
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)"> <div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
<span class="checkbox-text-span">{{ expense.description }}</span> <span class="checkbox-text-span">{{ expense.description }}</span>
<span v-if="isExpenseRecurring(expense)"
class="ml-2 inline-block rounded-full bg-indigo-100 text-indigo-700 px-2 py-0.5 text-xs font-semibold">Recurring
<template v-if="getNextOccurrence(expense)">
next {{ getNextOccurrence(expense) }}
</template>
</span>
<div class="item-subtext"> <div class="item-subtext">
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email || Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
'N/A' 'N/A'
@ -64,11 +78,27 @@
expense.split_type.replace('_', ' ') }})</h3> expense.split_type.replace('_', ' ') }})</h3>
<ul class="space-y-1"> <ul class="space-y-1">
<li v-for="split in expense.splits" :key="split.id" <li v-for="split in expense.splits" :key="split.id"
class="flex justify-between items-center py-1 text-sm"> class="flex justify-between items-center py-1 text-sm gap-2 flex-wrap">
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email <span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|| 'N/A' }} owes</span> || 'N/A' }} owes</span>
<span class="font-mono text-gray-800 font-semibold">{{ <span class="font-mono text-gray-800 font-semibold">{{
formatCurrency(split.owed_amount, expense.currency) }}</span> formatCurrency(split.owed_amount, expense.currency) }}</span>
<!-- Settlement progress -->
<span
v-if="split.settlement_activities && split.settlement_activities.length"
class="text-xs text-gray-500">Paid: {{
formatCurrency(calculatePaidAmount(split), expense.currency) }}</span>
<span v-if="calculateRemainingAmount(split) > 0"
class="text-xs text-red-600">Remaining: {{
formatCurrency(calculateRemainingAmount(split), expense.currency)
}}</span>
<!-- Settle button for current user -->
<button
v-if="authStore.getUser && authStore.getUser.id === split.user_id && calculateRemainingAmount(split) > 0"
@click.stop="handleSettleSplit(split, expense)"
class="ml-auto btn btn-sm btn-primary">Settle</button>
</li> </li>
</ul> </ul>
</div> </div>
@ -175,60 +205,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService' import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
import { apiClient } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
const props = defineProps<{ const props = defineProps<{
groupId?: number | string; groupId?: number | string;
}>(); }>();
// Types are kept local to this component // Pinia store for current user context
interface UserPublic { const authStore = useAuthStore()
id: number; const notifStore = useNotificationStore()
email: string;
full_name?: string;
}
interface ExpenseSplit {
id: number;
expense_id: number;
user_id: number;
owed_amount: string; // Decimal is string
share_percentage?: string;
share_units?: number;
user?: UserPublic;
created_at: string;
updated_at: string;
status: 'unpaid' | 'paid' | 'partially_paid';
paid_at?: string;
}
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
interface Expense {
id: number;
description: string;
total_amount: string; // Decimal is string
currency: string;
expense_date?: string;
split_type: SplitType;
list_id?: number;
group_id?: number;
item_id?: number;
paid_by_user_id: number;
is_recurring: boolean;
recurrence_pattern?: any;
created_at: string;
updated_at: string;
version: number;
created_by_user_id: number;
splits: ExpenseSplit[];
paid_by_user?: UserPublic;
overall_settlement_status: 'unpaid' | 'paid' | 'partially_paid';
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}
// Reactive state collections
const expenses = ref<Expense[]>([]) const expenses = ref<Expense[]>([])
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -237,6 +227,24 @@ const showModal = ref(false)
const editingExpense = ref<Expense | null>(null) const editingExpense = ref<Expense | null>(null)
const formError = ref<string | null>(null) const formError = ref<string | null>(null)
// UI-level filters
const showRecurringOnly = ref(false)
// Aggregate outstanding balance for current user across expenses
const userOutstanding = computed(() => {
const userId = authStore.getUser?.id
if (!userId) return 0
let remaining = 0
expenses.value.forEach((exp) => {
exp.splits.forEach((sp) => {
if (sp.user_id === userId) {
remaining += calculateRemainingAmount(sp)
}
})
})
return remaining
})
const initialFormState: CreateExpenseData = { const initialFormState: CreateExpenseData = {
description: '', description: '',
total_amount: '', total_amount: '',
@ -252,12 +260,16 @@ const initialFormState: CreateExpenseData = {
const formState = reactive<any>({ ...initialFormState }) const formState = reactive<any>({ ...initialFormState })
const filteredExpenses = computed(() => { const filteredExpenses = computed(() => {
let data = expenses.value
if (props.groupId) { if (props.groupId) {
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId; const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
return expenses.value.filter(expense => expense.group_id === groupIdNum); data = data.filter((expense) => expense.group_id === groupIdNum)
} }
return expenses.value; if (showRecurringOnly.value) {
}); data = data.filter((expense) => (expense as any).isRecurring || (expense as any).is_recurring)
}
return data
})
onMounted(async () => { onMounted(async () => {
try { try {
@ -333,6 +345,15 @@ const getStatusClass = (status: string) => {
return statusMap[status] || 'bg-gray-100 text-gray-800' return statusMap[status] || 'bg-gray-100 text-gray-800'
} }
const getNextOccurrence = (expense: Expense): string | null => {
const raw = (expense as any).next_occurrence ?? (expense as any).nextOccurrence ?? null
return raw ? formatDate(raw) : null
}
const isExpenseRecurring = (expense: Expense): boolean => {
return Boolean((expense as any).isRecurring ?? (expense as any).is_recurring)
}
const openCreateExpenseModal = () => { const openCreateExpenseModal = () => {
editingExpense.value = null editingExpense.value = null
Object.assign(formState, initialFormState) Object.assign(formState, initialFormState)
@ -351,7 +372,7 @@ const openEditExpenseModal = (expense: Expense) => {
formState.total_amount = expense.total_amount formState.total_amount = expense.total_amount
formState.currency = expense.currency formState.currency = expense.currency
formState.split_type = expense.split_type formState.split_type = expense.split_type
formState.isRecurring = expense.is_recurring formState.isRecurring = (expense as any).is_recurring ?? (expense as any).isRecurring ?? false
formState.list_id = expense.list_id formState.list_id = expense.list_id
formState.group_id = expense.group_id formState.group_id = expense.group_id
formState.item_id = expense.item_id formState.item_id = expense.item_id
@ -385,13 +406,15 @@ const handleFormSubmit = async () => {
if (index !== -1) { if (index !== -1) {
expenses.value[index] = updatedExpense expenses.value[index] = updatedExpense
} }
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
} else { } else {
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense; const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
expenses.value.unshift(newExpense) expenses.value.unshift(newExpense)
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense created', type: 'success' })
} }
closeModal() closeModal()
// re-fetch all expenses to ensure data consistency after create/update
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) { } catch (err: any) {
formError.value = err.response?.data?.detail || 'An error occurred during the operation.' formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
console.error(err) console.error(err)
@ -404,11 +427,46 @@ const handleDeleteExpense = async (expenseId: number) => {
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking. // Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
await expenseService.deleteExpense(expenseId) await expenseService.deleteExpense(expenseId)
expenses.value = expenses.value.filter(e => e.id !== expenseId) expenses.value = expenses.value.filter(e => e.id !== expenseId)
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete expense.' error.value = err.response?.data?.detail || 'Failed to delete expense.'
console.error(err) console.error(err)
} }
} }
// -----------------------------
// Settlement-related helpers
// -----------------------------
const calculatePaidAmount = (split: ExpenseSplit): number =>
(split.settlement_activities || []).reduce((sum: number, act: SettlementActivity) => sum + parseFloat(act.amount_paid), 0)
const calculateRemainingAmount = (split: ExpenseSplit): number =>
parseFloat(split.owed_amount) - calculatePaidAmount(split)
const handleSettleSplit = async (split: ExpenseSplit, parentExpense: Expense) => {
if (!authStore.getUser?.id) {
alert('You need to be logged in to settle an expense.')
return
}
const remaining = calculateRemainingAmount(split)
if (remaining <= 0) return
if (!confirm(`Settle ${formatCurrency(remaining, parentExpense.currency)} now?`)) return
try {
await apiClient.post(`/financials/expense_splits/${split.id}/settle`, {
expense_split_id: split.id,
paid_by_user_id: authStore.getUser.id,
amount_paid: remaining.toFixed(2),
})
// refresh expense list to get updated data
expenses.value = (await expenseService.getExpenses()) as Expense[]
} catch (err: any) {
console.error('Failed to settle split', err)
alert(err.response?.data?.detail || 'Failed to settle split.')
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -690,6 +748,11 @@ select.form-input {
background-color: #b91c1c; background-color: #b91c1c;
} }
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.spinner-dots { .spinner-dots {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -758,4 +821,9 @@ select.form-input {
margin-bottom: 1rem; margin-bottom: 1rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.badge-recurring {
background-color: #e0e7ff;
color: #4338ca;
}
</style> </style>

View File

@ -259,21 +259,24 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
}; };
const fetchListDetails = async () => { const fetchListDetails = async () => {
// If we're here for the first time without any cached data, it's an initial load.
if (pageInitialLoad.value) { if (pageInitialLoad.value) {
pageInitialLoad.value = false; pageInitialLoad.value = false;
} }
itemsAreLoading.value = true;
// Only show the items loading spinner if we don't have any items to show.
if (!list.value?.items.length) {
itemsAreLoading.value = true;
}
const routeId = String(route.params.id); const routeId = String(route.params.id);
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
// Since we're fetching, remove the potentially stale cache.
sessionStorage.removeItem(`listDetailFull_${routeId}`);
try { try {
let response; const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
if (cachedFullData) {
response = { data: JSON.parse(cachedFullData) };
sessionStorage.removeItem(`listDetailFull_${routeId}`);
} else {
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
}
const rawList = response.data as ListWithExpenses; const rawList = response.data as ListWithExpenses;
const localList: List = { const localList: List = {
@ -302,9 +305,6 @@ const fetchListDetails = async () => {
} }
} finally { } finally {
itemsAreLoading.value = false; itemsAreLoading.value = false;
if (!list.value && !error.value) {
pageInitialLoad.value = false;
}
} }
}; };
@ -687,9 +687,9 @@ onMounted(() => {
return; return;
} }
const listShellJSON = sessionStorage.getItem('listDetailShell');
const routeId = String(route.params.id); const routeId = String(route.params.id);
const listShellJSON = sessionStorage.getItem('listDetailShell');
if (listShellJSON) { if (listShellJSON) {
const shellData = JSON.parse(listShellJSON); const shellData = JSON.parse(listShellJSON);
if (shellData.id === parseInt(routeId, 10)) { if (shellData.id === parseInt(routeId, 10)) {
@ -709,6 +709,30 @@ onMounted(() => {
} }
} }
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
if (cachedFullData) {
try {
const rawList = JSON.parse(cachedFullData) as ListWithExpenses;
const localList: List = {
id: rawList.id,
name: rawList.name,
description: rawList.description ?? undefined,
is_complete: rawList.is_complete,
items: processListItems(rawList.items),
version: rawList.version,
updated_at: rawList.updated_at,
group_id: rawList.group_id ?? undefined
};
list.value = localList;
lastListUpdate.value = rawList.updated_at;
lastItemCount.value = rawList.items.length;
pageInitialLoad.value = false;
} catch (e) {
console.error("Error parsing cached list data", e);
sessionStorage.removeItem(`listDetailFull_${routeId}`);
}
}
// Fetch categories relevant to the list (either personal or group) // Fetch categories relevant to the list (either personal or group)
categoryStore.fetchCategories(list.value?.group_id); categoryStore.fetchCategories(list.value?.group_id);

View File

@ -214,6 +214,12 @@ const loadCachedData = () => {
const fetchLists = async () => { const fetchLists = async () => {
error.value = null; error.value = null;
// Only show loading if we don't have any cached data to display
if (lists.value.length === 0) {
loading.value = true;
}
try { try {
const endpoint = currentGroupId.value const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value)) ? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
@ -228,7 +234,10 @@ const fetchLists = async () => {
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed'); error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
console.error(error.value, err); console.error(error.value, err);
// If we have cached data and there's an error, don't clear the lists
if (cachedLists.value.length === 0) lists.value = []; if (cachedLists.value.length === 0) lists.value = [];
} finally {
loading.value = false;
} }
}; };
@ -245,7 +254,11 @@ const fetchArchivedLists = async () => {
}; };
const fetchListsAndGroups = async () => { const fetchListsAndGroups = async () => {
loading.value = true; // Only show loading if we don't have cached data
if (lists.value.length === 0) {
loading.value = true;
}
try { try {
await Promise.all([ await Promise.all([
fetchLists(), fetchLists(),

View File

@ -1,9 +1,10 @@
import { apiClient, API_ENDPOINTS } from '@/services/api'; import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { Group, GroupCreate, GroupUpdate } from '@/types/group'; import type { Group, GroupCreate, GroupUpdate, GroupPublic } from '@/types/group';
import type { ChoreHistory } from '@/types/chore'; import type { ChoreHistory } from '@/types/chore';
import type { UserPublic } from '@/types/user';
export const groupService = { export const groupService = {
async getUserGroups(): Promise<Group[]> { async getUserGroups(): Promise<GroupPublic[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
return response.data; return response.data;
}, },
@ -22,9 +23,18 @@ export const groupService = {
return response.data; return response.data;
}, },
async createGroup(groupData: Group): Promise<Group> { async createGroup(groupData: GroupCreate): Promise<Group> {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData); const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData);
return response.data; return response.data;
}, },
} async updateGroup(id: number, groupData: GroupUpdate): Promise<Group> {
const response = await apiClient.put(API_ENDPOINTS.GROUPS.BY_ID(String(id)), groupData);
return response.data;
},
async getGroupMembers(groupId: number): Promise<UserPublic[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.MEMBERS(String(groupId)));
return response.data;
},
};

View File

@ -117,6 +117,7 @@ export interface Expense {
overall_settlement_status: ExpenseOverallStatusEnum overall_settlement_status: ExpenseOverallStatusEnum
isRecurring: boolean isRecurring: boolean
is_recurring?: boolean
nextOccurrence?: string nextOccurrence?: string
lastOccurrence?: string lastOccurrence?: string
recurrencePattern?: RecurrencePattern recurrencePattern?: RecurrencePattern

View File

@ -1,6 +1,7 @@
// fe/src/types/group.ts // fe/src/types/group.ts
import type { AuthState } from '@/stores/auth'; import type { AuthState } from '@/stores/auth';
import type { ChoreHistory } from './chore'; import type { ChoreHistory } from './chore';
import type { UserPublic } from './user';
export interface Group { export interface Group {
id: number; id: number;
@ -10,3 +11,24 @@ export interface Group {
members: AuthState['user'][]; members: AuthState['user'][];
chore_history?: ChoreHistory[]; chore_history?: ChoreHistory[];
} }
export interface GroupCreate {
name: string;
description?: string;
}
export interface GroupUpdate {
name?: string;
description?: string;
}
export interface GroupPublic {
id: number;
name: string;
description?: string | null;
created_by_id: number;
created_at: string;
creator?: UserPublic;
members: UserPublic[];
is_member?: boolean;
}

View File

@ -3,6 +3,7 @@
export interface UserPublic { export interface UserPublic {
id: number; id: number;
name?: string | null; name?: string | null;
full_name?: string | null; // Alias provided by backend in many responses
email: string; email: string;
// Add other relevant public user fields if necessary // Add other relevant public user fields if necessary
} }