ph4 #57
@ -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",
|
||||
|
@ -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)}")
|
@ -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
|
8
fe/package-lock.json
generated
8
fe/package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"motion": "^12.15.0",
|
||||
"pinia": "^3.0.2",
|
||||
"qs": "^6.14.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.9.1",
|
||||
"vue-router": "^4.5.1",
|
||||
@ -5684,7 +5685,6 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@ -9539,7 +9539,6 @@
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@ -10320,7 +10319,6 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@ -11035,7 +11033,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@ -11055,7 +11052,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@ -11072,7 +11068,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@ -11091,7 +11086,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
|
@ -28,6 +28,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"motion": "^12.15.0",
|
||||
"pinia": "^3.0.2",
|
||||
"qs": "^6.14.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.9.1",
|
||||
"vue-router": "^4.5.1",
|
||||
|
@ -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">
|
||||
@ -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">
|
||||
@ -760,6 +754,7 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
padding-block-end: 3rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@ -1125,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;
|
||||
@ -1204,6 +1195,7 @@ onMounted(() => {
|
||||
|
||||
.neo-expense-item-wrapper {
|
||||
border-bottom: 1px solid #f0e5d8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-expense-item-wrapper:last-child {
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user