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})
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"]

View File

@ -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,

View File

@ -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):

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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(),

View File

@ -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;
},
};

View File

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

View File

@ -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;
}

View File

@ -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
}