Merge pull request 'refactor: Update ListDetailPage and listDetailStore for improved type safety and state management' (#67) from ph5 into prod

Reviewed-on: #67
This commit is contained in:
mo 2025-06-09 21:14:21 +02:00
commit 5c57ac8080
3 changed files with 128 additions and 152 deletions

View File

@ -443,7 +443,6 @@ import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import draggable from 'vuedraggable';
import { useCategoryStore } from '@/stores/categoryStore';
import { storeToRefs } from 'pinia';
import ExpenseCard from '@/components/ExpenseCard.vue';
const { t } = useI18n();
@ -555,9 +554,10 @@ const newItem = ref<{ name: string; quantity?: number | string; category_id?: nu
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
const categoryOptions = computed(() => {
type CategoryOption = { id: number; name: string };
return [
{ label: 'No Category', value: null },
...categories.value.map(c => ({ label: c.name, value: c.id })),
...categories.value.map((c: CategoryOption) => ({ label: c.name, value: c.id })),
];
});
@ -610,7 +610,8 @@ const showCreateExpenseForm = ref(false);
// Define a more specific type for the offline item payload
interface OfflineCreateItemPayload {
name: string;
quantity?: string | number; // Align with the target type from the linter error
quantity?: string | number;
category_id?: number | null;
}
const formatCurrency = (value: string | number | undefined | null): string => {
@ -1064,7 +1065,7 @@ watch(showCostSummaryDialog, (newVal) => {
// --- Expense and Settlement Status Logic ---
const listDetailStore = useListDetailStore();
const expenses = computed(() => listDetailStore.getExpenses);
const { getExpenses: expenses, currentList: listFromStore } = storeToRefs(listDetailStore);
const allFetchedGroups = ref<Group[]>([]);
const getGroupName = (groupId: number): string => {
@ -1309,6 +1310,10 @@ const handleExpenseCreated = (expense: any) => {
if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(list.value.id));
}
// Also, update the main expenses from the store
if (listDetailStore.currentList?.id) {
listDetailStore.fetchListWithExpenses(String(listDetailStore.currentList.id));
}
};
const handleCheckboxChange = (item: ItemWithUI, event: Event) => {
@ -1366,10 +1371,11 @@ const isExpenseExpanded = (expenseId: number) => {
const groupedItems = computed(() => {
if (!list.value?.items) return [];
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
type Category = { id: number; name: string };
list.value.items.forEach(item => {
const categoryId = item.category_id;
const category = categories.value.find(c => c.id === categoryId);
const category = categories.value.find((c: Category) => c.id === categoryId);
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
if (!groups[categoryName]) {

View File

@ -1,4 +1,5 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type {
Expense,
@ -15,132 +16,127 @@ export interface ListWithExpenses extends List {
expenses: Expense[]
}
interface ListDetailState {
currentList: ListWithExpenses | null
isLoading: boolean
error: string | null
isSettlingSplit: boolean
}
export const useListDetailStore = defineStore('listDetail', () => {
// State
const currentList = ref<ListWithExpenses | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isSettlingSplit = ref(false)
export const useListDetailStore = defineStore('listDetail', {
state: (): ListDetailState => ({
currentList: null,
isLoading: false,
error: null,
isSettlingSplit: false,
}),
// Getters (as computed properties or methods)
const getExpenses = computed((): Expense[] => {
return currentList.value?.expenses || []
})
actions: {
async fetchListWithExpenses(listId: string) {
if (!listId || listId === 'undefined' || listId === 'null') {
this.error = 'Invalid list ID provided.';
console.warn(`fetchListWithExpenses called with invalid ID: ${listId}`);
return;
}
this.isLoading = true
this.error = null
try {
// Get list details
const listEndpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
const listResponse = await apiClient.get(listEndpoint)
const listData = listResponse.data as List
// Get expenses for this list
const expensesEndpoint = API_ENDPOINTS.LISTS.EXPENSES(listId)
const expensesResponse = await apiClient.get(expensesEndpoint)
const expensesData = expensesResponse.data as Expense[]
// Combine into ListWithExpenses
this.currentList = {
...listData,
expenses: expensesData,
} as ListWithExpenses
} catch (err: any) {
this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details'
this.currentList = null
console.error('Error fetching list details:', err)
} finally {
this.isLoading = false
}
},
async settleExpenseSplit(payload: {
list_id_for_refetch: string // ID of the list to refetch after settlement
expense_split_id: number
activity_data: SettlementActivityCreate
}): Promise<boolean> {
this.isSettlingSplit = true
this.error = null
try {
// Call the actual API endpoint using generic post method
const endpoint = `/financials/expense_splits/${payload.expense_split_id}/settle`
const response = await apiClient.post(endpoint, payload.activity_data)
// Refresh list data to show updated statuses
if (payload.list_id_for_refetch) {
await this.fetchListWithExpenses(payload.list_id_for_refetch)
} else if (this.currentList?.id) {
// Fallback if list_id_for_refetch is not provided but currentList exists
await this.fetchListWithExpenses(String(this.currentList.id))
} else {
console.warn(
'Could not refetch list details: list_id_for_refetch not provided and no currentList available.',
)
const getPaidAmountForSplit = (splitId: number): number => {
let totalPaid = 0
if (currentList.value && currentList.value.expenses) {
for (const expense of currentList.value.expenses) {
const split = expense.splits.find((s) => s.id === splitId)
if (split && split.settlement_activities) {
totalPaid = split.settlement_activities.reduce((sum, activity) => {
return sum + parseFloat(activity.amount_paid)
}, 0)
break
}
this.isSettlingSplit = false
return true // Indicate success
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to settle expense split.'
this.error = errorMessage
console.error('Error settling expense split:', err)
this.isSettlingSplit = false
return false // Indicate failure
}
},
}
return totalPaid
}
setError(errorMessage: string) {
this.error = errorMessage
this.isLoading = false
},
},
const getExpenseSplitById = (splitId: number): ExpenseSplit | undefined => {
if (!currentList.value || !currentList.value.expenses) return undefined
for (const expense of currentList.value.expenses) {
const split = expense.splits.find((s) => s.id === splitId)
if (split) return split
}
return undefined
}
getters: {
getList(state: ListDetailState): ListWithExpenses | null {
return state.currentList
},
getExpenses(state: ListDetailState): Expense[] {
return state.currentList?.expenses || []
},
getPaidAmountForSplit:
(state: ListDetailState) =>
(splitId: number): number => {
let totalPaid = 0
if (state.currentList && state.currentList.expenses) {
for (const expense of state.currentList.expenses) {
const split = expense.splits.find((s) => s.id === splitId)
if (split && split.settlement_activities) {
totalPaid = split.settlement_activities.reduce((sum, activity) => {
return sum + parseFloat(activity.amount_paid)
}, 0)
break
}
}
}
return totalPaid
},
getExpenseSplitById:
(state: ListDetailState) =>
(splitId: number): ExpenseSplit | undefined => {
if (!state.currentList || !state.currentList.expenses) return undefined
for (const expense of state.currentList.expenses) {
const split = expense.splits.find((s) => s.id === splitId)
if (split) return split
}
return undefined
},
},
// Actions (as functions)
async function fetchListWithExpenses(listId: string) {
if (!listId || listId === 'undefined' || listId === 'null') {
error.value = 'Invalid list ID provided.'
console.warn(`fetchListWithExpenses called with invalid ID: ${listId}`)
return
}
isLoading.value = true
error.value = null
try {
// Get list details
const listEndpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
const listResponse = await apiClient.get(listEndpoint)
const listData = listResponse.data as List
// Get expenses for this list
const expensesEndpoint = API_ENDPOINTS.LISTS.EXPENSES(listId)
const expensesResponse = await apiClient.get(expensesEndpoint)
const expensesData = expensesResponse.data as Expense[]
// Combine into ListWithExpenses
currentList.value = {
...listData,
expenses: expensesData,
} as ListWithExpenses
} catch (err: any) {
error.value = err.response?.data?.detail || err.message || 'Failed to fetch list details'
currentList.value = null
console.error('Error fetching list details:', err)
} finally {
isLoading.value = false
}
}
async function settleExpenseSplit(payload: {
list_id_for_refetch: string
expense_split_id: number
activity_data: SettlementActivityCreate
}): Promise<boolean> {
isSettlingSplit.value = true
error.value = null
try {
const endpoint = `/financials/expense_splits/${payload.expense_split_id}/settle`
await apiClient.post(endpoint, payload.activity_data)
if (payload.list_id_for_refetch) {
await fetchListWithExpenses(payload.list_id_for_refetch)
} else if (currentList.value?.id) {
await fetchListWithExpenses(String(currentList.value.id))
} else {
console.warn(
'Could not refetch list details: list_id_for_refetch not provided and no currentList available.',
)
}
isSettlingSplit.value = false
return true
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to settle expense split.'
error.value = errorMessage
console.error('Error settling expense split:', err)
isSettlingSplit.value = false
return false
}
}
function setError(errorMessage: string) {
error.value = errorMessage
isLoading.value = false
}
return {
currentList,
isLoading,
error,
isSettlingSplit,
fetchListWithExpenses,
settleExpenseSplit,
setError,
getExpenses,
getPaidAmountForSplit,
getExpenseSplitById,
}
})
// Assuming List interface might be defined in fe/src/types/list.ts

View File

@ -101,7 +101,7 @@ export interface Expense {
description: string
total_amount: string // String representation of Decimal
currency: string
expense_date: string
expense_date?: string
split_type: string
list_id?: number | null
group_id?: number | null
@ -126,29 +126,3 @@ export interface Expense {
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
export type SettlementStatus = 'unpaid' | 'paid' | 'partially_paid';
export 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: SettlementStatus;
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}