commit
6004911912
@ -12,7 +12,7 @@ from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
|
|||||||
from app.schemas.message import Message # For simple responses
|
from app.schemas.message import Message # For simple responses
|
||||||
from app.crud import list as crud_list
|
from app.crud import list as crud_list
|
||||||
from app.crud import group as crud_group # Need for group membership check
|
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.schemas.expense import ExpensePublic # Import ExpensePublic
|
||||||
from app.core.exceptions import (
|
from app.core.exceptions import (
|
||||||
GroupMembershipError,
|
GroupMembershipError,
|
||||||
@ -106,6 +106,39 @@ async def read_lists(
|
|||||||
return 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(
|
@router.get(
|
||||||
"/{list_id}",
|
"/{list_id}",
|
||||||
response_model=ListDetail, # Return detailed list info including items
|
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),
|
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).
|
if the user has permission (creator or group member).
|
||||||
"""
|
"""
|
||||||
logger.info(f"User {current_user.email} requesting status for list ID: {list_id}")
|
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)
|
# 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)
|
||||||
# Calculate status
|
return await crud_list.get_list_status(db=db, list_id=list_id)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{list_id}/expenses",
|
"/{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:
|
async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
|
||||||
"""Gets the update timestamps and item count for a list."""
|
"""Gets the update timestamps and item count for a list."""
|
||||||
try:
|
try:
|
||||||
list_query = select(ListModel.updated_at).where(ListModel.id == list_id)
|
query = (
|
||||||
list_result = await db.execute(list_query)
|
select(
|
||||||
list_updated_at = list_result.scalar_one_or_none()
|
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)
|
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(
|
return ListStatus(
|
||||||
list_updated_at=list_updated_at,
|
updated_at=status.updated_at,
|
||||||
latest_item_updated_at=item_status.latest_item_updated_at if item_status else None,
|
item_count=status.item_count,
|
||||||
item_count=item_status.item_count if item_status else 0
|
latest_item_updated_at=status.latest_item_updated_at
|
||||||
)
|
)
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(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:
|
except OperationalError as e:
|
||||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||||
except SQLAlchemyError as 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
|
items: List[ItemPublic] = [] # Include list of items
|
||||||
|
|
||||||
class ListStatus(BaseModel):
|
class ListStatus(BaseModel):
|
||||||
list_updated_at: datetime
|
updated_at: datetime
|
||||||
latest_item_updated_at: Optional[datetime] = None # Can be null if list has no items
|
item_count: int
|
||||||
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",
|
"date-fns": "^4.1.0",
|
||||||
"motion": "^12.15.0",
|
"motion": "^12.15.0",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
|
"qs": "^6.14.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^9.9.1",
|
"vue-i18n": "^9.9.1",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@ -5684,7 +5685,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@ -9539,7 +9539,6 @@
|
|||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -10320,7 +10319,6 @@
|
|||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@ -11035,7 +11033,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@ -11055,7 +11052,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@ -11072,7 +11068,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@ -11091,7 +11086,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
|
@ -28,6 +28,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"motion": "^12.15.0",
|
"motion": "^12.15.0",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
|
"qs": "^6.14.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^9.9.1",
|
"vue-i18n": "^9.9.1",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
|
@ -81,10 +81,7 @@
|
|||||||
<div class="mt-4 neo-section">
|
<div class="mt-4 neo-section">
|
||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
<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>
|
</div>
|
||||||
<VList v-if="upcomingChores.length > 0">
|
<VList v-if="upcomingChores.length > 0">
|
||||||
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
|
<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="mt-4 neo-section">
|
||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
|
<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>
|
||||||
<div v-if="recentExpenses.length > 0" class="neo-expense-list">
|
<div v-if="recentExpenses.length > 0" class="neo-expense-list">
|
||||||
<div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item-wrapper">
|
<div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item-wrapper">
|
||||||
@ -760,6 +754,7 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page-padding {
|
.page-padding {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
padding-block-end: 3rem;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@ -1125,10 +1120,6 @@ onMounted(() => {
|
|||||||
transition: transform 0.1s ease-in-out;
|
transition: transform 0.1s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-expense-item:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-expense-info {
|
.neo-expense-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -1204,6 +1195,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.neo-expense-item-wrapper {
|
.neo-expense-item-wrapper {
|
||||||
border-bottom: 1px solid #f0e5d8;
|
border-bottom: 1px solid #f0e5d8;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-expense-item-wrapper:last-child {
|
.neo-expense-item-wrapper:last-child {
|
||||||
|
@ -378,9 +378,9 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||||
$t('listDetailPage.settleShareModal.cancelButton')
|
$t('listDetailPage.settleShareModal.cancelButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
|
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
|
|
||||||
@ -470,6 +470,11 @@ interface ItemWithUI extends Item {
|
|||||||
showFirework?: boolean; // For firework animation
|
showFirework?: boolean; // For firework animation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ListStatus {
|
||||||
|
updated_at: string;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface List {
|
interface List {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -514,7 +519,7 @@ const error = ref<string | null>(null); // For page-level errors
|
|||||||
const addingItem = ref(false);
|
const addingItem = ref(false);
|
||||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
const lastListUpdate = ref<string | 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 newItem = ref<{ name: string; quantity?: number | string }>({ name: '' });
|
||||||
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
|
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
|
||||||
@ -614,9 +619,8 @@ const fetchListDetails = async () => {
|
|||||||
};
|
};
|
||||||
list.value = localList;
|
list.value = localList;
|
||||||
lastListUpdate.value = rawList.updated_at;
|
lastListUpdate.value = rawList.updated_at;
|
||||||
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
|
lastItemCount.value = rawList.items.length;
|
||||||
return item.updated_at > latest ? item.updated_at : latest;
|
|
||||||
}, '');
|
|
||||||
if (showCostSummaryDialog.value) {
|
if (showCostSummaryDialog.value) {
|
||||||
await fetchListCostSummary();
|
await fetchListCostSummary();
|
||||||
}
|
}
|
||||||
@ -638,14 +642,13 @@ const fetchListDetails = async () => {
|
|||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
if (!list.value) return;
|
if (!list.value) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
|
const response = await apiClient.get(API_ENDPOINTS.LISTS.STATUS(String(list.value.id)));
|
||||||
const { updated_at: newListUpdatedAt, items: newItems } = response.data as ListWithExpenses;
|
const { updated_at: newListUpdatedAt, item_count: newItemCount } = response.data as ListStatus;
|
||||||
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) =>
|
|
||||||
item.updated_at > latest ? item.updated_at : latest,
|
|
||||||
'');
|
|
||||||
|
|
||||||
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
|
if (
|
||||||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
|
(lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
|
||||||
|
(lastItemCount.value !== null && newItemCount !== lastItemCount.value)
|
||||||
|
) {
|
||||||
await fetchListDetails();
|
await fetchListDetails();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -39,9 +39,11 @@
|
|||||||
<label class="neo-checkbox-label" @click.stop>
|
<label class="neo-checkbox-label" @click.stop>
|
||||||
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
|
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
|
||||||
:disabled="item.id === undefined && item.tempId !== undefined" />
|
:disabled="item.id === undefined && item.tempId !== undefined" />
|
||||||
<span class="checkbox-text-span"
|
<div class="checkbox-content">
|
||||||
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
|
<span class="checkbox-text-span"
|
||||||
item.name }}</span>
|
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
|
||||||
|
item.name }}</span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li class="neo-list-item new-item-input-container">
|
<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();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
interface ListStatus {
|
||||||
|
id: number;
|
||||||
|
updated_at: string;
|
||||||
|
item_count: number;
|
||||||
|
latest_item_updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface List {
|
interface List {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -123,6 +132,8 @@ const currentViewedGroup = ref<Group | null>(null);
|
|||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
const newItemInputRefs = ref<HTMLInputElement[]>([]);
|
const newItemInputRefs = ref<HTMLInputElement[]>([]);
|
||||||
|
|
||||||
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const currentGroupId = computed<number | null>(() => {
|
const currentGroupId = computed<number | null>(() => {
|
||||||
const idFromProp = props.groupId;
|
const idFromProp = props.groupId;
|
||||||
const idFromRoute = route.params.groupId;
|
const idFromRoute = route.params.groupId;
|
||||||
@ -413,11 +424,89 @@ const touchActiveListId = ref<number | null>(null);
|
|||||||
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
|
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
|
||||||
const handleTouchEnd = () => { touchActiveListId.value = null; };
|
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(() => {
|
onMounted(() => {
|
||||||
loadCachedData();
|
loadCachedData();
|
||||||
fetchListsAndGroups().then(() => {
|
fetchListsAndGroups().then(() => {
|
||||||
if (lists.value.length > 0) {
|
if (lists.value.length > 0) {
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
|
startPolling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -427,6 +516,9 @@ watch(currentGroupId, () => {
|
|||||||
fetchListsAndGroups().then(() => {
|
fetchListsAndGroups().then(() => {
|
||||||
if (lists.value.length > 0) {
|
if (lists.value.length > 0) {
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
|
startPolling();
|
||||||
|
} else {
|
||||||
|
stopPolling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -436,6 +528,7 @@ watch(() => lists.value.length, (newLength, oldLength) => {
|
|||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
}
|
}
|
||||||
if (newLength > 0) {
|
if (newLength > 0) {
|
||||||
|
startPolling();
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
|
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
|
||||||
if (intersectionObserver) {
|
if (intersectionObserver) {
|
||||||
@ -450,6 +543,7 @@ onUnmounted(() => {
|
|||||||
if (intersectionObserver) {
|
if (intersectionObserver) {
|
||||||
intersectionObserver.disconnect();
|
intersectionObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
stopPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@ -544,6 +638,7 @@ onUnmounted(() => {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Checkbox Styles */
|
||||||
.neo-checkbox-label {
|
.neo-checkbox-label {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
@ -551,7 +646,7 @@ onUnmounted(() => {
|
|||||||
gap: 0.8em;
|
gap: 0.8em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: fit-content;
|
width: 100%;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #414856;
|
color: #414856;
|
||||||
transition: color 0.3s ease;
|
transition: color 0.3s ease;
|
||||||
@ -562,95 +657,89 @@ onUnmounted(() => {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 18px;
|
height: 20px;
|
||||||
width: 18px;
|
width: 20px;
|
||||||
outline: none;
|
outline: none;
|
||||||
border: 2px solid var(--dark);
|
border: 2px solid #b8c1d1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--light);
|
background: transparent;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.neo-checkbox-label input[type="checkbox"]:hover {
|
||||||
border-color: var(--secondary);
|
border-color: var(--secondary);
|
||||||
background: var(--light);
|
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]::before,
|
.neo-checkbox-label input[type="checkbox"]::before,
|
||||||
.neo-checkbox-label input[type="checkbox"]::after {
|
.neo-checkbox-label input[type="checkbox"]::after {
|
||||||
content: "";
|
content: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]::after {
|
.neo-checkbox-label input[type="checkbox"]::after {
|
||||||
width: 0px;
|
content: "";
|
||||||
left: 45%;
|
position: absolute;
|
||||||
transform-origin: left bottom;
|
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 {
|
.neo-checkbox-label input[type="checkbox"]:checked {
|
||||||
border-color: var(--primary);
|
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 {
|
.neo-checkbox-label input[type="checkbox"]:checked::after {
|
||||||
opacity: 1;
|
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 {
|
.checkbox-text-span {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: color 0.4s ease, opacity 0.4s ease;
|
||||||
}
|
width: fit-content;
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animated strikethrough line */
|
||||||
.checkbox-text-span::before {
|
.checkbox-text-span::before {
|
||||||
height: 2px;
|
content: '';
|
||||||
width: 8px;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
left: -0.1em;
|
||||||
background: var(--secondary);
|
right: -0.1em;
|
||||||
border-radius: 2px;
|
height: 2px;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
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 {
|
.checkbox-text-span::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 4px;
|
width: 6px;
|
||||||
height: 4px;
|
height: 6px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 130%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@ -658,21 +747,39 @@ onUnmounted(() => {
|
|||||||
pointer-events: none;
|
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);
|
color: var(--dark);
|
||||||
opacity: 0.7;
|
opacity: 0.6;
|
||||||
text-decoration: line-through var(--dark);
|
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after {
|
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
|
||||||
animation: firework 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.15s;
|
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 {
|
.neo-completed-static {
|
||||||
color: var(--dark);
|
color: var(--dark);
|
||||||
opacity: 0.7;
|
opacity: 0.6;
|
||||||
text-decoration: line-through var(--dark);
|
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 {
|
.new-item-input-container .neo-checkbox-label {
|
||||||
@ -777,93 +884,22 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.neo-list-item {
|
.neo-list-item {
|
||||||
/* padding: 0.8rem 0; */
|
|
||||||
/* Removed as margin-bottom is used */
|
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.7rem;
|
||||||
/* Adjusted for mobile */
|
/* Adjusted for mobile */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes check-01 {
|
@keyframes firework-refined {
|
||||||
0% {
|
from {
|
||||||
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% {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, -50%) scale(0.5);
|
transform: translate(-50%, -50%) scale(0.5);
|
||||||
box-shadow:
|
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);
|
||||||
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% {
|
to {
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, -50%) scale(1.2);
|
transform: translate(-50%, -50%) scale(2);
|
||||||
box-shadow:
|
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);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 router from '@/router' // Import the router instance
|
||||||
import { useAuthStore } from '@/stores/auth' // Import the auth store
|
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 type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
|
||||||
|
import { stringify } from 'qs';
|
||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
@ -11,6 +12,9 @@ const api = axios.create({
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
withCredentials: true, // Enable sending cookies and authentication headers
|
withCredentials: true, // Enable sending cookies and authentication headers
|
||||||
|
paramsSerializer: {
|
||||||
|
serialize: (params) => stringify(params, { arrayFormat: 'repeat' }),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create apiClient with helper methods
|
// Create apiClient with helper methods
|
||||||
|
@ -32,6 +32,11 @@ export const useListDetailStore = defineStore('listDetail', {
|
|||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async fetchListWithExpenses(listId: string) {
|
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.isLoading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
try {
|
try {
|
||||||
|
Loading…
Reference in New Issue
Block a user