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
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:
parent
1897d48188
commit
5f6f988118
@ -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"]
|
||||
|
@ -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,
|
||||
|
@ -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):
|
||||
|
@ -27,8 +27,9 @@
|
||||
</div>
|
||||
</label>
|
||||
<div class="neo-item-actions">
|
||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed">
|
||||
{{ isActiveTimer ? 'Stop' : 'Start' }}
|
||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer"
|
||||
:disabled="chore.is_completed || !chore.current_assignment_id">
|
||||
{{ isActiveTimer ? '⏸️' : '▶️' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
|
||||
📋
|
||||
@ -37,10 +38,10 @@
|
||||
📅
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
|
||||
Edit
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
|
||||
Delete
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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<ChoreHistory[]>([])
|
||||
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
|
||||
const loadingHistory = 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 cachedTimestamp = useStorage<number>('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;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -579,7 +679,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
</div>
|
||||
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
||||
<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"
|
||||
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||
</div>
|
||||
@ -600,14 +700,25 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
</div>
|
||||
<div v-if="choreForm.type === 'group'" class="form-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">
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</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">
|
||||
<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">
|
||||
<option :value="null">None</option>
|
||||
<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">
|
||||
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
||||
t('choresPage.form.cancel', 'Cancel')
|
||||
}}</button>
|
||||
}}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
||||
t('choresPage.form.create', 'Create') }}</button>
|
||||
</div>
|
||||
@ -644,7 +755,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
||||
t('choresPage.deleteConfirm.delete', 'Delete')
|
||||
}}</button>
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -669,7 +780,7 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
<div class="detail-item">
|
||||
<span class="label">Created by:</span>
|
||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<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 class="assignment-main">
|
||||
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||
}}</span>
|
||||
}}</span>
|
||||
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
||||
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
||||
</span>
|
||||
@ -715,6 +826,45 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
</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 class="modal-footer">
|
||||
<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;
|
||||
}
|
||||
|
||||
.badge-you {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.chore-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--dark);
|
||||
@ -1202,4 +1358,38 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
opacity: 0.7;
|
||||
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>
|
||||
|
@ -7,6 +7,14 @@
|
||||
</button>
|
||||
</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 class="spinner-dots">
|
||||
<span></span>
|
||||
@ -38,6 +46,12 @@
|
||||
<div class="neo-item-content">
|
||||
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
|
||||
<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">
|
||||
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
|
||||
'N/A'
|
||||
@ -64,11 +78,27 @@
|
||||
expense.split_type.replace('_', ' ') }})</h3>
|
||||
<ul class="space-y-1">
|
||||
<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
|
||||
|| 'N/A' }} owes</span>
|
||||
<span class="font-mono text-gray-800 font-semibold">{{
|
||||
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>
|
||||
</ul>
|
||||
</div>
|
||||
@ -175,60 +205,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
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<{
|
||||
groupId?: number | string;
|
||||
}>();
|
||||
|
||||
// Types are kept local to this component
|
||||
interface UserPublic {
|
||||
id: number;
|
||||
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[];
|
||||
}
|
||||
// Pinia store for current user context
|
||||
const authStore = useAuthStore()
|
||||
const notifStore = useNotificationStore()
|
||||
|
||||
// Reactive state collections
|
||||
const expenses = ref<Expense[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
@ -237,6 +227,24 @@ const showModal = ref(false)
|
||||
const editingExpense = ref<Expense | 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 = {
|
||||
description: '',
|
||||
total_amount: '',
|
||||
@ -252,12 +260,16 @@ const initialFormState: CreateExpenseData = {
|
||||
const formState = reactive<any>({ ...initialFormState })
|
||||
|
||||
const filteredExpenses = computed(() => {
|
||||
let data = expenses.value
|
||||
if (props.groupId) {
|
||||
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
|
||||
return expenses.value.filter(expense => expense.group_id === groupIdNum);
|
||||
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
|
||||
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 () => {
|
||||
try {
|
||||
@ -333,6 +345,15 @@ const getStatusClass = (status: string) => {
|
||||
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 = () => {
|
||||
editingExpense.value = null
|
||||
Object.assign(formState, initialFormState)
|
||||
@ -351,7 +372,7 @@ const openEditExpenseModal = (expense: Expense) => {
|
||||
formState.total_amount = expense.total_amount
|
||||
formState.currency = expense.currency
|
||||
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.group_id = expense.group_id
|
||||
formState.item_id = expense.item_id
|
||||
@ -385,13 +406,15 @@ const handleFormSubmit = async () => {
|
||||
if (index !== -1) {
|
||||
expenses.value[index] = updatedExpense
|
||||
}
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
|
||||
} else {
|
||||
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
|
||||
expenses.value.unshift(newExpense)
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
notifStore.addNotification({ message: 'Expense created', type: 'success' })
|
||||
}
|
||||
closeModal()
|
||||
// re-fetch all expenses to ensure data consistency after create/update
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
} catch (err: any) {
|
||||
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
|
||||
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.
|
||||
await expenseService.deleteExpense(expenseId)
|
||||
expenses.value = expenses.value.filter(e => e.id !== expenseId)
|
||||
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to delete expense.'
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -690,6 +748,11 @@ select.form-input {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.spinner-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -758,4 +821,9 @@ select.form-input {
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-recurring {
|
||||
background-color: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
</style>
|
||||
|
@ -259,21 +259,24 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
|
||||
};
|
||||
|
||||
const fetchListDetails = async () => {
|
||||
// If we're here for the first time without any cached data, it's an initial load.
|
||||
if (pageInitialLoad.value) {
|
||||
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 cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
|
||||
|
||||
// Since we're fetching, remove the potentially stale cache.
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cachedFullData) {
|
||||
response = { data: JSON.parse(cachedFullData) };
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
} else {
|
||||
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||||
}
|
||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||||
|
||||
const rawList = response.data as ListWithExpenses;
|
||||
const localList: List = {
|
||||
@ -302,9 +305,6 @@ const fetchListDetails = async () => {
|
||||
}
|
||||
} finally {
|
||||
itemsAreLoading.value = false;
|
||||
if (!list.value && !error.value) {
|
||||
pageInitialLoad.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -687,9 +687,9 @@ onMounted(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||||
const routeId = String(route.params.id);
|
||||
|
||||
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||||
if (listShellJSON) {
|
||||
const shellData = JSON.parse(listShellJSON);
|
||||
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)
|
||||
categoryStore.fetchCategories(list.value?.group_id);
|
||||
|
||||
|
@ -214,6 +214,12 @@ const loadCachedData = () => {
|
||||
|
||||
const fetchLists = async () => {
|
||||
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 {
|
||||
const endpoint = currentGroupId.value
|
||||
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
||||
@ -228,7 +234,10 @@ const fetchLists = async () => {
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
|
||||
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 = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -245,7 +254,11 @@ const fetchArchivedLists = 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 {
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
|
@ -1,9 +1,10 @@
|
||||
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 { UserPublic } from '@/types/user';
|
||||
|
||||
export const groupService = {
|
||||
async getUserGroups(): Promise<Group[]> {
|
||||
async getUserGroups(): Promise<GroupPublic[]> {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||
return response.data;
|
||||
},
|
||||
@ -22,9 +23,18 @@ export const groupService = {
|
||||
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);
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
@ -117,6 +117,7 @@ export interface Expense {
|
||||
|
||||
overall_settlement_status: ExpenseOverallStatusEnum
|
||||
isRecurring: boolean
|
||||
is_recurring?: boolean
|
||||
nextOccurrence?: string
|
||||
lastOccurrence?: string
|
||||
recurrencePattern?: RecurrencePattern
|
||||
|
@ -1,6 +1,7 @@
|
||||
// fe/src/types/group.ts
|
||||
import type { AuthState } from '@/stores/auth';
|
||||
import type { ChoreHistory } from './chore';
|
||||
import type { UserPublic } from './user';
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
@ -10,3 +11,24 @@ export interface Group {
|
||||
members: AuthState['user'][];
|
||||
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;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
full_name?: string | null; // Alias provided by backend in many responses
|
||||
email: string;
|
||||
// Add other relevant public user fields if necessary
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user