Implement bulk status retrieval for lists and refine list status handling

This commit is contained in:
mohamad 2025-06-07 18:55:35 +02:00
parent 331eaf7c35
commit a1acee6e59
8 changed files with 321 additions and 207 deletions

View File

@ -12,7 +12,7 @@ from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list
from app.crud import group as crud_group # Need for group membership check
from app.schemas.list import ListStatus
from app.schemas.list import ListStatus, ListStatusWithId
from app.schemas.expense import ExpensePublic # Import ExpensePublic
from app.core.exceptions import (
GroupMembershipError,
@ -106,6 +106,39 @@ async def read_lists(
return lists
@router.get(
"/statuses",
response_model=PyList[ListStatusWithId],
summary="Get Status for Multiple Lists",
tags=["Lists"]
)
async def read_lists_statuses(
ids: PyList[int] = Query(...),
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves the status for a list of lists.
- `updated_at`: The timestamp of the last update to the list itself.
- `item_count`: The total number of items in the list.
The user must have permission to view each list requested.
Lists that the user does not have permission for will be omitted from the response.
"""
logger.info(f"User {current_user.email} requesting statuses for list IDs: {ids}")
statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id)
# The CRUD function returns a list of Row objects, so we map them to the Pydantic model
return [
ListStatusWithId(
id=s.id,
updated_at=s.updated_at,
item_count=s.item_count,
latest_item_updated_at=s.latest_item_updated_at
) for s in statuses
]
@router.get(
"/{list_id}",
response_model=ListDetail, # Return detailed list info including items
@ -216,28 +249,13 @@ async def read_list_status(
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves the completion status for a specific list
Retrieves the update timestamp and item count for a specific list
if the user has permission (creator or group member).
"""
logger.info(f"User {current_user.email} requesting status for list ID: {list_id}")
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
# Calculate status
total_items = len(list_db.items)
completed_items = sum(1 for item in list_db.items if item.is_complete)
try:
completion_percentage = (completed_items / total_items * 100) if total_items > 0 else 0
except ZeroDivisionError:
completion_percentage = 0
return ListStatus(
list_id=list_db.id,
total_items=total_items,
completed_items=completed_items,
completion_percentage=completion_percentage,
last_updated=list_db.updated_at
)
# The check_list_permission is not needed here as get_list_status handles not found
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
return await crud_list.get_list_status(db=db, list_id=list_id)
@router.get(
"/{list_id}/expenses",

View File

@ -219,27 +219,27 @@ async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, re
async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
"""Gets the update timestamps and item count for a list."""
try:
list_query = select(ListModel.updated_at).where(ListModel.id == list_id)
list_result = await db.execute(list_query)
list_updated_at = list_result.scalar_one_or_none()
query = (
select(
ListModel.updated_at,
sql_func.count(ItemModel.id).label("item_count"),
sql_func.max(ItemModel.updated_at).label("latest_item_updated_at")
)
.select_from(ListModel)
.outerjoin(ItemModel, ItemModel.list_id == ListModel.id)
.where(ListModel.id == list_id)
.group_by(ListModel.id)
)
result = await db.execute(query)
status = result.first()
if list_updated_at is None:
if status is None:
raise ListNotFoundError(list_id)
item_status_query = (
select(
sql_func.max(ItemModel.updated_at).label("latest_item_updated_at"),
sql_func.count(ItemModel.id).label("item_count")
)
.where(ItemModel.list_id == list_id)
)
item_result = await db.execute(item_status_query)
item_status = item_result.first()
return ListStatus(
list_updated_at=list_updated_at,
latest_item_updated_at=item_status.latest_item_updated_at if item_status else None,
item_count=item_status.item_count if item_status else 0
updated_at=status.updated_at,
item_count=status.item_count,
latest_item_updated_at=status.latest_item_updated_at
)
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
@ -295,4 +295,58 @@ async def get_list_by_name_and_group(
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}")
raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}")
async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], user_id: int) -> PyList[ListModel]:
"""
Gets status for a list of lists if the user has permission.
Status includes list updated_at and a count of its items.
"""
if not list_ids:
return []
try:
# First, get the groups the user is a member of
group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
)
user_group_ids = group_ids_result.scalars().all()
# Build the permission logic
permission_filter = or_(
# User is the creator of the list
and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)),
# List belongs to a group the user is a member of
ListModel.group_id.in_(user_group_ids)
)
# Main query to get list data and item counts
query = (
select(
ListModel.id,
ListModel.updated_at,
sql_func.count(ItemModel.id).label("item_count"),
sql_func.max(ItemModel.updated_at).label("latest_item_updated_at")
)
.outerjoin(ItemModel, ListModel.id == ItemModel.list_id)
.where(
and_(
ListModel.id.in_(list_ids),
permission_filter
)
)
.group_by(ListModel.id)
)
result = await db.execute(query)
# The result will be rows of (id, updated_at, item_count).
# We need to verify that all requested list_ids that the user *should* have access to are present.
# The filter in the query already handles permissions.
return result.all() # Returns a list of Row objects with id, updated_at, item_count
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to get lists statuses: {str(e)}")

