ph5 #69

Merged
mo merged 4 commits from ph5 into prod 2025-06-22 21:28:19 +02:00
12 changed files with 467 additions and 106 deletions
Showing only changes of commit 5f6f988118 - Show all commits

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
}