refactor: Update ListDetailPage and listDetailStore for improved type safety and state management #67

Merged
mo merged 1 commits from ph5 into prod 2025-06-09 21:14:21 +02:00
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 draggable from 'vuedraggable';
import { useCategoryStore } from '@/stores/categoryStore'; import { useCategoryStore } from '@/stores/categoryStore';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import ExpenseCard from '@/components/ExpenseCard.vue';
const { t } = useI18n(); 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 itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
const categoryOptions = computed(() => { const categoryOptions = computed(() => {
type CategoryOption = { id: number; name: string };
return [ return [
{ label: 'No Category', value: null }, { 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 // Define a more specific type for the offline item payload
interface OfflineCreateItemPayload { interface OfflineCreateItemPayload {
name: string; 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 => { const formatCurrency = (value: string | number | undefined | null): string => {
@ -1064,7 +1065,7 @@ watch(showCostSummaryDialog, (newVal) => {
// --- Expense and Settlement Status Logic --- // --- Expense and Settlement Status Logic ---
const listDetailStore = useListDetailStore(); const listDetailStore = useListDetailStore();
const expenses = computed(() => listDetailStore.getExpenses); const { getExpenses: expenses, currentList: listFromStore } = storeToRefs(listDetailStore);
const allFetchedGroups = ref<Group[]>([]); const allFetchedGroups = ref<Group[]>([]);
const getGroupName = (groupId: number): string => { const getGroupName = (groupId: number): string => {
@ -1309,6 +1310,10 @@ const handleExpenseCreated = (expense: any) => {
if (list.value?.id) { if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(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) => { const handleCheckboxChange = (item: ItemWithUI, event: Event) => {
@ -1366,10 +1371,11 @@ const isExpenseExpanded = (expenseId: number) => {
const groupedItems = computed(() => { const groupedItems = computed(() => {
if (!list.value?.items) return []; if (!list.value?.items) return [];
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {}; const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
type Category = { id: number; name: string };
list.value.items.forEach(item => { list.value.items.forEach(item => {
const categoryId = item.category_id; 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'); const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
if (!groups[categoryName]) { if (!groups[categoryName]) {

View File

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

View File

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