View File

@ -42,6 +42,9 @@ class ListDetail(ListBase):
items: List[ItemPublic] = [] # Include list of items
class ListStatus(BaseModel):
list_updated_at: datetime
latest_item_updated_at: Optional[datetime] = None # Can be null if list has no items
item_count: int
updated_at: datetime
item_count: int
latest_item_updated_at: Optional[datetime] = None
class ListStatusWithId(ListStatus):
id: int

View File

@ -81,10 +81,7 @@
<div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
<VButton :to="`/groups/${groupId}/chores`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> {{
t('groupDetailPage.chores.manageButton') }}
</VButton>
</div>
<VList v-if="upcomingChores.length > 0">
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
@ -92,7 +89,7 @@
<span class="neo-chore-name">{{ chore.name }}</span>
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
formatDate(chore.next_due_date)
}}</span>
}}</span>
</div>
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
</VListItem>
@ -107,10 +104,7 @@
<div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
<VButton :to="`/groups/${groupId}/expenses`" variant="primary">
<span class="material-icons" style="margin-right: 0.25em;">payments</span> {{
t('groupDetailPage.expenses.manageButton') }}
</VButton>
</div>
<div v-if="recentExpenses.length > 0" class="neo-expense-list">
<div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item-wrapper">
@ -226,10 +220,10 @@
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
t('groupDetailPage.settleShareModal.cancelButton')
}}</VButton>
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
t('groupDetailPage.settleShareModal.confirmButton')
}}</VButton>
}}</VButton>
</template>
</VModal>
</main>
@ -1126,10 +1120,6 @@ onMounted(() => {
transition: transform 0.1s ease-in-out;
}
.neo-expense-item:hover {
transform: translateY(-2px);
}
.neo-expense-info {
display: flex;
flex-direction: column;
@ -1205,6 +1195,7 @@ onMounted(() => {
.neo-expense-item-wrapper {
border-bottom: 1px solid #f0e5d8;
margin-bottom: 0.5rem;
}
.neo-expense-item-wrapper:last-child {

View File

@ -378,9 +378,9 @@
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.settleShareModal.cancelButton')
}}</VButton>
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
}}</VButton>
}}</VButton>
</template>
</VModal>
@ -470,6 +470,11 @@ interface ItemWithUI extends Item {
showFirework?: boolean; // For firework animation
}
interface ListStatus {
updated_at: string;
item_count: number;
}
interface List {
id: number;
name: string;
@ -514,7 +519,7 @@ const error = ref<string | null>(null); // For page-level errors
const addingItem = ref(false);
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null);
const lastItemCount = ref<number | null>(null);
const newItem = ref<{ name: string; quantity?: number | string }>({ name: '' });
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
@ -614,9 +619,8 @@ const fetchListDetails = async () => {
};
list.value = localList;
lastListUpdate.value = rawList.updated_at;
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest;
}, '');
lastItemCount.value = rawList.items.length;
if (showCostSummaryDialog.value) {
await fetchListCostSummary();
}
@ -638,14 +642,13 @@ const fetchListDetails = async () => {
const checkForUpdates = async () => {
if (!list.value) return;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
const { updated_at: newListUpdatedAt, items: newItems } = response.data as ListWithExpenses;
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) =>
item.updated_at > latest ? item.updated_at : latest,
'');
const response = await apiClient.get(API_ENDPOINTS.LISTS.STATUS(String(list.value.id)));
const { updated_at: newListUpdatedAt, item_count: newItemCount } = response.data as ListStatus;
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
if (
(lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
(lastItemCount.value !== null && newItemCount !== lastItemCount.value)
) {
await fetchListDetails();
}
} catch (err) {

View File

@ -39,9 +39,11 @@
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
:disabled="item.id === undefined && item.tempId !== undefined" />
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
item.name }}</span>
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
item.name }}</span>
</div>
</label>
</li>
<li class="neo-list-item new-item-input-container">
@ -77,6 +79,13 @@ import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
const { t } = useI18n();
interface ListStatus {
id: number;
updated_at: string;
item_count: number;
latest_item_updated_at: string | null;
}
interface List {
id: number;
name: string;
@ -123,6 +132,8 @@ const currentViewedGroup = ref<Group | null>(null);
const showCreateModal = ref(false);
const newItemInputRefs = ref<HTMLInputElement[]>([]);
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const currentGroupId = computed<number | null>(() => {
const idFromProp = props.groupId;
const idFromRoute = route.params.groupId;
@ -413,11 +424,89 @@ const touchActiveListId = ref<number | null>(null);
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
const handleTouchEnd = () => { touchActiveListId.value = null; };
const refetchList = async (listId: number) => {
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId)));
const updatedList = response.data as List;
const listIndex = lists.value.findIndex(l => l.id === listId);
if (listIndex !== -1) {
// Use direct assignment for better reactivity
lists.value[listIndex] = { ...updatedList, items: updatedList.items || [] };
// Update cache
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} else {
}
} catch (err) {
console.error(`Failed to refetch list ${listId}:`, err);
}
};
const checkForUpdates = async () => {
if (lists.value.length === 0) {
return;
}
const listIds = lists.value.map(l => l.id);
if (listIds.length === 0) return;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.STATUSES, {
params: { ids: listIds }
});
const statuses = response.data as ListStatus[];
for (const status of statuses) {
const localList = lists.value.find(l => l.id === status.id);
if (localList) {
const localUpdatedAt = new Date(localList.updated_at).getTime();
const remoteUpdatedAt = new Date(status.updated_at).getTime();
const localItemCount = localList.items.length;
const remoteItemCount = status.item_count;
const localLatestItemUpdate = localList.items.reduce((latest, item) => {
const itemDate = new Date(item.updated_at).getTime();
return itemDate > latest ? itemDate : latest;
}, 0);
const remoteLatestItemUpdate = status.latest_item_updated_at
? new Date(status.latest_item_updated_at).getTime()
: 0;
if (
remoteUpdatedAt > localUpdatedAt ||
localItemCount !== remoteItemCount ||
(remoteLatestItemUpdate > localLatestItemUpdate)
) {
await refetchList(status.id);
}
}
}
} catch (err) {
console.warn('Polling for list updates failed:', err);
}
};
const startPolling = () => {
stopPolling();
pollingInterval.value = setInterval(checkForUpdates, 15000); // Poll every 15 seconds
};
const stopPolling = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
};
onMounted(() => {
loadCachedData();
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
startPolling();
}
});
});
@ -427,6 +516,9 @@ watch(currentGroupId, () => {
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
startPolling();
} else {
stopPolling();
}
});
});
@ -436,6 +528,7 @@ watch(() => lists.value.length, (newLength, oldLength) => {
setupIntersectionObserver();
}
if (newLength > 0) {
startPolling();
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
if (intersectionObserver) {
@ -450,6 +543,7 @@ onUnmounted(() => {
if (intersectionObserver) {
intersectionObserver.disconnect();
}
stopPolling();
});
</script>
@ -544,6 +638,7 @@ onUnmounted(() => {
opacity: 0.6;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
@ -551,7 +646,7 @@ onUnmounted(() => {
gap: 0.8em;
cursor: pointer;
position: relative;
width: fit-content;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
@ -562,95 +657,89 @@ onUnmounted(() => {
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 18px;
width: 18px;
height: 20px;
width: 20px;
outline: none;
border: 2px solid var(--dark);
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: var(--light);
border-radius: 4px;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
background: var(--light);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
height: 2px;
background: var(--primary);
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.neo-checkbox-label input[type="checkbox"]::before {
width: 0px;
right: 55%;
transform-origin: right bottom;
content: none;
}
.neo-checkbox-label input[type="checkbox"]::after {
width: 0px;
left: 45%;
transform-origin: left bottom;
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
background: var(--light);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]:checked::before {
opacity: 1;
animation: check-01 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
animation: check-02 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::before,
.checkbox-text-span::after {
content: "";
position: absolute;
left: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
}
/* Animated strikethrough line */
.checkbox-text-span::before {
height: 2px;
width: 8px;
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
background: var(--secondary);
border-radius: 2px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
/* Firework particle container */
.checkbox-text-span::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
width: 6px;
height: 6px;
top: 50%;
left: 130%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--accent);
@ -658,21 +747,39 @@ onUnmounted(() => {
pointer-events: none;
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span {
/* Selector fixed to target span correctly */
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
transform: translateX(4px);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after {
animation: firework 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.15s;
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::after {
animation: firework-refined 0.7s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.2s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
opacity: 0.6;
position: relative;
}
/* Static strikethrough for items loaded as complete */
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
.new-item-input-container .neo-checkbox-label {
@ -777,93 +884,22 @@ onUnmounted(() => {
}
.neo-list-item {
/* padding: 0.8rem 0; */
/* Removed as margin-bottom is used */
margin-bottom: 0.7rem;
/* Adjusted for mobile */
}
}
@keyframes check-01 {
0% {
width: 4px;
top: auto;
transform: rotate(0);
}
50% {
width: 0px;
top: auto;
transform: rotate(0);
}
51% {
width: 0px;
top: 8px;
transform: rotate(45deg);
}
100% {
width: 6px;
top: 8px;
transform: rotate(45deg);
}
}
@keyframes check-02 {
0% {
width: 4px;
top: auto;
transform: rotate(0);
}
50% {
width: 0px;
top: auto;
transform: rotate(0);
}
51% {
width: 0px;
top: 8px;
transform: rotate(-45deg);
}
100% {
width: 11px;
top: 8px;
transform: rotate(-45deg);
}
}
@keyframes firework {
0% {
@keyframes firework-refined {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(0.5);
box-shadow:
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent);
box-shadow: 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
to {
opacity: 0;
transform: translate(-50%, -50%) scale(1.2);
box-shadow:
0 -15px 0 0 var(--accent),
14px -8px 0 0 var(--accent),
14px 8px 0 0 var(--accent),
0 15px 0 0 var(--accent),
-14px 8px 0 0 var(--accent),
-14px -8px 0 0 var(--accent);
transform: translate(-50%, -50%) scale(2);
box-shadow: 0 -20px 0 0 var(--accent), 20px 0px 0 0 var(--accent), 0 20px 0 0 var(--accent), -20px 0px 0 0 var(--accent), 14px -14px 0 0 var(--accent), 14px 14px 0 0 var(--accent), -14px 14px 0 0 var(--accent), -14px -14px 0 0 var(--accent);
}
}

View File

@ -3,6 +3,7 @@ import { API_BASE_URL, API_ENDPOINTS } from '@/config/api-config' // api-config.
import router from '@/router' // Import the router instance
import { useAuthStore } from '@/stores/auth' // Import the auth store
import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
import { stringify } from 'qs';
// Create axios instance
const api = axios.create({
@ -11,6 +12,9 @@ const api = axios.create({
'Content-Type': 'application/json',
},
withCredentials: true, // Enable sending cookies and authentication headers
paramsSerializer: {
serialize: (params) => stringify(params, { arrayFormat: 'repeat' }),
},
})
// Create apiClient with helper methods

View File

@ -32,6 +32,11 @@ export const useListDetailStore = defineStore('listDetail', {
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 {