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 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,132 +16,127 @@ 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)
export const useListDetailStore = defineStore('listDetail', { // Getters (as computed properties or methods)
state: (): ListDetailState => ({ const getExpenses = computed((): Expense[] => {
currentList: null, return currentList.value?.expenses || []
isLoading: false, })
error: null,
isSettlingSplit: false,
}),
actions: { const getPaidAmountForSplit = (splitId: number): number => {
async fetchListWithExpenses(listId: string) { let totalPaid = 0
if (!listId || listId === 'undefined' || listId === 'null') { if (currentList.value && currentList.value.expenses) {
this.error = 'Invalid list ID provided.'; for (const expense of currentList.value.expenses) {
console.warn(`fetchListWithExpenses called with invalid ID: ${listId}`); const split = expense.splits.find((s) => s.id === splitId)
return; if (split && split.settlement_activities) {
} totalPaid = split.settlement_activities.reduce((sum, activity) => {
this.isLoading = true return sum + parseFloat(activity.amount_paid)
this.error = null }, 0)
try { break
// 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.',
)
} }
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) { const getExpenseSplitById = (splitId: number): ExpenseSplit | undefined => {
this.error = errorMessage if (!currentList.value || !currentList.value.expenses) return undefined
this.isLoading = false for (const expense of currentList.value.expenses) {
}, const split = expense.splits.find((s) => s.id === splitId)
}, if (split) return split
}
return undefined
}
getters: { // Actions (as functions)
getList(state: ListDetailState): ListWithExpenses | null { async function fetchListWithExpenses(listId: string) {
return state.currentList if (!listId || listId === 'undefined' || listId === 'null') {
}, error.value = 'Invalid list ID provided.'
getExpenses(state: ListDetailState): Expense[] { console.warn(`fetchListWithExpenses called with invalid ID: ${listId}`)
return state.currentList?.expenses || [] return
}, }
getPaidAmountForSplit: isLoading.value = true
(state: ListDetailState) => error.value = null
(splitId: number): number => { try {
let totalPaid = 0 // Get list details
if (state.currentList && state.currentList.expenses) { const listEndpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
for (const expense of state.currentList.expenses) { const listResponse = await apiClient.get(listEndpoint)
const split = expense.splits.find((s) => s.id === splitId) const listData = listResponse.data as List
if (split && split.settlement_activities) {
totalPaid = split.settlement_activities.reduce((sum, activity) => { // Get expenses for this list
return sum + parseFloat(activity.amount_paid) const expensesEndpoint = API_ENDPOINTS.LISTS.EXPENSES(listId)
}, 0) const expensesResponse = await apiClient.get(expensesEndpoint)
break const expensesData = expensesResponse.data as Expense[]
}
} // Combine into ListWithExpenses
} currentList.value = {
return totalPaid ...listData,
}, expenses: expensesData,
getExpenseSplitById: } as ListWithExpenses
(state: ListDetailState) => } catch (err: any) {
(splitId: number): ExpenseSplit | undefined => { error.value = err.response?.data?.detail || err.message || 'Failed to fetch list details'
if (!state.currentList || !state.currentList.expenses) return undefined currentList.value = null
for (const expense of state.currentList.expenses) { console.error('Error fetching list details:', err)
const split = expense.splits.find((s) => s.id === splitId) } finally {
if (split) return split isLoading.value = false
} }
return undefined }
},
}, 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 // 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[];
}