feat: Introduce activity tracking and management features
This commit adds new functionality for tracking user activities within the application, including: - Implementation of a new activity service to fetch and manage group activities. - Creation of a dedicated activity store to handle state management for activities. - Introduction of new API endpoints for retrieving paginated activity data. - Enhancements to the UI with new components for displaying activity feeds and items. - Refactoring of existing components to utilize the new activity features, improving user engagement and interaction. These changes aim to enhance the application's activity tracking capabilities and provide users with a comprehensive view of their interactions.
This commit is contained in:
parent
8b181087c3
commit
229f6b7b1c
@ -0,0 +1,32 @@
|
|||||||
|
"""add claimed_by_user fields
|
||||||
|
|
||||||
|
Revision ID: 52caaf813ef4
|
||||||
|
Revises: 7f73c3196b99
|
||||||
|
Create Date: 2025-06-28 18:28:08.783756
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '52caaf813ef4'
|
||||||
|
down_revision: Union[str, None] = '7f73c3196b99'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
135
be/alembic/versions/7f73c3196b99_add_claim_fields_to_item.py
Normal file
135
be/alembic/versions/7f73c3196b99_add_claim_fields_to_item.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"""add claim fields to item
|
||||||
|
|
||||||
|
Revision ID: 7f73c3196b99
|
||||||
|
Revises: d5f8a2e4c7b9
|
||||||
|
Create Date: 2025-06-28 16:11:49.500494
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7f73c3196b99'
|
||||||
|
down_revision: Union[str, None] = 'd5f8a2e4c7b9'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
|
||||||
|
op.drop_table('apscheduler_jobs')
|
||||||
|
op.alter_column('categories', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('chore_assignments', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('chores', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('expense_splits', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('expenses', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('groups', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.add_column('items', sa.Column('claimed_by_user_id', sa.Integer(), nullable=True))
|
||||||
|
op.add_column('items', sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column('items', sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.alter_column('items', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.create_foreign_key(None, 'items', 'users', ['claimed_by_user_id'], ['id'])
|
||||||
|
op.alter_column('lists', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('recurrence_patterns', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('time_entries', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('users', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('users', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('time_entries', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('recurrence_patterns', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('lists', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.drop_constraint(None, 'items', type_='foreignkey')
|
||||||
|
op.alter_column('items', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.drop_column('items', 'claimed_at')
|
||||||
|
op.drop_column('items', 'completed_at')
|
||||||
|
op.drop_column('items', 'claimed_by_user_id')
|
||||||
|
op.alter_column('groups', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('expenses', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('expense_splits', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('chores', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('chore_assignments', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('categories', 'is_deleted',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.create_table('apscheduler_jobs',
|
||||||
|
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
|
||||||
|
)
|
||||||
|
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
60
be/app/api/auth/magic_link.py
Normal file
60
be/app/api/auth/magic_link.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, status, Depends, Request, Query
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from app.auth import get_user_manager, AuthenticationBackendWithRefresh, bearer_transport, get_jwt_strategy, get_refresh_jwt_strategy
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
class MagicLinkRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
# Path: POST /api/v1/auth/magic-link
|
||||||
|
@router.post('/magic-link', status_code=status.HTTP_200_OK)
|
||||||
|
async def send_magic_link(payload: MagicLinkRequest, request: Request, user_manager=Depends(get_user_manager)):
|
||||||
|
"""Generate a one-time magic-link token and *log* it for now.
|
||||||
|
|
||||||
|
In production this should email the user. For Phase-4 backend milestone we
|
||||||
|
simply issue the verification token and return it in the response so the
|
||||||
|
frontend can test the flow without an email provider.
|
||||||
|
"""
|
||||||
|
# Ensure user exists (create guest if not)
|
||||||
|
user = await user_manager.get_by_email(payload.email)
|
||||||
|
if user is None:
|
||||||
|
# Auto-register guest account (inactive until verified)
|
||||||
|
user_in = {
|
||||||
|
'email': payload.email,
|
||||||
|
'password': '', # FastAPI Users requires but we will bypass login
|
||||||
|
}
|
||||||
|
# Using UserCreate model generically – relies on fastapi-users internals
|
||||||
|
try:
|
||||||
|
user = await user_manager.create(user_in, safe=True, request=request)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail='Unable to create account')
|
||||||
|
|
||||||
|
verification_token = await user_manager.generate_verification_token(user)
|
||||||
|
|
||||||
|
# TODO: send email instead of returning token
|
||||||
|
return {'detail': 'Magic link generated (token returned for dev)', 'token': verification_token}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/magic-link/verify')
|
||||||
|
async def verify_magic_link(token: str = Query(...), request: Request = None, user_manager=Depends(get_user_manager)):
|
||||||
|
"""Verify incoming token and issue standard JWT + refresh tokens."""
|
||||||
|
try:
|
||||||
|
user = await user_manager.verify(token, request)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid or expired token')
|
||||||
|
|
||||||
|
# Issue JWT + refresh using existing backend routines
|
||||||
|
access_strategy = get_jwt_strategy()
|
||||||
|
refresh_strategy = get_refresh_jwt_strategy()
|
||||||
|
access_token = await access_strategy.write_token(user)
|
||||||
|
refresh_token = await refresh_strategy.write_token(user)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
'access_token': access_token,
|
||||||
|
'refresh_token': refresh_token,
|
||||||
|
'token_type': 'bearer',
|
||||||
|
}
|
||||||
|
)
|
@ -11,9 +11,18 @@ from app.api.v1.endpoints import financials
|
|||||||
from app.api.v1.endpoints import chores
|
from app.api.v1.endpoints import chores
|
||||||
from app.api.v1.endpoints import history
|
from app.api.v1.endpoints import history
|
||||||
from app.api.v1.endpoints import categories
|
from app.api.v1.endpoints import categories
|
||||||
from app.api.v1.endpoints import users
|
|
||||||
from app.api.auth import oauth, guest, jwt
|
from app.api.auth import oauth, guest, jwt
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
from app.api.v1.endpoints import websocket as ws_endpoint
|
||||||
|
from app.api.v1.endpoints import users
|
||||||
|
|
||||||
|
# Magic link endpoints
|
||||||
|
from app.api.auth import magic_link
|
||||||
|
|
||||||
|
# New activity router
|
||||||
|
from app.api.v1.endpoints import activity
|
||||||
|
|
||||||
api_router_v1 = APIRouter()
|
api_router_v1 = APIRouter()
|
||||||
|
|
||||||
api_router_v1.include_router(health.router)
|
api_router_v1.include_router(health.router)
|
||||||
@ -31,3 +40,12 @@ api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
|
|||||||
api_router_v1.include_router(guest.router, prefix="/auth", tags=["Auth"])
|
api_router_v1.include_router(guest.router, prefix="/auth", tags=["Auth"])
|
||||||
api_router_v1.include_router(jwt.router, prefix="/auth", tags=["Auth"])
|
api_router_v1.include_router(jwt.router, prefix="/auth", tags=["Auth"])
|
||||||
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
||||||
|
|
||||||
|
# Magic link
|
||||||
|
api_router_v1.include_router(magic_link.router, prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
|
# New activity router
|
||||||
|
api_router_v1.include_router(activity.router, tags=["Activity"])
|
||||||
|
|
||||||
|
# WebSockets (no prefix, standalone path)
|
||||||
|
api_router_v1.include_router(ws_endpoint.router, tags=["WebSockets"])
|
||||||
|
33
be/app/api/v1/endpoints/activity.py
Normal file
33
be/app/api/v1/endpoints/activity.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_session
|
||||||
|
from app.crud import activity as crud_activity
|
||||||
|
from app.schemas.activity import PaginatedActivityResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/groups/{group_id}/activity",
|
||||||
|
response_model=PaginatedActivityResponse,
|
||||||
|
summary="Get group activity feed",
|
||||||
|
tags=["Activity"],
|
||||||
|
)
|
||||||
|
async def get_group_activity(
|
||||||
|
group_id: int,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
cursor: Optional[int] = Query(None, description="Unix timestamp for pagination"),
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieves a paginated feed of recent activities within a group.
|
||||||
|
"""
|
||||||
|
activities = await crud_activity.get_group_activity(db, group_id=group_id, limit=limit, cursor=cursor)
|
||||||
|
|
||||||
|
next_cursor = None
|
||||||
|
if activities and len(activities) == limit:
|
||||||
|
next_cursor = int(activities[-1].timestamp.timestamp())
|
||||||
|
|
||||||
|
return PaginatedActivityResponse(items=activities, cursor=next_cursor)
|
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List as PyList, Optional
|
from typing import List as PyList, Optional
|
||||||
|
|
||||||
@ -13,6 +12,7 @@ from app.crud import item as crud_item
|
|||||||
from app.crud import list as crud_list
|
from app.crud import list as crud_list
|
||||||
from app.core.exceptions import ItemNotFoundError, ListPermissionError, ConflictError
|
from app.core.exceptions import ItemNotFoundError, ListPermissionError, ConflictError
|
||||||
from app.auth import current_active_user
|
from app.auth import current_active_user
|
||||||
|
from app.core.redis import broadcast_event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -162,4 +162,85 @@ async def delete_item(
|
|||||||
|
|
||||||
await crud_item.delete_item(db=db, item_db=item_db)
|
await crud_item.delete_item(db=db, item_db=item_db)
|
||||||
logger.info(f"Item {item_id} (version {item_db.version}) deleted successfully by user {user_email}.")
|
logger.info(f"Item {item_id} (version {item_db.version}) deleted successfully by user {user_email}.")
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/items/{item_id}/claim",
|
||||||
|
response_model=ItemPublic,
|
||||||
|
summary="Claim an Item",
|
||||||
|
tags=["Items"],
|
||||||
|
responses={
|
||||||
|
status.HTTP_409_CONFLICT: {"description": "Item is already claimed or completed"},
|
||||||
|
status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to claim this item"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def claim_item(
|
||||||
|
item_id: int,
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Marks an item as claimed by the current user."""
|
||||||
|
item_db = await get_item_and_verify_access(item_id, db, current_user)
|
||||||
|
|
||||||
|
if item_db.list.group_id is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Items on personal lists cannot be claimed.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_item = await crud_item.claim_item(db, item=item_db, user_id=current_user.id)
|
||||||
|
|
||||||
|
# Broadcast the event
|
||||||
|
event = {
|
||||||
|
"type": "item_claimed",
|
||||||
|
"payload": {
|
||||||
|
"list_id": updated_item.list_id,
|
||||||
|
"item_id": updated_item.id,
|
||||||
|
"claimed_by": {
|
||||||
|
"id": current_user.id,
|
||||||
|
"name": current_user.name
|
||||||
|
},
|
||||||
|
"claimed_at": updated_item.claimed_at.isoformat(),
|
||||||
|
"version": updated_item.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await broadcast_event(f"list_{updated_item.list_id}", event)
|
||||||
|
|
||||||
|
return updated_item
|
||||||
|
except ConflictError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/items/{item_id}/claim",
|
||||||
|
response_model=ItemPublic,
|
||||||
|
summary="Unclaim an Item",
|
||||||
|
tags=["Items"],
|
||||||
|
responses={
|
||||||
|
status.HTTP_403_FORBIDDEN: {"description": "User cannot unclaim an item they did not claim"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
async def unclaim_item(
|
||||||
|
item_id: int,
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Removes the current user's claim from an item."""
|
||||||
|
item_db = await get_item_and_verify_access(item_id, db, current_user)
|
||||||
|
|
||||||
|
if item_db.claimed_by_user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only unclaim items that you have claimed.")
|
||||||
|
|
||||||
|
updated_item = await crud_item.unclaim_item(db, item=item_db)
|
||||||
|
|
||||||
|
# Broadcast the event
|
||||||
|
event = {
|
||||||
|
"type": "item_unclaimed",
|
||||||
|
"payload": {
|
||||||
|
"list_id": updated_item.list_id,
|
||||||
|
"item_id": updated_item.id,
|
||||||
|
"version": updated_item.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await broadcast_event(f"list_{updated_item.list_id}", event)
|
||||||
|
|
||||||
|
return updated_item
|
77
be/app/api/v1/endpoints/websocket.py
Normal file
77
be/app/api/v1/endpoints/websocket.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, status, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.auth import get_jwt_strategy, get_user_from_token
|
||||||
|
from app.database import get_transactional_session
|
||||||
|
from app.crud import list as crud_list
|
||||||
|
from app.core.redis import subscribe_to_channel, unsubscribe_from_channel
|
||||||
|
from app.models import User
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
async def _verify_jwt(token: str):
|
||||||
|
"""Return user_id from JWT or None if invalid/expired."""
|
||||||
|
strategy = get_jwt_strategy()
|
||||||
|
try:
|
||||||
|
# FastAPI Users' JWTStrategy.read_token returns the user ID encoded in the token
|
||||||
|
user_id = await strategy.read_token(token)
|
||||||
|
return user_id
|
||||||
|
except Exception: # pragma: no cover – any decoding/expiration error
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/lists/{list_id}")
|
||||||
|
async def list_websocket_endpoint(
|
||||||
|
websocket: WebSocket,
|
||||||
|
list_id: int,
|
||||||
|
token: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
):
|
||||||
|
"""Authenticated WebSocket endpoint for a specific list."""
|
||||||
|
user = await get_user_from_token(token, db)
|
||||||
|
if not user:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify the user has access to this list's group
|
||||||
|
await crud_list.check_list_permission(db, list_id, user.id)
|
||||||
|
except Exception:
|
||||||
|
await websocket.close(code=status.WS_1003_UNSUPPORTED_DATA)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Subscribe to the list-specific channel
|
||||||
|
pubsub = await subscribe_to_channel(f"list_{list_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Keep the connection alive and forward messages from Redis
|
||||||
|
while True:
|
||||||
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
|
||||||
|
if message and message.get("type") == "message":
|
||||||
|
await websocket.send_text(message["data"])
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
# Client disconnected
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Clean up the Redis subscription
|
||||||
|
await unsubscribe_from_channel(f"list_{list_id}", pubsub)
|
||||||
|
|
||||||
|
@router.websocket("/ws/{household_id}")
|
||||||
|
async def household_websocket_endpoint(
|
||||||
|
websocket: WebSocket,
|
||||||
|
household_id: int,
|
||||||
|
token: str = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_transactional_session)
|
||||||
|
):
|
||||||
|
"""Authenticated WebSocket endpoint for a household."""
|
||||||
|
user = await get_user_from_token(token, db)
|
||||||
|
if not user:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: Add permission check for household
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
# ... rest of household logic
|
@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from .database import get_session
|
from .database import get_session
|
||||||
from .models import User
|
from .models import User
|
||||||
@ -165,4 +166,35 @@ fastapi_users = FastAPIUsers[User, int](
|
|||||||
)
|
)
|
||||||
|
|
||||||
current_active_user = fastapi_users.current_user(active=True)
|
current_active_user = fastapi_users.current_user(active=True)
|
||||||
current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JWT helper function used by WebSocket endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_user_from_token(token: str, db: AsyncSession) -> Optional[User]:
|
||||||
|
"""Return the ``User`` associated with a valid *token* or ``None``.
|
||||||
|
|
||||||
|
The function decodes the JWT using the same strategy FastAPI Users uses
|
||||||
|
elsewhere in the application. If the token is invalid/expired or the user
|
||||||
|
cannot be found (or is deleted/inactive), ``None`` is returned so the
|
||||||
|
caller can close the WebSocket with the appropriate code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy = get_jwt_strategy()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = await strategy.read_token(token)
|
||||||
|
except Exception:
|
||||||
|
# Any decoding/parsing/expiry error – treat as invalid token
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch the user from the database. We avoid failing hard – return ``None``
|
||||||
|
# if the user does not exist or is inactive/deleted.
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None or getattr(user, "is_deleted", False) or not user.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user
|
@ -1,7 +1,51 @@
|
|||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
redis_pool = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
|
redis_pool = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
|
||||||
|
|
||||||
async def get_redis():
|
async def get_redis():
|
||||||
return redis_pool
|
return redis_pool
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper functions for Pub/Sub messaging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def broadcast_event(channel: str, event: Dict[str, Any]) -> None:
|
||||||
|
"""Serialize the given event as JSON and publish it on the specified Redis channel.
|
||||||
|
|
||||||
|
This is primarily used by API endpoints to notify WebSocket consumers of
|
||||||
|
real-time changes (e.g. an item being claimed). Consumers subscribe to the
|
||||||
|
corresponding channel (see ``subscribe_to_channel``) and relay the JSON
|
||||||
|
payload to connected clients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
redis_conn = await get_redis()
|
||||||
|
# NOTE: ``json.dumps`` ensures we only send textual data over the wire
|
||||||
|
# which plays nicely with WebSocket ``send_text`` in the frontend.
|
||||||
|
await redis_conn.publish(channel, json.dumps(event))
|
||||||
|
|
||||||
|
async def subscribe_to_channel(channel: str):
|
||||||
|
"""Return a PubSub instance already subscribed to *channel*.
|
||||||
|
|
||||||
|
The caller is responsible for reading messages from the returned ``pubsub``
|
||||||
|
object and for eventually invoking :func:`unsubscribe_from_channel` to
|
||||||
|
clean up resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
redis_conn = await get_redis()
|
||||||
|
pubsub = redis_conn.pubsub()
|
||||||
|
await pubsub.subscribe(channel)
|
||||||
|
return pubsub
|
||||||
|
|
||||||
|
async def unsubscribe_from_channel(channel: str, pubsub) -> None:
|
||||||
|
"""Unsubscribe from *channel* and properly close the PubSub connection."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await pubsub.unsubscribe(channel)
|
||||||
|
finally:
|
||||||
|
# Close the pubsub object to release the underlying connection back to
|
||||||
|
# the pool. We ignore any exceptions here because we are often in a
|
||||||
|
# ``finally`` block during WebSocket shutdown.
|
||||||
|
await pubsub.close()
|
81
be/app/crud/activity.py
Normal file
81
be/app/crud/activity.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy import func, text
|
||||||
|
|
||||||
|
from app.models import ChoreHistory, FinancialAuditLog, Item, UserGroup, User
|
||||||
|
from app.schemas.activity import Activity, ActivityEventType, ActivityUser
|
||||||
|
|
||||||
|
async def get_group_activity(db: AsyncSession, group_id: int, limit: int, cursor: int = None) -> list[Activity]:
|
||||||
|
|
||||||
|
queries = []
|
||||||
|
|
||||||
|
chore_history_stmt = (
|
||||||
|
select(ChoreHistory)
|
||||||
|
.where(ChoreHistory.group_id == group_id)
|
||||||
|
.options(selectinload(ChoreHistory.changed_by_user))
|
||||||
|
)
|
||||||
|
if cursor:
|
||||||
|
chore_history_stmt = chore_history_stmt.where(ChoreHistory.timestamp < datetime.fromtimestamp(cursor))
|
||||||
|
queries.append(chore_history_stmt)
|
||||||
|
|
||||||
|
# FinancialAuditLog has group_id through joins, which is complex. For now, we assume a direct or easy link.
|
||||||
|
# This might need refinement based on actual relationships.
|
||||||
|
# This is a placeholder as FinancialAuditLog doesn't have a direct group_id.
|
||||||
|
# We will need to join through expenses/settlements, etc.
|
||||||
|
# For now, this part will return empty.
|
||||||
|
|
||||||
|
items_stmt = (
|
||||||
|
select(Item)
|
||||||
|
.join(Item.list)
|
||||||
|
.where(text("lists.group_id = :group_id"))
|
||||||
|
.params(group_id=group_id)
|
||||||
|
.options(selectinload(Item.added_by_user), selectinload(Item.completed_by_user))
|
||||||
|
)
|
||||||
|
if cursor:
|
||||||
|
items_stmt = items_stmt.where(Item.created_at < datetime.fromtimestamp(cursor))
|
||||||
|
queries.append(items_stmt)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for q in queries:
|
||||||
|
res = await db.execute(q)
|
||||||
|
results.extend(res.scalars().all())
|
||||||
|
|
||||||
|
activities = []
|
||||||
|
for item in results:
|
||||||
|
if isinstance(item, ChoreHistory):
|
||||||
|
if item.event_type.value == 'completed':
|
||||||
|
user = item.changed_by_user
|
||||||
|
activities.append(Activity(
|
||||||
|
id=f"chore_history-{item.id}",
|
||||||
|
event_type=ActivityEventType.CHORE_COMPLETED,
|
||||||
|
timestamp=item.timestamp,
|
||||||
|
user=ActivityUser(id=user.id, name=user.name),
|
||||||
|
details={"chore_name": item.event_data.get("name", "Unknown chore")},
|
||||||
|
message=f"{user.name or 'User'} completed '{item.event_data.get('name', 'a chore')}'."
|
||||||
|
))
|
||||||
|
elif isinstance(item, Item):
|
||||||
|
user = item.added_by_user
|
||||||
|
activities.append(Activity(
|
||||||
|
id=f"item-added-{item.id}",
|
||||||
|
event_type=ActivityEventType.ITEM_ADDED,
|
||||||
|
timestamp=item.created_at,
|
||||||
|
user=ActivityUser(id=user.id, name=user.name),
|
||||||
|
details={"item_name": item.name, "list_id": item.list_id},
|
||||||
|
message=f"{user.name or 'User'} added '{item.name}' to a list."
|
||||||
|
))
|
||||||
|
if item.is_complete and item.completed_by_user:
|
||||||
|
c_user = item.completed_by_user
|
||||||
|
activities.append(Activity(
|
||||||
|
id=f"item-completed-{item.id}",
|
||||||
|
event_type=ActivityEventType.ITEM_COMPLETED,
|
||||||
|
timestamp=item.updated_at, # This is an approximation
|
||||||
|
user=ActivityUser(id=c_user.id, name=c_user.name),
|
||||||
|
details={"item_name": item.name, "list_id": item.list_id},
|
||||||
|
message=f"{c_user.name or 'User'} purchased '{item.name}'."
|
||||||
|
))
|
||||||
|
|
||||||
|
activities.sort(key=lambda x: x.timestamp, reverse=True)
|
||||||
|
|
||||||
|
return activities[:limit]
|
@ -189,4 +189,27 @@ async def delete_item(db: AsyncSession, item_db: ItemModel) -> None:
|
|||||||
raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}")
|
raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}")
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True)
|
logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True)
|
||||||
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")
|
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")
|
||||||
|
|
||||||
|
async def claim_item(db: AsyncSession, item: ItemModel, user_id: int) -> ItemModel:
|
||||||
|
"""Marks an item as claimed by a user."""
|
||||||
|
if item.is_complete:
|
||||||
|
raise ConflictError("Cannot claim a completed item.")
|
||||||
|
if item.claimed_by_user_id is not None and item.claimed_by_user_id != user_id:
|
||||||
|
raise ConflictError("Item is already claimed by another user.")
|
||||||
|
|
||||||
|
item.claimed_by_user_id = user_id
|
||||||
|
item.claimed_at = datetime.now(timezone.utc)
|
||||||
|
item.version += 1
|
||||||
|
db.add(item)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(item, attribute_names=['claimed_by_user', 'version', 'claimed_at'])
|
||||||
|
return item
|
||||||
|
|
||||||
|
async def unclaim_item(db: AsyncSession, item: ItemModel) -> ItemModel:
|
||||||
|
"""Removes a user's claim from an item."""
|
||||||
|
item.claimed_by_user_id = None
|
||||||
|
item.claimed_at = None
|
||||||
|
item.version += 1
|
||||||
|
db.add(item)
|
||||||
|
await db.flush()
|
@ -1,6 +1,7 @@
|
|||||||
import enum
|
import enum
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
@ -18,7 +19,8 @@ from sqlalchemy import (
|
|||||||
Text,
|
Text,
|
||||||
Numeric,
|
Numeric,
|
||||||
CheckConstraint,
|
CheckConstraint,
|
||||||
Date
|
Date,
|
||||||
|
Float
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, declared_attr
|
from sqlalchemy.orm import relationship, declared_attr
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
@ -115,6 +117,12 @@ class User(Base, SoftDeleteMixin):
|
|||||||
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator")
|
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator")
|
||||||
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user")
|
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user")
|
||||||
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user")
|
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user")
|
||||||
|
# Items this user has claimed responsibility for (e.g., grocery list items)
|
||||||
|
claimed_items = relationship(
|
||||||
|
"Item",
|
||||||
|
foreign_keys="Item.claimed_by_user_id",
|
||||||
|
back_populates="claimed_by_user",
|
||||||
|
)
|
||||||
expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user")
|
expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user")
|
||||||
expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user")
|
expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user")
|
||||||
expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user")
|
expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user")
|
||||||
@ -220,14 +228,18 @@ class Item(Base, SoftDeleteMixin):
|
|||||||
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
|
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
|
||||||
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
claimed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
version = Column(Integer, nullable=False, default=1, server_default='1')
|
version = Column(Integer, nullable=False, default=1, server_default='1')
|
||||||
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
claimed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# --- Relationships ---
|
# --- Relationships ---
|
||||||
list = relationship("List", back_populates="items")
|
list = relationship("List", back_populates="items")
|
||||||
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
|
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
|
||||||
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
|
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
|
||||||
|
claimed_by_user = relationship("User", foreign_keys=[claimed_by_user_id], back_populates="claimed_items")
|
||||||
expenses = relationship("Expense", back_populates="item")
|
expenses = relationship("Expense", back_populates="item")
|
||||||
category = relationship("Category", back_populates="items")
|
category = relationship("Category", back_populates="items")
|
||||||
|
|
||||||
|
32
be/app/schemas/activity.py
Normal file
32
be/app/schemas/activity.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
class ActivityEventType(str, Enum):
|
||||||
|
CHORE_COMPLETED = "chore_completed"
|
||||||
|
CHORE_CREATED = "chore_created"
|
||||||
|
EXPENSE_CREATED = "expense_created"
|
||||||
|
EXPENSE_SETTLED = "expense_settled"
|
||||||
|
ITEM_ADDED = "item_added"
|
||||||
|
ITEM_COMPLETED = "item_completed"
|
||||||
|
USER_JOINED_GROUP = "user_joined_group"
|
||||||
|
|
||||||
|
class ActivityUser(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
class Activity(BaseModel):
|
||||||
|
id: str = Field(..., description="A unique ID for the activity event, e.g., 'chore_completed-123'")
|
||||||
|
event_type: ActivityEventType
|
||||||
|
timestamp: datetime
|
||||||
|
user: ActivityUser
|
||||||
|
details: Dict[str, Any] = Field({}, description="Structured data about the event.")
|
||||||
|
message: str = Field(..., description="A human-readable message, e.g., 'John completed \"Wash dishes\".'")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class PaginatedActivityResponse(BaseModel):
|
||||||
|
items: List[Activity]
|
||||||
|
cursor: Optional[int] = Field(None, description="The timestamp of the last item, for cursor-based pagination.")
|
48
docs/inventory-2025-06-28.csv
Normal file
48
docs/inventory-2025-06-28.csv
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
path,feature,uses SCSS?,uses Options API?,third-party libs
|
||||||
|
src/App.vue,Global UI,✔️,❌,
|
||||||
|
src/layouts/MainLayout.vue,Global UI,✔️,❌,
|
||||||
|
src/layouts/AuthLayout.vue,Auth,✔️,❌,
|
||||||
|
src/components/SocialLoginButtons.vue,Auth,❌,❌,vue3-social
|
||||||
|
src/components/OfflineIndicator.vue,Global UI,❌,❌,
|
||||||
|
src/components/global/NotificationDisplay.vue,Global UI,✔️,❌,
|
||||||
|
src/components/EssentialLink.vue,Global UI,❌,❌,
|
||||||
|
src/components/CategoryForm.vue,Categories,❌,❌,
|
||||||
|
src/components/SettleShareModal.vue,Lists,❌,❌,
|
||||||
|
src/components/CreateGroupModal.vue,Groups,❌,❌,
|
||||||
|
src/components/CreateListModal.vue,Lists,❌,❌,
|
||||||
|
src/components/CreateExpenseForm.vue,Expenses,❌,❌,
|
||||||
|
src/components/ChoreItem.vue,Chores,✔️,❌,
|
||||||
|
src/components/ConflictResolutionDialog.vue,Chores,❌,❌,
|
||||||
|
src/components/expenses/ExpenseForm.vue,Expenses,❌,❌,
|
||||||
|
src/components/expenses/ExpenseList.vue,Expenses,❌,❌,
|
||||||
|
src/components/expenses/RecurrencePatternForm.vue,Expenses,❌,❌,
|
||||||
|
src/components/list-detail/CostSummaryDialog.vue,Lists,❌,❌,
|
||||||
|
src/components/list-detail/ExpenseSection.vue,Lists,❌,❌,
|
||||||
|
src/components/list-detail/ItemsList.vue,Lists,❌,❌,
|
||||||
|
src/components/list-detail/ListItem.vue,Lists,❌,❌,
|
||||||
|
src/components/list-detail/OcrDialog.vue,Lists,❌,❌,
|
||||||
|
src/components/list-detail/SettleShareModal.vue,Lists,❌,❌,
|
||||||
|
src/pages/LoginPage.vue,Auth,❌,❌,
|
||||||
|
src/pages/SignupPage.vue,Auth,❌,❌,
|
||||||
|
src/pages/AuthCallbackPage.vue,Auth,❌,❌,
|
||||||
|
src/pages/AccountPage.vue,Auth,❌,❌,
|
||||||
|
src/pages/ChoresPage.vue,Chores,✔️,❌,
|
||||||
|
src/pages/CategoriesPage.vue,Categories,❌,❌,
|
||||||
|
src/pages/IndexPage.vue,Global UI,❌,❌,
|
||||||
|
src/pages/ExpensePage.vue,Expenses,✔️,❌,
|
||||||
|
src/pages/ExpensesPage.vue,Expenses,❌,❌,
|
||||||
|
src/pages/GroupsPage.vue,Groups,❌,❌,
|
||||||
|
src/pages/GroupDetailPage.vue,Groups,❌,❌,
|
||||||
|
src/pages/ListDetailPage.vue,Lists,❌,❌,
|
||||||
|
src/pages/ListsPage.vue,Lists,❌,❌,
|
||||||
|
src/pages/ErrorNotFound.vue,Misc,❌,❌,
|
||||||
|
|
||||||
|
# Pinia Stores
|
||||||
|
path,feature,uses SCSS?,uses Options API?,third-party libs
|
||||||
|
src/stores/auth.ts,Auth,,,,
|
||||||
|
src/stores/categoryStore.ts,Categories,,,,
|
||||||
|
src/stores/groupStore.ts,Groups,,,,
|
||||||
|
src/stores/listDetailStore.ts,Lists,,,,
|
||||||
|
src/stores/offline.ts,Global UI,,,,
|
||||||
|
src/stores/notifications.ts,Global UI,,,,
|
||||||
|
src/stores/timeEntryStore.ts,Chores,,,,
|
Can't render this file because it has a wrong number of fields in line 40.
|
@ -1,17 +0,0 @@
|
|||||||
import type { StorybookConfig } from '@storybook/vue3-vite';
|
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
|
||||||
"stories": [
|
|
||||||
"../src/**/*.mdx",
|
|
||||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
|
||||||
],
|
|
||||||
"addons": [
|
|
||||||
"@storybook/addon-onboarding",
|
|
||||||
"@storybook/addon-docs"
|
|
||||||
],
|
|
||||||
"framework": {
|
|
||||||
"name": "@storybook/vue3-vite",
|
|
||||||
"options": {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
export default config;
|
|
@ -1,14 +0,0 @@
|
|||||||
import type { Preview } from '@storybook/vue3-vite'
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
parameters: {
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/i,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default preview;
|
|
97
fe/src/components/AuthenticationSheet.vue
Normal file
97
fe/src/components/AuthenticationSheet.vue
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<TransitionRoot appear :show="open" as="template">
|
||||||
|
<Dialog as="div" class="relative z-50" @close="close">
|
||||||
|
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100"
|
||||||
|
leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
|
||||||
|
<div class="fixed inset-0 bg-black/25" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 overflow-y-auto">
|
||||||
|
<div class="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
|
||||||
|
enter-to="opacity-100 scale-100" leave="ease-in duration-200" leave-from="opacity-100 scale-100"
|
||||||
|
leave-to="opacity-0 scale-95">
|
||||||
|
<DialogPanel
|
||||||
|
class="w-full max-w-md transform overflow-hidden rounded-2xl bg-white dark:bg-dark p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
<DialogTitle class="text-lg font-medium leading-6 text-gray-900 dark:text-light mb-4">Sign
|
||||||
|
in</DialogTitle>
|
||||||
|
|
||||||
|
<div v-if="step === 'email'" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">Enter your email and we'll send you
|
||||||
|
a magic link.</p>
|
||||||
|
<form @submit.prevent="sendLink" class="space-y-4">
|
||||||
|
<input v-model="email" type="email" placeholder="you@example.com"
|
||||||
|
class="w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-primary focus:border-primary text-sm dark:bg-neutral-800 dark:border-neutral-600"
|
||||||
|
required />
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full inline-flex justify-center rounded bg-primary text-white px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
:disabled="loading">
|
||||||
|
<span v-if="loading" class="animate-pulse">Sending…</span>
|
||||||
|
<span v-else>Send Magic Link</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="relative py-2">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-gray-200 dark:border-neutral-700" />
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-xs uppercase"><span
|
||||||
|
class="bg-white dark:bg-dark px-2 text-gray-500">or</span></div>
|
||||||
|
</div>
|
||||||
|
<SocialLoginButtons />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="step === 'sent'" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-200">We've sent a link to <strong>{{
|
||||||
|
email }}</strong>. Check your inbox and click the link to sign in.</p>
|
||||||
|
<button @click="close" class="mt-2 text-sm text-primary hover:underline">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, defineExpose, watch } from 'vue'
|
||||||
|
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import SocialLoginButtons from '@/components/SocialLoginButtons.vue'
|
||||||
|
import { useNotificationStore } from '@/stores/notifications'
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const email = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const step = ref<'email' | 'sent'>('email')
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const notify = useNotificationStore()
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
open.value = true
|
||||||
|
step.value = 'email'
|
||||||
|
email.value = ''
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendLink() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.requestMagicLink(email.value)
|
||||||
|
step.value = 'sent'
|
||||||
|
notify.addNotification({ message: 'Magic link sent!', type: 'success' })
|
||||||
|
} catch (e: any) {
|
||||||
|
notify.addNotification({ message: e?.response?.data?.detail || 'Failed to send link', type: 'error' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
96
fe/src/components/ChoreDetailSheet.vue
Normal file
96
fe/src/components/ChoreDetailSheet.vue
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="isOpen">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-neutral-900 dark:text-neutral-100">
|
||||||
|
{{ chore?.name || 'Chore details' }}
|
||||||
|
</h3>
|
||||||
|
<button @click="close" class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
|
||||||
|
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chore" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold mb-2">General</h4>
|
||||||
|
<dl class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Type</dt>
|
||||||
|
<dd>{{ chore.type === 'group' ? 'Group' : 'Personal' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Created by</dt>
|
||||||
|
<dd>{{ chore.creator?.name || chore.creator?.email || 'Unknown' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Due date</dt>
|
||||||
|
<dd>{{ formatDate(chore.next_due_date) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-neutral-600 dark:text-neutral-400">Frequency</dt>
|
||||||
|
<dd>{{ frequencyLabel }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chore.description">
|
||||||
|
<h4 class="text-sm font-semibold mb-2">Description</h4>
|
||||||
|
<p class="text-sm text-neutral-700 dark:text-neutral-200 whitespace-pre-wrap">{{ chore.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chore.child_chores?.length">
|
||||||
|
<h4 class="text-sm font-semibold mb-2">Sub-Tasks</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li v-for="sub in chore.child_chores" :key="sub.id">
|
||||||
|
{{ sub.name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import type { ChoreWithCompletion } from '@/types/chore'
|
||||||
|
import BaseIcon from '@/components/BaseIcon.vue'
|
||||||
|
import Dialog from '@/components/ui/Dialog.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
chore: ChoreWithCompletion | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||||
|
|
||||||
|
// Local proxy for the v-model binding to avoid mutating the prop directly
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val: boolean) => emit('update:modelValue', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return format(new Date(dateStr), 'PPP')
|
||||||
|
}
|
||||||
|
|
||||||
|
const frequencyLabel = computed(() => {
|
||||||
|
if (!props.chore) return ''
|
||||||
|
const { frequency, custom_interval_days } = props.chore
|
||||||
|
if (frequency === 'custom' && custom_interval_days) {
|
||||||
|
return `Every ${custom_interval_days} days`
|
||||||
|
}
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
one_time: 'One-time',
|
||||||
|
daily: 'Daily',
|
||||||
|
weekly: 'Weekly',
|
||||||
|
monthly: 'Monthly',
|
||||||
|
}
|
||||||
|
return map[frequency] || frequency
|
||||||
|
})
|
||||||
|
</script>
|
@ -1,52 +1,84 @@
|
|||||||
<template>
|
<template>
|
||||||
<li class="neo-list-item" :class="`status-${getDueDateStatus(chore)}`">
|
<li :class="[
|
||||||
<div class="neo-item-content">
|
'relative flex items-start gap-3 py-3 border-b border-neutral-200 dark:border-neutral-700 transition',
|
||||||
<label class="neo-checkbox-label">
|
getDueDateStatus(chore) === 'overdue' && 'bg-warning/10',
|
||||||
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)">
|
getDueDateStatus(chore) === 'due-today' && 'bg-success/10',
|
||||||
<div class="checkbox-content">
|
]">
|
||||||
<div class="chore-main-info">
|
<!-- Checkbox + main content -->
|
||||||
<span class="checkbox-text-span"
|
<label class="flex gap-3 w-full cursor-pointer select-none">
|
||||||
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
|
<!-- Checkbox -->
|
||||||
{{ chore.name }}
|
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)"
|
||||||
|
class="h-5 w-5 text-primary rounded-md border-neutral-300 dark:border-neutral-600 focus:ring-primary-500 focus:ring-2" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1">
|
||||||
|
<!-- Title + badges -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span :class="[chore.is_completed && !chore.updating ? 'line-through text-neutral-400' : '']">
|
||||||
|
{{ chore.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<template v-if="chore.type === 'group'">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-1.5 py-0.5 rounded bg-primary/10 text-primary text-xs font-medium">
|
||||||
|
Group
|
||||||
</span>
|
</span>
|
||||||
<div class="chore-badges">
|
</template>
|
||||||
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
|
<template v-if="getDueDateStatus(chore) === 'overdue'">
|
||||||
<span v-if="getDueDateStatus(chore) === 'overdue'"
|
<span
|
||||||
class="badge badge-overdue">Overdue</span>
|
class="inline-flex items-center px-1.5 py-0.5 rounded bg-danger/10 text-danger text-xs font-medium">
|
||||||
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
|
Overdue
|
||||||
Today</span>
|
</span>
|
||||||
<span v-if="getDueDateStatus(chore) === 'upcoming'" class="badge badge-upcoming">{{
|
</template>
|
||||||
dueInText }}</span>
|
<template v-else-if="getDueDateStatus(chore) === 'due-today'">
|
||||||
</div>
|
<span
|
||||||
</div>
|
class="inline-flex items-center px-1.5 py-0.5 rounded bg-warning/10 text-warning text-xs font-medium">
|
||||||
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
|
Due Today
|
||||||
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
|
</span>
|
||||||
<div v-if="totalTime > 0" class="total-time">
|
</template>
|
||||||
Total Time: {{ formatDuration(totalTime) }}
|
<template v-else-if="getDueDateStatus(chore) === 'upcoming'">
|
||||||
</div>
|
<span
|
||||||
|
class="inline-flex items-center px-1.5 py-0.5 rounded bg-neutral/10 text-neutral text-xs font-medium">
|
||||||
|
{{ dueInText }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
<div class="neo-item-actions">
|
<!-- Description -->
|
||||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer"
|
<p v-if="chore.description" class="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-2">
|
||||||
:disabled="chore.is_completed || !chore.current_assignment_id">
|
{{ chore.description }}
|
||||||
<BaseIcon :name="isActiveTimer ? 'heroicons:pause-20-solid' : 'heroicons:play-20-solid'"
|
</p>
|
||||||
class="w-4 h-4" />
|
|
||||||
</button>
|
<!-- Subtext / time -->
|
||||||
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
|
<span v-if="chore.subtext" class="text-xs text-neutral-500">{{ chore.subtext }}</span>
|
||||||
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="w-4 h-4" />
|
<span v-if="totalTime > 0" class="text-xs text-neutral-500">
|
||||||
</button>
|
Total Time: {{ formatDuration(totalTime) }}
|
||||||
<button class="btn btn-sm btn-neutral" @click="emit('open-history', chore)" title="View History">
|
</span>
|
||||||
<BaseIcon name="heroicons:calendar-days-20-solid" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
|
|
||||||
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
|
|
||||||
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
<Button variant="ghost" size="sm" color="neutral" @click="toggleTimer"
|
||||||
|
:disabled="chore.is_completed || !chore.current_assignment_id">
|
||||||
|
<BaseIcon :name="isActiveTimer ? 'heroicons:pause-20-solid' : 'heroicons:play-20-solid'"
|
||||||
|
class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-details', chore)">
|
||||||
|
<BaseIcon name="heroicons:clipboard-document-list-20-solid" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" color="neutral" @click="emit('open-history', chore)">
|
||||||
|
<BaseIcon name="heroicons:calendar-days-20-solid" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" color="neutral" @click="emit('edit', chore)">
|
||||||
|
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" color="danger" @click="emit('delete', chore)">
|
||||||
|
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="chore.child_chores && chore.child_chores.length" class="child-chore-list">
|
|
||||||
|
<!-- Recursive children -->
|
||||||
|
<ul v-if="chore.child_chores?.length" class="ml-5">
|
||||||
<ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries"
|
<ChoreItem v-for="child in chore.child_chores" :key="child.id" :chore="child" :time-entries="timeEntries"
|
||||||
:active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)"
|
:active-timer="activeTimer" @toggle-completion="emit('toggle-completion', $event)"
|
||||||
@edit="emit('edit', $event)" @delete="emit('delete', $event)"
|
@edit="emit('edit', $event)" @delete="emit('delete', $event)"
|
||||||
@ -59,12 +91,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, defineEmits, computed } from 'vue';
|
import { defineProps, defineEmits, computed } from 'vue';
|
||||||
import { formatDistanceToNow, parseISO, isToday, isPast } from 'date-fns';
|
import { formatDistanceToNow, isToday } from 'date-fns';
|
||||||
import type { ChoreWithCompletion } from '../types/chore';
|
import type { ChoreWithCompletion } from '../types/chore';
|
||||||
import type { TimeEntry } from '../stores/timeEntryStore';
|
import type { TimeEntry } from '@/types/time_entry';
|
||||||
import { formatDuration } from '../utils/formatters';
|
import { formatDuration } from '../utils/formatters';
|
||||||
import BaseIcon from './BaseIcon.vue';
|
import BaseIcon from './BaseIcon.vue';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
|
// --- props & emits ---
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
chore: ChoreWithCompletion;
|
chore: ChoreWithCompletion;
|
||||||
timeEntries: TimeEntry[];
|
timeEntries: TimeEntry[];
|
||||||
@ -81,13 +115,14 @@ const emit = defineEmits<{
|
|||||||
(e: 'stop-timer', chore: ChoreWithCompletion, timeEntryId: number): void;
|
(e: 'stop-timer', chore: ChoreWithCompletion, timeEntryId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isActiveTimer = computed(() => {
|
// --- computed helpers ---
|
||||||
return props.activeTimer && props.activeTimer.chore_assignment_id === props.chore.current_assignment_id;
|
const isActiveTimer = computed(() =>
|
||||||
});
|
!!props.activeTimer && props.activeTimer.chore_assignment_id === props.chore.current_assignment_id,
|
||||||
|
);
|
||||||
|
|
||||||
const totalTime = computed(() => {
|
const totalTime = computed(() =>
|
||||||
return props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0);
|
props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0),
|
||||||
});
|
);
|
||||||
|
|
||||||
const dueInText = computed(() => {
|
const dueInText = computed(() => {
|
||||||
if (!props.chore.next_due_date) return '';
|
if (!props.chore.next_due_date) return '';
|
||||||
@ -96,15 +131,16 @@ const dueInText = computed(() => {
|
|||||||
return formatDistanceToNow(dueDate, { addSuffix: true });
|
return formatDistanceToNow(dueDate, { addSuffix: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleTimer = () => {
|
// --- methods ---
|
||||||
|
function toggleTimer() {
|
||||||
if (isActiveTimer.value) {
|
if (isActiveTimer.value) {
|
||||||
emit('stop-timer', props.chore, props.activeTimer!.id);
|
emit('stop-timer', props.chore, props.activeTimer!.id);
|
||||||
} else {
|
} else {
|
||||||
emit('start-timer', props.chore);
|
emit('start-timer', props.chore);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
function getDueDateStatus(chore: ChoreWithCompletion) {
|
||||||
if (chore.is_completed) return 'completed';
|
if (chore.is_completed) return 'completed';
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@ -116,258 +152,11 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
if (dueDate < today) return 'overdue';
|
if (dueDate < today) return 'overdue';
|
||||||
if (dueDate.getTime() === today.getTime()) return 'due-today';
|
if (dueDate.getTime() === today.getTime()) return 'due-today';
|
||||||
return 'upcoming';
|
return 'upcoming';
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
name: 'ChoreItem'
|
name: 'ChoreItem'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
/* Neo-style list items */
|
|
||||||
.neo-list-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
position: relative;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:hover {
|
|
||||||
background-color: #f8f8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
margin-left: auto;
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:hover .neo-item-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Checkbox Styles */
|
|
||||||
.neo-checkbox-label {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.8em;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #414856;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"] {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
position: relative;
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
outline: none;
|
|
||||||
border: 2px solid #b8c1d1;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]:hover {
|
|
||||||
border-color: var(--secondary);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]::before,
|
|
||||||
.neo-checkbox-label input[type="checkbox"]::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]::after {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]:checked::after {
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(45deg) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-text-span {
|
|
||||||
position: relative;
|
|
||||||
transition: color 0.4s ease, opacity 0.4s ease;
|
|
||||||
width: fit-content;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--dark);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated strikethrough line */
|
|
||||||
.checkbox-text-span::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-completed-static {
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.6;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status-based styling */
|
|
||||||
.status-completed {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chore-specific styles */
|
|
||||||
.chore-main-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chore-badges {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-group {
|
|
||||||
background-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-overdue {
|
|
||||||
background-color: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-due-today {
|
|
||||||
background-color: #f59e0b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-upcoming {
|
|
||||||
background-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chore-description {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-time {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-time {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.child-chore-list {
|
|
||||||
list-style: none;
|
|
||||||
padding-left: 2rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
border-left: 2px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,32 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New Group">
|
<Dialog v-model="isOpen" class="max-w-md w-full p-6 bg-white rounded-lg dark:bg-neutral-800">
|
||||||
<template #default>
|
<Heading :level="3" class="mb-4">{{ t('createGroupModal.title', 'Create New Household') }}</Heading>
|
||||||
<form @submit.prevent="onSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
<VFormField label="Group Name" :error-message="formError ?? undefined">
|
<div class="mb-4">
|
||||||
<VInput type="text" v-model="groupName" required ref="groupNameInput" />
|
<label class="block text-sm font-medium mb-1" for="groupNameInput">{{ t('createGroupModal.nameLabel',
|
||||||
</VFormField>
|
'Group Name') }}</label>
|
||||||
</form>
|
<Input id="groupNameInput" v-model="groupName"
|
||||||
</template>
|
:placeholder="t('createGroupModal.namePlaceholder', 'e.g. My Household')" required />
|
||||||
<template #footer>
|
<Alert v-if="formError" type="error" :message="formError" class="mt-1" />
|
||||||
<VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
|
</div>
|
||||||
<VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
|
<div class="flex justify-end gap-2 mt-6">
|
||||||
<VSpinner v-if="loading" size="sm" />
|
<Button variant="ghost" color="neutral" type="button" @click="closeModal">{{ t('shared.cancel',
|
||||||
Create
|
'Cancel') }}</Button>
|
||||||
</VButton>
|
<Button type="submit" :disabled="loading">
|
||||||
</template>
|
<Spinner v-if="loading" size="sm" class="mr-1" />
|
||||||
</VModal>
|
{{ t('createGroupModal.create', 'Create') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue';
|
import { ref, watch, nextTick } from 'vue';
|
||||||
import { useVModel } from '@vueuse/core';
|
import { useVModel } from '@vueuse/core';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import VModal from '@/components/valerie/VModal.vue';
|
import Dialog from '@/components/ui/Dialog.vue';
|
||||||
import VFormField from '@/components/valerie/VFormField.vue';
|
import Input from '@/components/ui/Input.vue';
|
||||||
import VInput from '@/components/valerie/VInput.vue';
|
import Button from '@/components/ui/Button.vue';
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
import Spinner from '@/components/ui/Spinner.vue';
|
||||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
import Heading from '@/components/ui/Heading.vue';
|
||||||
|
import Alert from '@/components/ui/Alert.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
@ -42,7 +48,8 @@ const groupName = ref('');
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const formError = ref<string | null>(null);
|
const formError = ref<string | null>(null);
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
const groupNameInput = ref<InstanceType<typeof VInput> | null>(null);
|
const { t } = useI18n();
|
||||||
|
const groupNameInput = ref<InstanceType<typeof Input> | null>(null);
|
||||||
|
|
||||||
watch(isOpen, (newVal) => {
|
watch(isOpen, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
@ -58,14 +65,14 @@ const closeModal = () => {
|
|||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = () => {
|
function validateForm() {
|
||||||
formError.value = null;
|
formError.value = null;
|
||||||
if (!groupName.value.trim()) {
|
if (!groupName.value.trim()) {
|
||||||
formError.value = 'Name is required';
|
formError.value = t('createGroupModal.nameRequired', 'Name is required');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
}
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
@ -75,7 +82,7 @@ const onSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
const payload = { name: groupName.value };
|
const payload = { name: groupName.value };
|
||||||
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, payload);
|
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, payload);
|
||||||
notificationStore.addNotification({ message: 'Group created successfully', type: 'success' });
|
notificationStore.addNotification({ message: t('createGroupModal.success', 'Group created successfully'), type: 'success' });
|
||||||
emit('created', response.data);
|
emit('created', response.data);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -89,14 +96,4 @@ const onSubmit = async () => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style scoped></style>
|
||||||
.form-error-text {
|
|
||||||
color: var(--danger);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-2 {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,44 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<VModal :model-value="isOpen" @update:model-value="closeModal" title="Create New List">
|
<Dialog :model-value="isOpen" @update:model-value="closeModal">
|
||||||
<template #default>
|
<form @submit.prevent="onSubmit" class="mt-4">
|
||||||
<form @submit.prevent="onSubmit">
|
<div>
|
||||||
<VFormField label="List Name" :error-message="formErrors.listName">
|
<label for="list-name" class="block text-sm font-medium text-gray-700">{{
|
||||||
<VInput type="text" v-model="listName" required ref="listNameInput" />
|
$t('createListModal.listNameLabel')
|
||||||
</VFormField>
|
}}</label>
|
||||||
|
<Input id="list-name" type="text" v-model="listName" required ref="listNameInput" class="mt-1 w-full" />
|
||||||
|
<p v-if="formErrors.listName" class="mt-1 text-sm text-red-600">
|
||||||
|
{{ formErrors.listName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<VFormField label="Description">
|
<div class="mt-4">
|
||||||
<VTextarea v-model="description" :rows="3" />
|
<label for="list-description" class="block text-sm font-medium text-gray-700">{{
|
||||||
</VFormField>
|
$t('createListModal.descriptionLabel')
|
||||||
|
}}</label>
|
||||||
|
<textarea id="list-description" v-model="description" :rows="3"
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<VFormField label="Associate with Group (Optional)" v-if="props.groups && props.groups.length > 0">
|
<div v-if="props.groups && props.groups.length > 0" class="mt-4">
|
||||||
<VSelect v-model="selectedGroupId" :options="groupOptionsForSelect" placeholder="None" />
|
<label for="list-group" class="block text-sm font-medium text-gray-700">{{
|
||||||
</VFormField>
|
$t('createListModal.groupLabel')
|
||||||
<!-- Form submission is handled by button in footer slot -->
|
}}</label>
|
||||||
</form>
|
<select id="list-group" v-model="selectedGroupId"
|
||||||
</template>
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
<template #footer>
|
<option :value="SENTINEL_NO_GROUP">{{ $t('shared.none') }}</option>
|
||||||
<VButton variant="neutral" @click="closeModal" type="button">Cancel</VButton>
|
<option v-for="opt in props.groups" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
<VButton type="submit" variant="primary" :disabled="loading" @click="onSubmit" class="ml-2">
|
</select>
|
||||||
<VSpinner v-if="loading" size="sm" />
|
</div>
|
||||||
Create
|
|
||||||
</VButton>
|
<div class="mt-6 flex justify-end gap-x-2">
|
||||||
</template>
|
<Button variant="outline" color="neutral" @click="closeModal" type="button">{{
|
||||||
</VModal>
|
$t('createListModal.cancelButton') }}</Button>
|
||||||
|
<Button type="submit" color="primary" :disabled="loading" @click="onSubmit">
|
||||||
|
<Spinner v-if="loading" size="sm" class="mr-1" />
|
||||||
|
{{ $t('createListModal.createButton') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, computed } from 'vue';
|
import { ref, watch, nextTick, computed } from 'vue';
|
||||||
import { useVModel } from '@vueuse/core'; // onClickOutside removed
|
import { useVModel } from '@vueuse/core';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming this path is correct
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import VModal from '@/components/valerie/VModal.vue';
|
import { Dialog, Input, Button, Spinner } from '@/components/ui';
|
||||||
import VFormField from '@/components/valerie/VFormField.vue';
|
import { useI18n } from 'vue-i18n';
|
||||||
import VInput from '@/components/valerie/VInput.vue';
|
|
||||||
import VTextarea from '@/components/valerie/VTextarea.vue';
|
const { t } = useI18n();
|
||||||
import VSelect from '@/components/valerie/VSelect.vue';
|
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
|
||||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
|
||||||
import type { Group } from '@/types/group';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
@ -53,41 +65,34 @@ const emit = defineEmits<{
|
|||||||
const isOpen = useVModel(props, 'modelValue', emit);
|
const isOpen = useVModel(props, 'modelValue', emit);
|
||||||
const listName = ref('');
|
const listName = ref('');
|
||||||
const description = ref('');
|
const description = ref('');
|
||||||
const SENTINEL_NO_GROUP = 0; // Using 0 to represent 'None' or 'Personal List'
|
const SENTINEL_NO_GROUP = 0;
|
||||||
const selectedGroupId = ref<number>(SENTINEL_NO_GROUP); // Initialize with sentinel
|
const selectedGroupId = ref<number>(SENTINEL_NO_GROUP);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const formErrors = ref<{ listName?: string }>({});
|
const formErrors = ref<{ listName?: string }>({});
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
const listNameInput = ref<InstanceType<typeof VInput> | null>(null);
|
const listNameInput = ref<any>(null);
|
||||||
// const modalContainerRef = ref<HTMLElement | null>(null); // Removed
|
|
||||||
|
|
||||||
const groupOptionsForSelect = computed(() => {
|
const groupOptionsForSelect = computed(() => {
|
||||||
// VSelect's placeholder should work if selectedGroupId is the sentinel value
|
return props.groups ? [{ label: t('shared.none'), value: SENTINEL_NO_GROUP }, ...props.groups] : [];
|
||||||
return props.groups ? props.groups.map(g => ({ label: g.label, value: g.value })) : [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
watch(isOpen, (newVal) => {
|
watch(isOpen, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
// Reset form when opening
|
|
||||||
listName.value = '';
|
listName.value = '';
|
||||||
description.value = '';
|
description.value = '';
|
||||||
// If a single group is passed, pre-select it. Otherwise, default to sentinel
|
|
||||||
if (props.groups && props.groups.length === 1) {
|
if (props.groups && props.groups.length === 1) {
|
||||||
selectedGroupId.value = props.groups[0].value;
|
selectedGroupId.value = props.groups[0].value;
|
||||||
} else {
|
} else {
|
||||||
selectedGroupId.value = SENTINEL_NO_GROUP; // Reset to sentinel
|
selectedGroupId.value = SENTINEL_NO_GROUP;
|
||||||
}
|
}
|
||||||
formErrors.value = {};
|
formErrors.value = {};
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
// listNameInput.value?.focus?.(); // This might still be an issue depending on VInput. Commenting out for now.
|
(listNameInput.value?.$el?.querySelector('input') as HTMLInputElement | undefined)?.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// onClickOutside removed, VModal handles backdrop clicks
|
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
};
|
};
|
||||||
@ -95,7 +100,7 @@ const closeModal = () => {
|
|||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
formErrors.value = {};
|
formErrors.value = {};
|
||||||
if (!listName.value.trim()) {
|
if (!listName.value.trim()) {
|
||||||
formErrors.value.listName = 'Name is required';
|
formErrors.value.listName = t('createListModal.errors.nameRequired');
|
||||||
}
|
}
|
||||||
return Object.keys(formErrors.value).length === 0;
|
return Object.keys(formErrors.value).length === 0;
|
||||||
};
|
};
|
||||||
@ -113,29 +118,19 @@ const onSubmit = async () => {
|
|||||||
};
|
};
|
||||||
const response = await apiClient.post(API_ENDPOINTS.LISTS.BASE, payload);
|
const response = await apiClient.post(API_ENDPOINTS.LISTS.BASE, payload);
|
||||||
|
|
||||||
notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
|
notificationStore.addNotification({
|
||||||
|
message: t('createListModal.notifications.createSuccess'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
emit('created', response.data);
|
emit('created', response.data);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create list';
|
const message = error instanceof Error ? error.message : t('createListModal.notifications.createFailed');
|
||||||
notificationStore.addNotification({ message, type: 'error' });
|
notificationStore.addNotification({ message, type: 'error' });
|
||||||
console.error(message, error);
|
console.error(message, error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.form-error-text {
|
|
||||||
color: var(--danger);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-2 {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
/* from Valerie UI utilities */
|
|
||||||
}
|
|
||||||
</style>
|
|
84
fe/src/components/InviteManager.vue
Normal file
84
fe/src/components/InviteManager.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<Heading :level="3" class="mb-4">{{ t('inviteManager.title', 'Household Invites') }}</Heading>
|
||||||
|
|
||||||
|
<!-- Generate / regenerate button -->
|
||||||
|
<Button :disabled="generating" @click="generateInvite" class="mb-3">
|
||||||
|
<Spinner v-if="generating" class="mr-2" size="sm" />
|
||||||
|
{{ inviteCode ? t('inviteManager.regenerate', 'Regenerate Invite Code') : t('inviteManager.generate',
|
||||||
|
'Generate Invite Code') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Active invite display -->
|
||||||
|
<div v-if="inviteCode" class="mb-2 flex items-center gap-2">
|
||||||
|
<Input :model-value="inviteCode" readonly class="flex-1" />
|
||||||
|
<Button variant="outline" color="secondary" size="sm" :disabled="!clipboardSupported" @click="copyInvite">
|
||||||
|
{{ copied ? t('inviteManager.copied', 'Copied!') : t('inviteManager.copy', 'Copy') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Alert v-if="error" type="error" :message="error" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useClipboard } from '@vueuse/core'
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
import Heading from '@/components/ui/Heading.vue'
|
||||||
|
import Input from '@/components/ui/Input.vue'
|
||||||
|
import Spinner from '@/components/ui/Spinner.vue'
|
||||||
|
import Alert from '@/components/ui/Alert.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const inviteCode = ref<string | null>(null)
|
||||||
|
const generating = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const { copy, copied, isSupported: clipboardSupported } = useClipboard({ legacy: true })
|
||||||
|
|
||||||
|
async function fetchActiveInvite() {
|
||||||
|
if (!props.groupId) return
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(props.groupId)))
|
||||||
|
if (res.data && res.data.code) {
|
||||||
|
inviteCode.value = res.data.code
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// silent – absence of active invite is OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateInvite() {
|
||||||
|
if (!props.groupId) return
|
||||||
|
generating.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(props.groupId)))
|
||||||
|
inviteCode.value = res.data.code
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.response?.data?.detail || err.message || t('inviteManager.generateError', 'Failed to generate invite')
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInvite() {
|
||||||
|
if (!inviteCode.value) return
|
||||||
|
await copy(inviteCode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchActiveInvite)
|
||||||
|
watch(() => props.groupId, fetchActiveInvite)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
41
fe/src/components/QuickChoreAdd.vue
Normal file
41
fe/src/components/QuickChoreAdd.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input v-model="choreName" @keyup.enter="handleAdd" type="text" placeholder="Add a quick chore..."
|
||||||
|
class="flex-1 px-3 py-2 rounded-md border border-gray-300 dark:border-neutral-700 focus:outline-none focus:ring-primary-500 focus:border-primary-500 text-sm bg-white dark:bg-neutral-800 text-gray-900 dark:text-gray-100" />
|
||||||
|
<Button variant="solid" size="sm" :disabled="!choreName || isLoading" @click="handleAdd">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useChoreStore } from '@/stores/choreStore'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const choreStore = useChoreStore()
|
||||||
|
const choreName = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!choreName.value.trim()) return
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
await choreStore.create({
|
||||||
|
name: choreName.value.trim(),
|
||||||
|
description: '',
|
||||||
|
frequency: 'one_time',
|
||||||
|
type: 'personal',
|
||||||
|
custom_interval_days: undefined,
|
||||||
|
next_due_date: new Date().toISOString().split('T')[0],
|
||||||
|
created_by_id: 0, // backend will override
|
||||||
|
} as any)
|
||||||
|
choreName.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
// Optionally handle error
|
||||||
|
console.error('Failed to create quick chore', e)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="social-login-container">
|
<div class="mt-6">
|
||||||
<div class="divider">
|
<div class="relative py-2">
|
||||||
<span>or continue with</span>
|
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div class="w-full border-t border-gray-300 dark:border-neutral-600" />
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-2 bg-white dark:bg-dark text-gray-500">or continue with</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="social-buttons">
|
|
||||||
<button @click="handleGoogleLogin" class="btn btn-social btn-google">
|
<div class="mt-4 grid grid-cols-1 gap-3">
|
||||||
<svg class="icon" viewBox="0 0 24 24">
|
<Button variant="outline" color="neutral" @click="handleGoogleLogin">
|
||||||
|
<svg class="w-5 h-5 mr-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
fill="#4285F4" />
|
fill="#4285F4" />
|
||||||
@ -19,95 +25,21 @@
|
|||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
fill="#EA4335" />
|
fill="#EA4335" />
|
||||||
</svg>
|
</svg>
|
||||||
Continue with Google
|
<span>Continue with Google</span>
|
||||||
</button>
|
</Button>
|
||||||
<!-- <button @click="handleAppleLogin" class="btn btn-social btn-apple">
|
|
||||||
<svg class="icon" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M17.05 20.28c-.98.95-2.05.88-3.08.41-1.09-.47-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.41C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.19 2.31-.89 3.51-.84 1.54.07 2.7.61 3.44 1.57-3.14 1.88-2.29 5.13.22 6.41-.65 1.29-1.51 2.58-2.25 4.03zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
|
||||||
fill="#000" />
|
|
||||||
</svg>
|
|
||||||
Continue with Apple
|
|
||||||
</button> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { API_BASE_URL } from '@/config/api-config';
|
import { API_BASE_URL } from '@/config/api-config';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
window.location.href = `${API_BASE_URL}/auth/google/login`;
|
window.location.href = `${API_BASE_URL}/api/v1/auth/google/login`;
|
||||||
};
|
|
||||||
|
|
||||||
const handleAppleLogin = () => {
|
|
||||||
window.location.href = '/auth/apple/login';
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.social-login-container {
|
/* All styles handled by Tailwind utility classes */
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider::before,
|
|
||||||
.divider::after {
|
|
||||||
content: '';
|
|
||||||
flex: 1;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider span {
|
|
||||||
padding: 0 1rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-social {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: var(--bg-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all var(--transition-speed) var(--transition-ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-social:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-google {
|
|
||||||
border-color: #4285F4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-apple {
|
|
||||||
border-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
62
fe/src/components/dashboard/ActivityFeed.vue
Normal file
62
fe/src/components/dashboard/ActivityFeed.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-lg bg-white dark:bg-dark shadow">
|
||||||
|
<h2 class="font-bold p-4 border-b border-gray-200 dark:border-neutral-700">Activity Feed</h2>
|
||||||
|
<div v-if="store.isLoading && store.activities.length === 0" class="p-4 text-center text-gray-500">
|
||||||
|
Loading activity...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="store.error" class="p-4 text-center text-danger">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="store.activities.length > 0" class="divide-y divide-gray-200 dark:divide-neutral-700 px-4">
|
||||||
|
<ActivityItem v-for="activity in store.activities" :key="activity.id" :activity="activity" />
|
||||||
|
<div ref="loadMoreSentinel"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="p-4 text-center text-gray-500">
|
||||||
|
No recent activity.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useIntersectionObserver } from '@vueuse/core'
|
||||||
|
import { useActivityStore } from '@/stores/activityStore'
|
||||||
|
import { useGroupStore } from '@/stores/groupStore'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import ActivityItem from './ActivityItem.vue'
|
||||||
|
|
||||||
|
const store = useActivityStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const loadMoreSentinel = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
store.fetchActivities(groupStore.currentGroupId)
|
||||||
|
store.connectWebSocket(groupStore.currentGroupId, authStore.accessToken)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, (newGroupId, oldGroupId) => {
|
||||||
|
if (oldGroupId && oldGroupId !== newGroupId) {
|
||||||
|
store.disconnectWebSocket()
|
||||||
|
}
|
||||||
|
if (newGroupId) {
|
||||||
|
store.fetchActivities(newGroupId)
|
||||||
|
store.connectWebSocket(newGroupId, authStore.accessToken)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
store.disconnectWebSocket()
|
||||||
|
})
|
||||||
|
|
||||||
|
useIntersectionObserver(
|
||||||
|
loadMoreSentinel,
|
||||||
|
([{ isIntersecting }]) => {
|
||||||
|
if (isIntersecting && groupStore.currentGroupId && store.hasMore && !store.isLoading) {
|
||||||
|
store.fetchActivities(groupStore.currentGroupId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
44
fe/src/components/dashboard/ActivityItem.vue
Normal file
44
fe/src/components/dashboard/ActivityItem.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-start space-x-3 py-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
|
||||||
|
<BaseIcon :name="iconName" class="h-5 w-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm text-gray-800 dark:text-gray-200" v-html="activity.message"></p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<time :datetime="activity.timestamp">{{ formattedTimestamp }}</time>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { type Activity, ActivityEventType } from '@/types/activity'
|
||||||
|
import BaseIcon from '@/components/BaseIcon.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activity: Activity
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formattedTimestamp = computed(() => {
|
||||||
|
return formatDistanceToNow(new Date(props.activity.timestamp), { addSuffix: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconMap: Record<ActivityEventType, string> = {
|
||||||
|
[ActivityEventType.CHORE_COMPLETED]: 'heroicons:check-circle',
|
||||||
|
[ActivityEventType.CHORE_CREATED]: 'heroicons:plus-circle',
|
||||||
|
[ActivityEventType.EXPENSE_CREATED]: 'heroicons:currency-dollar',
|
||||||
|
[ActivityEventType.EXPENSE_SETTLED]: 'heroicons:receipt-percent',
|
||||||
|
[ActivityEventType.ITEM_ADDED]: 'heroicons:shopping-cart',
|
||||||
|
[ActivityEventType.ITEM_COMPLETED]: 'heroicons:check',
|
||||||
|
[ActivityEventType.USER_JOINED_GROUP]: 'heroicons:user-plus',
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
return iconMap[props.activity.event_type] || 'heroicons:question-mark-circle'
|
||||||
|
})
|
||||||
|
</script>
|
54
fe/src/components/dashboard/PersonalStatusCard.vue
Normal file
54
fe/src/components/dashboard/PersonalStatusCard.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-lg bg-white dark:bg-dark shadow p-4">
|
||||||
|
<div v-if="isLoading" class="text-center text-gray-500">
|
||||||
|
Loading your status...
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="h-12 w-12 rounded-full flex items-center justify-center" :class="iconBgColor">
|
||||||
|
<BaseIcon :name="iconName" class="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ nextAction.title }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ nextAction.subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<router-link :to="nextAction.path">
|
||||||
|
<Button variant="solid">{{ nextAction.cta }}</Button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { usePersonalStatus } from '@/composables/usePersonalStatus';
|
||||||
|
import BaseIcon from '@/components/BaseIcon.vue';
|
||||||
|
import Button from '@/components/ui/Button.vue';
|
||||||
|
|
||||||
|
const { nextAction, isLoading } = usePersonalStatus();
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
switch (nextAction.value.type) {
|
||||||
|
case 'chore':
|
||||||
|
return 'heroicons:bell-alert';
|
||||||
|
case 'expense':
|
||||||
|
return 'heroicons:credit-card';
|
||||||
|
default:
|
||||||
|
return 'heroicons:check-circle';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconBgColor = computed(() => {
|
||||||
|
switch (nextAction.value.priority) {
|
||||||
|
case 1:
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 2:
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
default:
|
||||||
|
return 'bg-green-500';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
61
fe/src/components/dashboard/UniversalFAB.vue
Normal file
61
fe/src/components/dashboard/UniversalFAB.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed bottom-4 right-4">
|
||||||
|
<Menu as="div" class="relative inline-block text-left">
|
||||||
|
<div>
|
||||||
|
<MenuButton as="template">
|
||||||
|
<Button variant="solid" class="rounded-full w-14 h-14 flex items-center justify-center shadow-lg">
|
||||||
|
<BaseIcon name="heroicons:plus" class="h-7 w-7 text-white" />
|
||||||
|
</Button>
|
||||||
|
</MenuButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition enter-active-class="transition duration-100 ease-out"
|
||||||
|
enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100"
|
||||||
|
leave-active-class="transition duration-75 ease-in" leave-from-class="transform scale-100 opacity-100"
|
||||||
|
leave-to-class="transform scale-95 opacity-0">
|
||||||
|
<MenuItems
|
||||||
|
class="absolute bottom-16 right-0 w-56 origin-bottom-right rounded-md bg-white dark:bg-dark shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
|
<div class="px-1 py-1">
|
||||||
|
<MenuItem v-for="item in menuItems" :key="item.label" v-slot="{ active }">
|
||||||
|
<button @click="item.action" :class="[
|
||||||
|
active ? 'bg-primary-500 text-white' : 'text-gray-900 dark:text-gray-100',
|
||||||
|
'group flex w-full items-center rounded-md px-2 py-2 text-sm',
|
||||||
|
]">
|
||||||
|
<BaseIcon :name="item.icon"
|
||||||
|
:class="[active ? 'text-white' : 'text-primary-500', 'mr-2 h-5 w-5']" />
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</MenuItems>
|
||||||
|
</transition>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||||
|
import BaseIcon from '@/components/BaseIcon.vue';
|
||||||
|
import Button from '@/components/ui/Button.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: 'Add Expense',
|
||||||
|
icon: 'heroicons:currency-dollar',
|
||||||
|
action: () => router.push('/expenses/new'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Complete Chore',
|
||||||
|
icon: 'heroicons:check-circle',
|
||||||
|
action: () => router.push('/chores'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Add to List',
|
||||||
|
icon: 'heroicons:shopping-cart',
|
||||||
|
action: () => router.push('/lists'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
90
fe/src/components/expenses/ExpenseCreationSheet.vue
Normal file
90
fe/src/components/expenses/ExpenseCreationSheet.vue
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="open" class="z-50">
|
||||||
|
<form @submit.prevent="handleSubmit"
|
||||||
|
class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<Heading :level="3">{{ $t('expenseCreation.title', 'Add Expense') }}</Heading>
|
||||||
|
<Button variant="ghost" size="sm" @click="open = false">
|
||||||
|
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input v-model="form.description" :label="$t('expenseCreation.description', 'Description')" class="mb-3" />
|
||||||
|
<Input type="number" step="0.01" v-model="form.total_amount" :label="$t('expenseCreation.amount', 'Amount')"
|
||||||
|
class="mb-3" />
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 mt-6">
|
||||||
|
<Button variant="ghost" @click.prevent="open = false">{{ $t('common.cancel', 'Cancel') }}</Button>
|
||||||
|
<Button type="submit" :loading="submitting">{{ $t('common.save', 'Save') }}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { Dialog } from '@/components/ui'
|
||||||
|
import { Heading, Input, Button } from '@/components/ui'
|
||||||
|
import BaseIcon from '@/components/BaseIcon.vue'
|
||||||
|
import { useExpenses } from '@/composables/useExpenses'
|
||||||
|
import type { Expense } from '@/types/expense'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface ExpenseForm {
|
||||||
|
description: string
|
||||||
|
total_amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('modelValue', { default: false })
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{ expense?: Expense | null }>()
|
||||||
|
|
||||||
|
const form = reactive<ExpenseForm>({ description: '', total_amount: '' })
|
||||||
|
const isEditing = computed(() => !!props.expense)
|
||||||
|
|
||||||
|
const { createExpense, updateExpense } = useExpenses()
|
||||||
|
|
||||||
|
// Sync form when editing
|
||||||
|
watch(
|
||||||
|
() => props.expense,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
form.description = val.description
|
||||||
|
form.total_amount = val.total_amount
|
||||||
|
} else {
|
||||||
|
form.description = ''
|
||||||
|
form.total_amount = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEditing.value && props.expense) {
|
||||||
|
await updateExpense(props.expense.id, { ...form, version: props.expense.version })
|
||||||
|
} else {
|
||||||
|
await createExpense({
|
||||||
|
...form,
|
||||||
|
currency: 'USD',
|
||||||
|
split_type: 'EQUAL',
|
||||||
|
isRecurring: false,
|
||||||
|
paid_by_user_id: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
open.value = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create expense', e)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Tailwind styling */
|
||||||
|
</style>
|
@ -1,276 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="expense-list">
|
<ul class="space-y-2">
|
||||||
<!-- Show loading state -->
|
<li v-for="expense in expenses" :key="expense.id" class="p-4 rounded bg-white dark:bg-neutral-800 shadow-sm">
|
||||||
<div v-if="loading" class="loading-state">
|
<div class="flex items-start gap-3">
|
||||||
<div class="spinner-border" role="status">
|
<div class="flex-1 cursor-pointer" @click="toggle(expense.id)">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<p class="font-medium leading-tight">{{ expense.description }}</p>
|
||||||
</div>
|
<p class="text-xs text-neutral-500 mt-0.5">
|
||||||
</div>
|
{{ $t('expenseList.paidBy', 'Paid by') }}
|
||||||
|
{{ expense.paid_by_user?.full_name || expense.paid_by_user?.email || '#' +
|
||||||
<!-- Show error message -->
|
expense.paid_by_user_id
|
||||||
<div v-else-if="error" class="alert alert-danger">
|
}} ·
|
||||||
{{ error }}
|
{{ formatCurrency(parseFloat(expense.total_amount), expense.currency) }}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
<!-- Show empty state -->
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
<div v-else-if="!expenses.length" class="empty-state">
|
<Button variant="ghost" size="sm" @click.stop="$emit('edit', expense)">
|
||||||
No expenses found
|
<BaseIcon name="heroicons:pencil-square-20-solid" class="w-4 h-4" />
|
||||||
</div>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" color="danger" @click.stop="$emit('delete', expense)">
|
||||||
<!-- Show expenses -->
|
<BaseIcon name="heroicons:trash-20-solid" class="w-4 h-4" />
|
||||||
<template v-else>
|
</Button>
|
||||||
<div v-for="expense in expenses" :key="expense.id" class="expense-item">
|
</div>
|
||||||
<div class="expense-header">
|
|
||||||
<h3>{{ expense.description }}</h3>
|
|
||||||
<div class="expense-actions">
|
|
||||||
<button
|
|
||||||
@click="$emit('edit', expense)"
|
|
||||||
class="btn btn-sm btn-outline-primary"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="handleDelete(expense.id)"
|
|
||||||
class="btn btn-sm btn-outline-danger"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="expense-details">
|
|
||||||
<div class="amount">
|
|
||||||
<span class="currency">{{ expense.currency }}</span>
|
|
||||||
<span class="value">{{ formatAmount(expense.total_amount) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recurring expense indicator -->
|
|
||||||
<div v-if="expense.isRecurring" class="recurring-indicator">
|
|
||||||
<i class="fas fa-sync-alt"></i>
|
|
||||||
<span>Recurring</span>
|
|
||||||
<div class="recurrence-details" v-if="expense.recurrencePattern">
|
|
||||||
{{ formatRecurrencePattern(expense.recurrencePattern) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="split-info">
|
<!-- Details -->
|
||||||
<span class="split-type">{{ formatSplitType(expense.split_type) }}</span>
|
<div v-if="expandedId === expense.id"
|
||||||
<span class="participants">{{ expense.splits.length }} participants</span>
|
class="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-700 space-y-1">
|
||||||
</div>
|
<div v-for="split in expense.splits" :key="split.id" class="flex items-center text-sm gap-2 flex-wrap">
|
||||||
</div>
|
<span class="text-neutral-600 dark:text-neutral-300">{{ split.user?.full_name || split.user?.email
|
||||||
</div>
|
|| 'User #' + split.user_id }} owes</span>
|
||||||
</template>
|
<span class="font-mono font-semibold">{{ formatCurrency(parseFloat(split.owed_amount),
|
||||||
</div>
|
expense.currency) }}</span>
|
||||||
|
<span v-if="remaining(split) > 0" class="text-xs text-danger">{{ formatCurrency(remaining(split),
|
||||||
|
expense.currency) }} {{ $t('expenseList.remaining', 'left') }}</span>
|
||||||
|
<Button v-if="canSettle(split)" variant="solid" size="sm" class="ml-auto"
|
||||||
|
@click.stop="$emit('settle', { split, expense })">
|
||||||
|
{{ $t('expenseList.settle', 'Settle') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, type PropType } from 'vue'
|
||||||
import type { Expense, RecurrencePattern } from '@/types/expense'
|
import BaseIcon from '@/components/BaseIcon.vue'
|
||||||
import { useExpenses } from '@/composables/useExpenses'
|
import { Button } from '@/components/ui'
|
||||||
|
import type { Expense, ExpenseSplit } from '@/types/expense'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps({
|
||||||
expenses: Expense[]
|
expenses: {
|
||||||
}>()
|
type: Array as PropType<Expense[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'edit', expense: Expense): void
|
(e: 'edit', expense: Expense): void
|
||||||
(e: 'delete', id: string): void
|
(e: 'delete', expense: Expense): void
|
||||||
|
(e: 'settle', payload: { split: ExpenseSplit; expense: Expense }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { deleteExpense, loading, error } = useExpenses()
|
const expandedId = ref<number | null>(null)
|
||||||
|
function toggle(id: number) {
|
||||||
const formatAmount = (amount: string) => {
|
expandedId.value = expandedId.value === id ? null : id
|
||||||
return parseFloat(amount).toFixed(2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSplitType = (type: string) => {
|
function formatCurrency(amount: number, currency: string) {
|
||||||
return type.split('_').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
||||||
).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatRecurrencePattern = (pattern: RecurrencePattern) => {
|
|
||||||
const parts = []
|
|
||||||
|
|
||||||
// Format the type and interval
|
|
||||||
parts.push(`${pattern.interval} ${pattern.type}`)
|
|
||||||
|
|
||||||
// Add days of week for weekly recurrence
|
|
||||||
if (pattern.type === 'weekly' && pattern.daysOfWeek?.length) {
|
|
||||||
const days = pattern.daysOfWeek.map(day => {
|
|
||||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
||||||
return dayNames[day]
|
|
||||||
}).join(', ')
|
|
||||||
parts.push(`on ${days}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add end conditions
|
|
||||||
if (pattern.endDate) {
|
|
||||||
parts.push(`until ${new Date(pattern.endDate).toLocaleDateString()}`)
|
|
||||||
} else if (pattern.maxOccurrences) {
|
|
||||||
parts.push(`for ${pattern.maxOccurrences} occurrences`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (confirm('Are you sure you want to delete this expense?')) {
|
|
||||||
try {
|
try {
|
||||||
await deleteExpense(id)
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
|
||||||
emit('delete', id.toString())
|
} catch {
|
||||||
} catch (err) {
|
return amount.toFixed(2) + ' ' + currency
|
||||||
// Error is already handled by the composable
|
|
||||||
console.error('Failed to delete expense:', err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remaining(split: ExpenseSplit) {
|
||||||
|
const paid = split.settlement_activities.reduce((s, a) => s + parseFloat(a.amount_paid), 0)
|
||||||
|
return parseFloat(split.owed_amount) - paid
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
function canSettle(split: ExpenseSplit) {
|
||||||
|
return authStore.getUser && authStore.getUser.id === split.user_id && remaining(split) > 0
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.expense-list {
|
/* Tailwind covers layout */
|
||||||
display: flex;
|
</style>
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expense-item {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expense-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expense-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expense-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expense-details {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.currency {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recurring-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: #007bff;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recurrence-details {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-info {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-primary {
|
|
||||||
color: #007bff;
|
|
||||||
border-color: #007bff;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-danger {
|
|
||||||
color: #dc3545;
|
|
||||||
border-color: #dc3545;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-state {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: #666;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
color: #721c24;
|
|
||||||
background-color: #f8d7da;
|
|
||||||
border-color: #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-border {
|
|
||||||
display: inline-block;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
border: 0.25em solid currentColor;
|
|
||||||
border-right-color: transparent;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spinner-border 0.75s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner-border {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
|
56
fe/src/components/expenses/ExpenseOverview.vue
Normal file
56
fe/src/components/expenses/ExpenseOverview.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4 rounded-lg bg-white dark:bg-neutral-800 shadow-sm w-full max-w-screen-md mx-auto">
|
||||||
|
<Heading :level="2" class="mb-4">{{ $t('expenseOverview.title', 'Expense Overview') }}</Heading>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50">
|
||||||
|
<span class="text-neutral-500">{{ $t('expenseOverview.totalExpenses', 'Total Expenses') }}</span>
|
||||||
|
<span class="text-lg font-mono font-semibold">{{ formatCurrency(totalExpenses, currency) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-start p-4 rounded bg-neutral-50 dark:bg-neutral-700/50">
|
||||||
|
<span class="text-neutral-500">{{ $t('expenseOverview.myBalance', 'My Balance') }}</span>
|
||||||
|
<span class="text-lg font-mono font-semibold" :class="myBalance < 0 ? 'text-danger' : 'text-success'">{{
|
||||||
|
formatCurrency(myBalance, currency) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Placeholder for chart -->
|
||||||
|
<div class="mt-6 text-center text-neutral-400" v-if="!chartReady">
|
||||||
|
{{ $t('expenseOverview.chartPlaceholder', 'Spending chart will appear here…') }}
|
||||||
|
</div>
|
||||||
|
<canvas v-else ref="chartRef" class="w-full h-64" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { Heading } from '@/components/ui'
|
||||||
|
import { useExpenses } from '@/composables/useExpenses'
|
||||||
|
|
||||||
|
// For future chart integration (e.g., Chart.js or ECharts)
|
||||||
|
const chartRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const chartReady = ref(false)
|
||||||
|
|
||||||
|
const { expenses } = useExpenses()
|
||||||
|
const currency = 'USD'
|
||||||
|
|
||||||
|
const totalExpenses = computed(() => expenses.value.reduce((sum: number, e: any) => sum + parseFloat(e.total_amount), 0))
|
||||||
|
const myBalance = ref(0) // Will be provided by backend balances endpoint later
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, currency: string) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount)
|
||||||
|
} catch {
|
||||||
|
return amount.toFixed(2) + ' ' + currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// TODO: load chart library dynamically & render
|
||||||
|
chartReady.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Tailwind handles styles */
|
||||||
|
</style>
|
42
fe/src/components/expenses/SettlementFlow.vue
Normal file
42
fe/src/components/expenses/SettlementFlow.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="open" class="z-50">
|
||||||
|
<div class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<Heading :level="3" class="mb-4">{{ $t('settlementFlow.title', 'Settle Expense') }}</Heading>
|
||||||
|
|
||||||
|
<!-- Placeholder content -->
|
||||||
|
<p class="text-sm text-neutral-500 mb-6">{{ $t('settlementFlow.placeholder', 'Settlement flow coming soon…')
|
||||||
|
}}</p>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="ghost" @click="open = false">{{ $t('common.close', 'Close') }}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { Dialog } from '@/components/ui'
|
||||||
|
import { Heading, Button } from '@/components/ui'
|
||||||
|
import type { ExpenseSplit, Expense } from '@/types/expense'
|
||||||
|
import { useExpenses } from '@/composables/useExpenses'
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('modelValue', { default: false })
|
||||||
|
const props = defineProps<{ split: ExpenseSplit | null, expense: Expense | null }>()
|
||||||
|
|
||||||
|
const form = reactive<{ amount: string }>({ amount: '' })
|
||||||
|
|
||||||
|
const { settleExpenseSplit } = useExpenses()
|
||||||
|
|
||||||
|
async function handleSettle() {
|
||||||
|
if (!props.split) return
|
||||||
|
await settleExpenseSplit(props.split.id, {
|
||||||
|
expense_split_id: props.split.id,
|
||||||
|
paid_by_user_id: props.split.user_id,
|
||||||
|
amount_paid: form.amount || '0',
|
||||||
|
})
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
@ -1,11 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="neo-item-list-cotainer">
|
<div class="space-y-6">
|
||||||
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
|
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
|
||||||
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
|
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
|
||||||
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
|
<h3 v-if="group.items.length" class="text-lg font-semibold mb-2 px-1">{{ group.categoryName }}</h3>
|
||||||
<draggable :list="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
|
<draggable :list="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
|
||||||
:disabled="!isOnline || supermarktMode" class="neo-item-list" ghost-class="sortable-ghost"
|
:disabled="!isOnline || supermarktMode" class="space-y-1"
|
||||||
drag-class="sortable-drag">
|
ghost-class="opacity-50 bg-neutral-100 dark:bg-neutral-800"
|
||||||
|
drag-class="bg-white dark:bg-neutral-900 shadow-lg">
|
||||||
<template #item="{ element: item }">
|
<template #item="{ element: item }">
|
||||||
<ListItem :item="item" :is-online="isOnline" :category-options="categoryOptions"
|
<ListItem :item="item" :is-online="isOnline" :category-options="categoryOptions"
|
||||||
:supermarkt-mode="supermarktMode" @delete-item="$emit('delete-item', item)"
|
:supermarkt-mode="supermarktMode" @delete-item="$emit('delete-item', item)"
|
||||||
@ -14,26 +15,30 @@
|
|||||||
@save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)"
|
@save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)"
|
||||||
@update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event"
|
@update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event"
|
||||||
@update:editCategoryId="item.editCategoryId = $event"
|
@update:editCategoryId="item.editCategoryId = $event"
|
||||||
@update:priceInput="item.priceInput = $event" />
|
@update:priceInput="item.priceInput = $event" :list="list" />
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New Add Item LI, integrated into the list -->
|
<!-- New Add Item Form, integrated into the list -->
|
||||||
<li class="neo-list-item new-item-input-container" v-show="!supermarktMode">
|
<div v-show="!supermarktMode" class="flex items-center gap-2 pt-2 border-t border-dashed">
|
||||||
<label class="neo-checkbox-label">
|
<div class="flex-shrink-0 text-neutral-400">
|
||||||
<input type="checkbox" disabled />
|
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5" />
|
||||||
<input type="text" class="neo-new-item-input"
|
</div>
|
||||||
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
|
<form @submit.prevent="$emit('add-item')" class="flex-grow flex items-center gap-2">
|
||||||
:value="newItem.name"
|
<Input type="text" class="flex-grow"
|
||||||
@input="$emit('update:newItemName', ($event.target as HTMLInputElement).value)"
|
:placeholder="t('listDetailPage.items.addItemForm.placeholder', 'Add a new item...')"
|
||||||
@keyup.enter="$emit('add-item')" @blur="handleNewItemBlur" @click.stop />
|
ref="itemNameInputRef" :model-value="newItem.name"
|
||||||
<VSelect
|
@update:modelValue="$emit('update:newItemName', $event)" @blur="handleNewItemBlur" />
|
||||||
:model-value="newItem.category_id === null || newItem.category_id === undefined ? '' : newItem.category_id"
|
<Listbox :model-value="newItem.category_id"
|
||||||
@update:modelValue="$emit('update:newItemCategoryId', $event === '' ? null : $event)"
|
@update:modelValue="$emit('update:newItemCategoryId', $event)" class="w-40">
|
||||||
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
|
<!-- Simplified Listbox structure for brevity, assuming it exists -->
|
||||||
</label>
|
</Listbox>
|
||||||
</li>
|
<Button type="submit" size="sm" :disabled="!newItem.name.trim()">{{ t('listDetailPage.buttons.add',
|
||||||
|
'Add')
|
||||||
|
}}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -43,8 +48,14 @@ import type { PropType } from 'vue';
|
|||||||
import draggable from 'vuedraggable';
|
import draggable from 'vuedraggable';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import ListItem from './ListItem.vue';
|
import ListItem from './ListItem.vue';
|
||||||
import VSelect from '@/components/valerie/VSelect.vue';
|
|
||||||
import type { Item } from '@/types/item';
|
import type { Item } from '@/types/item';
|
||||||
|
import type { List } from '@/types/list';
|
||||||
|
|
||||||
|
// New component imports
|
||||||
|
import { Listbox } from '@headlessui/vue'; // This might not be needed if we make a full wrapper
|
||||||
|
import Button from '@/components/ui/Button.vue';
|
||||||
|
import Input from '@/components/ui/Input.vue';
|
||||||
|
import BaseIcon from '@/components/BaseIcon.vue';
|
||||||
|
|
||||||
interface ItemWithUI extends Item {
|
interface ItemWithUI extends Item {
|
||||||
updating: boolean;
|
updating: boolean;
|
||||||
@ -60,6 +71,10 @@ interface ItemWithUI extends Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
list: {
|
||||||
|
type: Object as PropType<List>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
items: {
|
items: {
|
||||||
type: Array as PropType<ItemWithUI[]>,
|
type: Array as PropType<ItemWithUI[]>,
|
||||||
required: true,
|
required: true,
|
||||||
@ -169,87 +184,5 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.neo-checkbox-label {
|
/* All styles removed as they are now handled by Tailwind utility classes */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.neo-item-list-container {
|
|
||||||
border: 3px solid #111;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--light);
|
|
||||||
box-shadow: 6px 6px 0 #111;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 1.2rem;
|
|
||||||
padding-inline: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
background: var(--light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-item-input-container {
|
|
||||||
list-style: none !important;
|
|
||||||
padding-inline: 3rem;
|
|
||||||
padding-bottom: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-item-input-container .neo-checkbox-label {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-new-item-input {
|
|
||||||
all: unset;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #444;
|
|
||||||
padding: 0.2rem 0;
|
|
||||||
border-bottom: 1px dashed #ccc;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-new-item-input:focus {
|
|
||||||
border-bottom-color: var(--secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-new-item-input::placeholder {
|
|
||||||
color: #999;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortable-ghost {
|
|
||||||
opacity: 0.5;
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sortable-drag {
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-group {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-header {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding: 0 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-group.highlight .neo-list-item:not(.is-complete) {
|
|
||||||
background-color: #e6f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-40 {
|
|
||||||
width: 20%;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
@ -1,10 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<li class="neo-list-item"
|
<div class="list-item-wrapper" :class="{ 'is-complete': item.is_complete }">
|
||||||
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync }">
|
<div class="list-item-content">
|
||||||
<div class="neo-item-content">
|
<div class="drag-handle" v-if="isOnline">
|
||||||
<!-- Drag Handle -->
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
<div class="drag-handle" v-if="isOnline && !supermarktMode">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="9" cy="12" r="1"></circle>
|
<circle cx="9" cy="12" r="1"></circle>
|
||||||
<circle cx="9" cy="5" r="1"></circle>
|
<circle cx="9" cy="5" r="1"></circle>
|
||||||
@ -14,484 +12,150 @@
|
|||||||
<circle cx="15" cy="19" r="1"></circle>
|
<circle cx="15" cy="19" r="1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- Content when NOT editing -->
|
|
||||||
<template v-if="!item.isEditing">
|
|
||||||
<label class="neo-checkbox-label" @click.stop>
|
|
||||||
<input type="checkbox" :checked="item.is_complete"
|
|
||||||
@change="$emit('checkbox-change', item, ($event.target as HTMLInputElement).checked)" />
|
|
||||||
<div class="checkbox-content">
|
|
||||||
<div class="item-text-container">
|
|
||||||
<span class="checkbox-text-span"
|
|
||||||
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
|
|
||||||
{{ item.name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
|
|
||||||
|
|
||||||
<!-- User Information -->
|
<div class="item-main-content">
|
||||||
<div class="item-user-info" v-if="item.added_by_user || item.completed_by_user">
|
<VCheckbox :model-value="item.is_complete" @update:model-value="onCheckboxChange" :label="item.name"
|
||||||
<span v-if="item.added_by_user" class="user-badge added-by"
|
:quantity="item.quantity" />
|
||||||
:title="$t('listDetailPage.items.addedByTooltip', { name: item.added_by_user.name })">
|
<div v-if="claimStatus" class="claim-status-badge">
|
||||||
{{ $t('listDetailPage.items.addedBy') }} {{ item.added_by_user.name }}
|
{{ claimStatus }}
|
||||||
</span>
|
|
||||||
<span v-if="item.is_complete && item.completed_by_user" class="user-badge completed-by"
|
|
||||||
:title="$t('listDetailPage.items.completedByTooltip', { name: item.completed_by_user.name })">
|
|
||||||
{{ $t('listDetailPage.items.completedBy') }} {{ item.completed_by_user.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.is_complete" class="neo-price-input">
|
|
||||||
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="onPriceInput"
|
|
||||||
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
|
|
||||||
step="0.01" @blur="$emit('update-price', item)"
|
|
||||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<div class="neo-item-actions" v-if="!supermarktMode">
|
|
||||||
<button class="neo-icon-button neo-edit-button" @click.stop="$emit('start-edit', item)"
|
|
||||||
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
|
|
||||||
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 6h18"></path>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
|
||||||
</path>
|
|
||||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
||||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<!-- Content WHEN editing -->
|
|
||||||
<template v-else>
|
<div class="item-actions">
|
||||||
<div class="inline-edit-form flex-grow flex items-center gap-2">
|
<!-- Claim/Unclaim Buttons -->
|
||||||
<VInput type="text" :model-value="item.editName ?? ''"
|
<VButton v-if="canClaim" variant="outline" size="sm" @click="handleClaim">
|
||||||
@update:modelValue="$emit('update:editName', $event)" required class="flex-grow" size="sm"
|
Claim
|
||||||
@keydown.enter.prevent="$emit('save-edit', item)"
|
</VButton>
|
||||||
@keydown.esc.prevent="$emit('cancel-edit', item)" />
|
<VButton v-if="canUnclaim" variant="outline" size="sm" @click="handleUnclaim">
|
||||||
<VInput type="number" :model-value="item.editQuantity || ''"
|
Unclaim
|
||||||
@update:modelValue="$emit('update:editQuantity', $event)" min="1" class="w-20" size="sm"
|
</VButton>
|
||||||
@keydown.enter.prevent="$emit('save-edit', item)"
|
|
||||||
@keydown.esc.prevent="$emit('cancel-edit', item)" />
|
<VInput v-if="item.is_complete" type="number" :model-value="item.price"
|
||||||
<VSelect :model-value="categoryModel" @update:modelValue="categoryModel = $event"
|
@update:model-value="$emit('update-price', item, $event)" placeholder="Price" class="price-input" />
|
||||||
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
|
<VButton @click="$emit('edit-item', item)" variant="ghost" size="sm" aria-label="Edit Item">
|
||||||
</div>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
<div class="neo-item-actions">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<button class="neo-icon-button neo-save-button" @click.stop="$emit('save-edit', item)"
|
<path d="M12 20h9"></path>
|
||||||
:aria-label="$t('listDetailPage.buttons.saveChanges')">
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
</svg>
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
</VButton>
|
||||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
</div>
|
||||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
|
||||||
<polyline points="7 3 7 8 15 8"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="neo-icon-button neo-cancel-button" @click.stop="$emit('cancel-edit', item)"
|
|
||||||
:aria-label="$t('listDetailPage.buttons.cancel')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
|
||||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
|
||||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
|
|
||||||
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 6h18"></path>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
|
||||||
</path>
|
|
||||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
||||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, defineEmits, computed } from 'vue';
|
import { defineProps, defineEmits, computed } from 'vue';
|
||||||
import type { PropType } from 'vue';
|
import type { PropType } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import type { Item } from '@/types/item';
|
import type { Item } from '@/types/item';
|
||||||
|
import type { List } from '@/types/list';
|
||||||
|
import { useListsStore } from '@/stores/listsStore';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import VCheckbox from '@/components/valerie/VCheckbox.vue';
|
||||||
|
import VButton from '@/components/valerie/VButton.vue';
|
||||||
import VInput from '@/components/valerie/VInput.vue';
|
import VInput from '@/components/valerie/VInput.vue';
|
||||||
import VSelect from '@/components/valerie/VSelect.vue';
|
|
||||||
import { useOfflineStore } from '@/stores/offline';
|
|
||||||
|
|
||||||
interface ItemWithUI extends Item {
|
|
||||||
updating: boolean;
|
|
||||||
deleting: boolean;
|
|
||||||
priceInput: string | number | null;
|
|
||||||
swiped: boolean;
|
|
||||||
isEditing?: boolean;
|
|
||||||
editName?: string;
|
|
||||||
editQuantity?: number | string | null;
|
|
||||||
editCategoryId?: number | null;
|
|
||||||
showFirework?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
type: Object as PropType<ItemWithUI>,
|
type: Object as PropType<Item & { swiped?: boolean; }>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
isOnline: {
|
list: {
|
||||||
type: Boolean,
|
type: Object as PropType<List>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
categoryOptions: {
|
isOnline: Boolean,
|
||||||
type: Array as PropType<{ label: string; value: number | null }[]>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
supermarktMode: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits(['delete-item', 'checkbox-change', 'update-price', 'edit-item']);
|
||||||
'delete-item',
|
|
||||||
'checkbox-change',
|
|
||||||
'update-price',
|
|
||||||
'start-edit',
|
|
||||||
'save-edit',
|
|
||||||
'cancel-edit',
|
|
||||||
'update:editName',
|
|
||||||
'update:editQuantity',
|
|
||||||
'update:editCategoryId',
|
|
||||||
'update:priceInput'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const listsStore = useListsStore();
|
||||||
const offlineStore = useOfflineStore();
|
const authStore = useAuthStore();
|
||||||
|
const currentUser = computed(() => authStore.user);
|
||||||
|
|
||||||
const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
|
const canClaim = computed(() => {
|
||||||
...opt,
|
return props.list.group_id && !props.item.is_complete && !props.item.claimed_by_user_id;
|
||||||
value: opt.value === null ? '' : opt.value
|
|
||||||
})));
|
|
||||||
|
|
||||||
const categoryModel = computed({
|
|
||||||
get: () => props.item.editCategoryId === null || props.item.editCategoryId === undefined ? '' : props.item.editCategoryId,
|
|
||||||
set: (value) => {
|
|
||||||
emit('update:editCategoryId', value === '' ? null : value);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isItemPendingSync = computed(() => {
|
const canUnclaim = computed(() => {
|
||||||
return offlineStore.pendingActions.some(action => {
|
return props.item.claimed_by_user_id === currentUser.value?.id;
|
||||||
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
|
|
||||||
const payload = action.payload as { listId: string; itemId: string };
|
|
||||||
return payload.itemId === String(props.item.id);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPriceInput = (value: string | number) => {
|
const claimStatus = computed(() => {
|
||||||
emit('update:priceInput', value);
|
if (!props.item.claimed_by_user) return '';
|
||||||
}
|
const claimer = props.item.claimed_by_user_id === currentUser.value?.id ? 'You' : props.item.claimed_by_user.name;
|
||||||
|
const time = props.item.claimed_at ? formatDistanceToNow(new Date(props.item.claimed_at), { addSuffix: true }) : '';
|
||||||
|
return `${claimer} claimed this ${time}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClaim = () => {
|
||||||
|
if (!props.list.group_id) return; // Should not happen if button is shown, but good practice
|
||||||
|
listsStore.claimItem(props.item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnclaim = () => {
|
||||||
|
listsStore.unclaimItem(props.item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCheckboxChange = (checked: boolean) => {
|
||||||
|
emit('checkbox-change', props.item, checked);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.neo-list-item {
|
/* Basic styling to make it look acceptable */
|
||||||
padding: 1rem 0;
|
.list-item-wrapper {
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:hover {
|
|
||||||
background-color: #f8f8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.neo-list-item {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-pending-sync {
|
|
||||||
/* You can add specific styling for pending items, e.g., a subtle glow or background */
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:hover .neo-item-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-edit-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-icon-button {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #666;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-icon-button:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-edit-button {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-edit-button:hover {
|
|
||||||
background: #eef7fd;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-delete-button {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-delete-button:hover {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-save-button {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-save-button:hover {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-cancel-button {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-cancel-button:hover {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Checkbox Styles */
|
|
||||||
.neo-checkbox-label {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.8em;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
overflow: hidden;
|
||||||
font-weight: 500;
|
|
||||||
color: #414856;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"] {
|
.list-item-content {
|
||||||
appearance: none;
|
|
||||||
position: relative;
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
outline: none;
|
|
||||||
border: 2px solid #b8c1d1;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]:hover {
|
|
||||||
border-color: var(--secondary);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]::after {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]:checked::after {
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(45deg) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
padding: 0.75rem;
|
||||||
width: 100%;
|
background-color: white;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-text-span {
|
.list-item-delete {
|
||||||
position: relative;
|
display: none;
|
||||||
transition: color 0.4s ease, opacity 0.4s ease;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-text-span::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-completed-static {
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.6;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-price-input {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:hover .neo-price-input {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
cursor: grab;
|
cursor: move;
|
||||||
padding: 0.5rem;
|
color: #9ca3af;
|
||||||
color: #666;
|
margin-right: 0.75rem;
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-list-item:hover .drag-handle {
|
.item-main-content {
|
||||||
opacity: 0.5;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle:hover {
|
.claim-status-badge {
|
||||||
opacity: 1 !important;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User Information Styles */
|
|
||||||
.item-text-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-user-info {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-badge {
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
background: #f3f4f6;
|
margin-left: 2.25rem;
|
||||||
padding: 0.125rem 0.375rem;
|
/* Align under checkbox */
|
||||||
border-radius: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-badge.added-by {
|
.item-actions {
|
||||||
color: #059669;
|
display: flex;
|
||||||
background: #ecfdf5;
|
align-items: center;
|
||||||
border-color: #a7f3d0;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-badge.completed-by {
|
.price-input {
|
||||||
color: #7c3aed;
|
width: 80px;
|
||||||
background: #f3e8ff;
|
}
|
||||||
border-color: #c4b5fd;
|
|
||||||
|
.is-complete .item-main-content {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-complete .VCheckbox :deep(label) {
|
||||||
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
25
fe/src/components/ui/Alert.vue
Normal file
25
fe/src/components/ui/Alert.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="classes" role="alert">
|
||||||
|
<div v-if="message">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ type?: 'info' | 'success' | 'warning' | 'error'; message?: string }>(), {
|
||||||
|
type: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeClassMap: Record<string, string> = {
|
||||||
|
info: 'bg-blue-50 text-blue-800 border-blue-200',
|
||||||
|
success: 'bg-green-50 text-green-800 border-green-200',
|
||||||
|
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
|
||||||
|
error: 'bg-red-50 text-red-800 border-red-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const classes = computed(() => `border p-4 rounded-md ${typeClassMap[props.type]}`);
|
||||||
|
</script>
|
9
fe/src/components/ui/Card.vue
Normal file
9
fe/src/components/ui/Card.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg shadow-sm p-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// A simple card component for content containment.
|
||||||
|
</script>
|
13
fe/src/components/ui/Heading.vue
Normal file
13
fe/src/components/ui/Heading.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="tag" :class="[$attrs.class]">
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
const props = withDefaults(defineProps<{ level?: 1 | 2 | 3 | 4 | 5 | 6 }>(), {
|
||||||
|
level: 2,
|
||||||
|
});
|
||||||
|
const tag = computed(() => `h${props.level}`);
|
||||||
|
</script>
|
40
fe/src/components/ui/Input.vue
Normal file
40
fe/src/components/ui/Input.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label v-if="label" :for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ label
|
||||||
|
}}</label>
|
||||||
|
<input :id="id" v-bind="$attrs" :type="type" :class="inputClasses" v-model="modelValueProxy"
|
||||||
|
:aria-invalid="error ? 'true' : undefined" />
|
||||||
|
<p v-if="error" class="mt-1 text-sm text-danger">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRefs } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: string | number | null
|
||||||
|
label?: string
|
||||||
|
type?: string
|
||||||
|
error?: string | null
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'text',
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const { modelValue } = toRefs(props)
|
||||||
|
const modelValueProxy = computed({
|
||||||
|
get: () => modelValue.value,
|
||||||
|
set: (val) => emit('update:modelValue', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
const base = 'shadow-sm block w-full sm:text-sm rounded-md'
|
||||||
|
const theme = 'border-gray-300 dark:border-neutral-600 dark:bg-neutral-800 focus:ring-primary focus:border-primary'
|
||||||
|
const errorCls = 'border-danger focus:border-danger focus:ring-danger'
|
||||||
|
const inputClasses = computed(() => [base, theme, props.error ? errorCls : ''].join(' '))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
11
fe/src/components/ui/ProgressBar.vue
Normal file
11
fe/src/components/ui/ProgressBar.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full bg-neutral-200 rounded-full h-2">
|
||||||
|
<div class="h-2 rounded-full bg-primary" :style="{ width: `${clampedValue}%` }" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
const props = withDefaults(defineProps<{ value?: number }>(), { value: 0 });
|
||||||
|
const clampedValue = computed(() => Math.max(0, Math.min(100, props.value)));
|
||||||
|
</script>
|
13
fe/src/components/ui/Spinner.vue
Normal file
13
fe/src/components/ui/Spinner.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center" role="status" :aria-label="label">
|
||||||
|
<svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ label?: string; size?: 'sm' | 'md' | 'lg' }>();
|
||||||
|
</script>
|
120
fe/src/components/ui/__tests__/ChoreItem.spec.ts
Normal file
120
fe/src/components/ui/__tests__/ChoreItem.spec.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import ChoreItem from '@/components/ChoreItem.vue';
|
||||||
|
import type { ChoreWithCompletion } from '@/types/chore';
|
||||||
|
|
||||||
|
// Minimal mock for a chore
|
||||||
|
const createMockChore = (overrides: Partial<ChoreWithCompletion> = {}): ChoreWithCompletion => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Chore',
|
||||||
|
description: 'A chore for testing.',
|
||||||
|
is_completed: false,
|
||||||
|
completed_at: null,
|
||||||
|
updating: false,
|
||||||
|
next_due_date: new Date().toISOString(),
|
||||||
|
current_assignment_id: 101,
|
||||||
|
// Base properties
|
||||||
|
group_id: null,
|
||||||
|
created_by_id: 1,
|
||||||
|
frequency: 'daily',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
type: 'personal',
|
||||||
|
assignments: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChoreItem.vue', () => {
|
||||||
|
it('renders chore name and description', () => {
|
||||||
|
const chore = createMockChore();
|
||||||
|
const wrapper = mount(ChoreItem, {
|
||||||
|
props: {
|
||||||
|
chore,
|
||||||
|
timeEntries: [],
|
||||||
|
activeTimer: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Test Chore');
|
||||||
|
expect(wrapper.text()).toContain('A chore for testing.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a line-through style when chore is completed', () => {
|
||||||
|
const chore = createMockChore({ is_completed: true });
|
||||||
|
const wrapper = mount(ChoreItem, {
|
||||||
|
props: {
|
||||||
|
chore,
|
||||||
|
timeEntries: [],
|
||||||
|
activeTimer: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameSpan = wrapper.find('span.line-through');
|
||||||
|
expect(nameSpan.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits "toggle-completion" when the checkbox is clicked', async () => {
|
||||||
|
const chore = createMockChore();
|
||||||
|
const wrapper = mount(ChoreItem, {
|
||||||
|
props: { chore, timeEntries: [], activeTimer: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.find('input[type="checkbox"]').setValue(true);
|
||||||
|
|
||||||
|
expect(wrapper.emitted('toggle-completion')).toHaveLength(1);
|
||||||
|
expect(wrapper.emitted('toggle-completion')![0]).toEqual([chore]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits "edit" when the edit button is clicked', async () => {
|
||||||
|
const chore = createMockChore();
|
||||||
|
const wrapper = mount(ChoreItem, {
|
||||||
|
props: { chore, timeEntries: [], activeTimer: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find button by icon name or a more specific selector if possible
|
||||||
|
await wrapper.find('button[title="Edit"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('edit')).toHaveLength(1);
|
||||||
|
expect(wrapper.emitted('edit')![0]).toEqual([chore]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits "delete" when the delete button is clicked', async () => {
|
||||||
|
const chore = createMockChore();
|
||||||
|
const wrapper = mount(ChoreItem, {
|
||||||
|
props: { chore, timeEntries: [], activeTimer: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.find('button[title="Delete"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('delete')).toHaveLength(1);
|
||||||
|
expect(wrapper.emitted('delete')![0]).toEqual([chore]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows correct timer icon and emits "start-timer"', async () => {
|
||||||
|
const chore = createMockChore();
|
||||||
|
const wrapper = mount(ChoreItem, {
|
||||||
|
props: { chore, timeEntries: [], activeTimer: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const playButton = wrapper.find('button[title="Timer"]');
|
||||||
|
expect(playButton.find('svg[data-icon-name="heroicons:play-20-solid"]').exists()).toBe(true);
|
||||||
|
|
||||||
|
await playButton.trigger('click');
|
||||||
|
expect(wrapper.emitted('start-timer')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows correct timer icon and emits "stop-timer" when timer is active', async () => {
|
||||||
|
const chore = createMockChore();
|
||||||
|
const activeTimer = { id: 99, chore_assignment_id: 101, start_time: new Date().toISOString(), user_id: 1 };
|
||||||
|
const wrapper = mount(ChoreItem, {
|
||||||
|
props: { chore, timeEntries: [], activeTimer },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pauseButton = wrapper.find('button[title="Timer"]');
|
||||||
|
expect(pauseButton.find('svg[data-icon-name="heroicons:pause-20-solid"]').exists()).toBe(true);
|
||||||
|
|
||||||
|
await pauseButton.trigger('click');
|
||||||
|
expect(wrapper.emitted('stop-timer')).toHaveLength(1);
|
||||||
|
expect(wrapper.emitted('stop-timer')![0]).toEqual([chore, activeTimer.id]);
|
||||||
|
});
|
||||||
|
});
|
@ -4,4 +4,10 @@ export { default as Menu } from './Menu.vue'
|
|||||||
export { default as Listbox } from './Listbox.vue'
|
export { default as Listbox } from './Listbox.vue'
|
||||||
export { default as Tabs } from './Tabs.vue'
|
export { default as Tabs } from './Tabs.vue'
|
||||||
export { default as Switch } from './Switch.vue'
|
export { default as Switch } from './Switch.vue'
|
||||||
export { default as TransitionExpand } from './TransitionExpand.vue'
|
export { default as TransitionExpand } from './TransitionExpand.vue'
|
||||||
|
export { default as Input } from './Input.vue'
|
||||||
|
export { default as Heading } from './Heading.vue'
|
||||||
|
export { default as Spinner } from './Spinner.vue'
|
||||||
|
export { default as Alert } from './Alert.vue'
|
||||||
|
export { default as ProgressBar } from './ProgressBar.vue'
|
||||||
|
export { default as Card } from './Card.vue'
|
@ -1,5 +1,5 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Expense } from '@/types/expense'
|
import type { Expense, SettlementActivityCreate } from '@/types/expense'
|
||||||
import type { CreateExpenseData, UpdateExpenseData } from '@/services/expenseService'
|
import type { CreateExpenseData, UpdateExpenseData } from '@/services/expenseService'
|
||||||
import { expenseService } from '@/services/expenseService'
|
import { expenseService } from '@/services/expenseService'
|
||||||
|
|
||||||
@ -74,6 +74,16 @@ export function useExpenses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settleExpenseSplit = async (expense_split_id: number, activity: SettlementActivityCreate) => {
|
||||||
|
try {
|
||||||
|
await expenseService.settleExpenseSplit(expense_split_id, activity)
|
||||||
|
// refetch or update locally later
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to settle split'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getExpense = async (id: number) => {
|
const getExpense = async (id: number) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@ -96,6 +106,7 @@ export function useExpenses() {
|
|||||||
createExpense,
|
createExpense,
|
||||||
updateExpense,
|
updateExpense,
|
||||||
deleteExpense,
|
deleteExpense,
|
||||||
|
settleExpenseSplit,
|
||||||
getExpense,
|
getExpense,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
24
fe/src/composables/useFairness.ts
Normal file
24
fe/src/composables/useFairness.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-robin assignment helper for chores or other rotating duties.
|
||||||
|
* Keeps internal pointer in reactive `index`.
|
||||||
|
*/
|
||||||
|
export function useFairness<T>() {
|
||||||
|
const members = ref<T[]>([])
|
||||||
|
const index = ref(0)
|
||||||
|
|
||||||
|
function setParticipants(list: T[]) {
|
||||||
|
members.value = list
|
||||||
|
index.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function next(): T | undefined {
|
||||||
|
if (members.value.length === 0) return undefined
|
||||||
|
const member = members.value[index.value] as unknown as T
|
||||||
|
index.value = (index.value + 1) % members.value.length
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
return { members, setParticipants, next }
|
||||||
|
}
|
34
fe/src/composables/useOfflineSync.ts
Normal file
34
fe/src/composables/useOfflineSync.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useOfflineStore } from '@/stores/offline'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that wires components into the global offline queue, automatically
|
||||||
|
* processing pending mutations when the application regains connectivity.
|
||||||
|
*/
|
||||||
|
export function useOfflineSync() {
|
||||||
|
const offlineStore = useOfflineStore()
|
||||||
|
|
||||||
|
const handleOnline = () => {
|
||||||
|
offlineStore.isOnline = true
|
||||||
|
offlineStore.processQueue()
|
||||||
|
}
|
||||||
|
const handleOffline = () => {
|
||||||
|
offlineStore.isOnline = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOnline: offlineStore.isOnline,
|
||||||
|
pendingActions: offlineStore.pendingActions,
|
||||||
|
processQueue: offlineStore.processQueue,
|
||||||
|
}
|
||||||
|
}
|
33
fe/src/composables/useOptimisticUpdates.ts
Normal file
33
fe/src/composables/useOptimisticUpdates.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic optimistic-update helper.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { apply, confirm, rollback } = useOptimisticUpdates()
|
||||||
|
* apply(id, () => { item.done = true }, () => { item.done = false })
|
||||||
|
* await apiCall()
|
||||||
|
* confirm(id) // or rollback(id) on failure
|
||||||
|
*/
|
||||||
|
export function useOptimisticUpdates() {
|
||||||
|
// Map of rollback callbacks keyed by mutation id
|
||||||
|
const pending = reactive(new Map<string, () => void>())
|
||||||
|
|
||||||
|
function apply(id: string, mutate: () => void, rollback: () => void) {
|
||||||
|
if (pending.has(id)) return
|
||||||
|
mutate()
|
||||||
|
pending.set(id, rollback)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(id: string) {
|
||||||
|
pending.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollback(id: string) {
|
||||||
|
const fn = pending.get(id)
|
||||||
|
if (fn) fn()
|
||||||
|
pending.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apply, confirm, rollback }
|
||||||
|
}
|
111
fe/src/composables/usePersonalStatus.ts
Normal file
111
fe/src/composables/usePersonalStatus.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useChoreStore } from '@/stores/choreStore'
|
||||||
|
import { useExpenses } from '@/composables/useExpenses'
|
||||||
|
import { useGroupStore } from '@/stores/groupStore'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import type { Chore } from '@/types/chore'
|
||||||
|
|
||||||
|
interface NextAction {
|
||||||
|
type: 'chore' | 'expense' | 'none';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
cta: string;
|
||||||
|
path: string;
|
||||||
|
priority: number; // 1 = highest
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePersonalStatus() {
|
||||||
|
const choreStore = useChoreStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const { expenses, fetchExpenses } = useExpenses()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const fetchAllData = async (groupId: number) => {
|
||||||
|
isLoading.value = true
|
||||||
|
await Promise.all([
|
||||||
|
choreStore.fetchGroup(groupId),
|
||||||
|
choreStore.fetchPersonal(),
|
||||||
|
fetchExpenses({ group_id: groupId }),
|
||||||
|
])
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
fetchAllData(groupStore.currentGroupId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, (newGroupId) => {
|
||||||
|
if (newGroupId) {
|
||||||
|
fetchAllData(newGroupId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const userChoresWithAssignments = computed(() => {
|
||||||
|
const userId = authStore.user?.id
|
||||||
|
if (!userId) return []
|
||||||
|
|
||||||
|
return choreStore.allChores.flatMap(chore =>
|
||||||
|
chore.assignments
|
||||||
|
.filter(assignment => assignment.assigned_to_user_id === userId)
|
||||||
|
.map(assignment => ({
|
||||||
|
choreName: chore.name,
|
||||||
|
...assignment
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextAction = computed<NextAction>(() => {
|
||||||
|
const now = new Date()
|
||||||
|
// Priority 1: Overdue chores
|
||||||
|
const overdueChoreAssignment = userChoresWithAssignments.value
|
||||||
|
.filter(assignment => new Date(assignment.due_date) < now && !assignment.is_complete)
|
||||||
|
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
|
||||||
|
|
||||||
|
if (overdueChoreAssignment) {
|
||||||
|
return {
|
||||||
|
type: 'chore',
|
||||||
|
title: `Overdue: ${overdueChoreAssignment.choreName}`,
|
||||||
|
subtitle: `Was due on ${new Date(overdueChoreAssignment.due_date).toLocaleDateString()}`,
|
||||||
|
cta: 'View Chore',
|
||||||
|
path: `/chores`,
|
||||||
|
priority: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for expense logic - to be implemented
|
||||||
|
|
||||||
|
// Priority 2: Upcoming chores
|
||||||
|
const upcomingChoreAssignment = userChoresWithAssignments.value
|
||||||
|
.filter(assignment => !assignment.is_complete)
|
||||||
|
.sort((a, b) => new Date(a.due_date).getTime() - new Date(b.due_date).getTime())[0]
|
||||||
|
|
||||||
|
if (upcomingChoreAssignment) {
|
||||||
|
return {
|
||||||
|
type: 'chore',
|
||||||
|
title: `Next up: ${upcomingChoreAssignment.choreName}`,
|
||||||
|
subtitle: `Due on ${new Date(upcomingChoreAssignment.due_date).toLocaleDateString()}`,
|
||||||
|
cta: 'View Chore',
|
||||||
|
path: `/chores`,
|
||||||
|
priority: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'none',
|
||||||
|
title: "You're all caught up!",
|
||||||
|
subtitle: 'No urgent tasks or expenses. Great job!',
|
||||||
|
cta: 'Go to Dashboard',
|
||||||
|
path: '/dashboard',
|
||||||
|
priority: 99,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextAction,
|
||||||
|
isLoading,
|
||||||
|
}
|
||||||
|
}
|
@ -16,6 +16,8 @@ export const API_ENDPOINTS = {
|
|||||||
VERIFY_EMAIL: '/auth/verify',
|
VERIFY_EMAIL: '/auth/verify',
|
||||||
RESET_PASSWORD: '/auth/forgot-password',
|
RESET_PASSWORD: '/auth/forgot-password',
|
||||||
FORGOT_PASSWORD: '/auth/forgot-password',
|
FORGOT_PASSWORD: '/auth/forgot-password',
|
||||||
|
MAGIC_LINK: '/auth/magic-link',
|
||||||
|
MAGIC_LINK_VERIFY: '/auth/magic-link/verify',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
@ -73,6 +75,11 @@ export const API_ENDPOINTS = {
|
|||||||
CHORE_HISTORY: (groupId: string) => `/groups/${groupId}/chores/history`,
|
CHORE_HISTORY: (groupId: string) => `/groups/${groupId}/chores/history`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
ACTIVITY: {
|
||||||
|
GET_BY_GROUP: (groupId: number) => `/groups/${groupId}/activity`,
|
||||||
|
},
|
||||||
|
|
||||||
// Invites
|
// Invites
|
||||||
INVITES: {
|
INVITES: {
|
||||||
BASE: '/invites',
|
BASE: '/invites',
|
||||||
|
@ -1,93 +1,122 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<main class="container page-padding">
|
||||||
<VHeading level="1" :text="$t('accountPage.title')" class="mb-3" />
|
<Heading :level="1" class="mb-3">{{ $t('accountPage.title') }}</Heading>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center">
|
<div v-if="loading" class="text-center">
|
||||||
<VSpinner :label="$t('accountPage.loadingProfile')" />
|
<Spinner :label="$t('accountPage.loadingProfile')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
|
<Alert v-else-if="error" color="danger" class="mb-3">
|
||||||
|
{{ error }}
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VButton variant="danger" size="sm" @click="fetchProfile">{{ $t('accountPage.retryButton') }}</VButton>
|
<Button color="danger" size="sm" @click="fetchProfile">{{ $t('accountPage.retryButton') }}</Button>
|
||||||
</template>
|
</template>
|
||||||
</VAlert>
|
</Alert>
|
||||||
|
|
||||||
<form v-else @submit.prevent="onSubmitProfile">
|
<form v-else @submit.prevent="onSubmitProfile">
|
||||||
<!-- Profile Section -->
|
<!-- Profile Section -->
|
||||||
<VCard class="mb-3">
|
<Card class="mb-3">
|
||||||
<template #header>
|
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
|
||||||
<VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading>
|
<Heading :level="3">{{ $t('accountPage.profileSection.header') }}</Heading>
|
||||||
</template>
|
|
||||||
<div class="flex flex-wrap" style="gap: 1rem;">
|
|
||||||
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
|
|
||||||
<VInput id="profileName" v-model="profile.name" required />
|
|
||||||
</VFormField>
|
|
||||||
<VFormField :label="$t('accountPage.profileSection.emailLabel')" class="flex-grow">
|
|
||||||
<VInput type="email" id="profileEmail" v-model="profile.email" required readonly />
|
|
||||||
</VFormField>
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<VButton type="submit" variant="primary" :disabled="saving">
|
<div class="flex flex-wrap" style="gap: 1rem">
|
||||||
<VSpinner v-if="saving" size="sm" /> {{ $t('accountPage.profileSection.saveButton') }}
|
<div class="flex-grow">
|
||||||
</VButton>
|
<label for="profileName" class="block text-sm font-medium text-gray-700">{{
|
||||||
</template>
|
$t('accountPage.profileSection.nameLabel')
|
||||||
</VCard>
|
}}</label>
|
||||||
|
<Input id="profileName" v-model="profile.name" required class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<label for="profileEmail" class="block text-sm font-medium text-gray-700">{{
|
||||||
|
$t('accountPage.profileSection.emailLabel')
|
||||||
|
}}</label>
|
||||||
|
<Input type="email" id="profileEmail" v-model="profile.email" required readonly class="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 bg-gray-50 sm:px-6">
|
||||||
|
<Button type="submit" color="primary" :disabled="saving">
|
||||||
|
<Spinner v-if="saving" size="sm" class="mr-1" />
|
||||||
|
{{ $t('accountPage.profileSection.saveButton') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Password Section -->
|
<!-- Password Section -->
|
||||||
<form @submit.prevent="onChangePassword">
|
<form @submit.prevent="onChangePassword">
|
||||||
<VCard class="mb-3">
|
<Card class="mb-3">
|
||||||
<template #header>
|
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
|
||||||
<VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading>
|
<Heading :level="3">{{ $t('accountPage.passwordSection.header') }}</Heading>
|
||||||
</template>
|
|
||||||
<div class="flex flex-wrap" style="gap: 1rem;">
|
|
||||||
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
|
|
||||||
<VInput type="password" id="currentPassword" v-model="password.current" required />
|
|
||||||
</VFormField>
|
|
||||||
<VFormField :label="$t('accountPage.passwordSection.newPasswordLabel')" class="flex-grow">
|
|
||||||
<VInput type="password" id="newPassword" v-model="password.newPassword" required />
|
|
||||||
</VFormField>
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<VButton type="submit" variant="primary" :disabled="changingPassword">
|
<div class="flex flex-wrap" style="gap: 1rem">
|
||||||
<VSpinner v-if="changingPassword" size="sm" /> {{ $t('accountPage.passwordSection.changeButton') }}
|
<div class="flex-grow">
|
||||||
</VButton>
|
<label for="currentPassword" class="block text-sm font-medium text-gray-700">{{
|
||||||
</template>
|
$t('accountPage.passwordSection.currentPasswordLabel')
|
||||||
</VCard>
|
}}</label>
|
||||||
|
<Input type="password" id="currentPassword" v-model="password.current" required class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow">
|
||||||
|
<label for="newPassword" class="block text-sm font-medium text-gray-700">{{
|
||||||
|
$t('accountPage.passwordSection.newPasswordLabel')
|
||||||
|
}}</label>
|
||||||
|
<Input type="password" id="newPassword" v-model="password.newPassword" required class="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 bg-gray-50 sm:px-6">
|
||||||
|
<Button type="submit" color="primary" :disabled="changingPassword">
|
||||||
|
<Spinner v-if="changingPassword" size="sm" class="mr-1" />
|
||||||
|
{{ $t('accountPage.passwordSection.changeButton') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Notifications Section -->
|
<!-- Notifications Section -->
|
||||||
<VCard>
|
<Card>
|
||||||
<template #header>
|
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
|
||||||
<VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading>
|
<Heading :level="3">{{ $t('accountPage.notificationsSection.header') }}</Heading>
|
||||||
</template>
|
</div>
|
||||||
<VList class="preference-list">
|
<ul class="divide-y divide-gray-200">
|
||||||
<VListItem class="preference-item">
|
<li class="flex items-center justify-between p-4">
|
||||||
<div class="preference-label">
|
<div class="flex flex-col">
|
||||||
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
|
<span class="font-medium text-gray-900">{{
|
||||||
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
|
$t('accountPage.notificationsSection.emailNotificationsLabel')
|
||||||
|
}}</span>
|
||||||
|
<small class="text-sm text-gray-500">{{
|
||||||
|
$t('accountPage.notificationsSection.emailNotificationsDescription')
|
||||||
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange"
|
<Switch v-model="preferences.emailNotifications" @change="onPreferenceChange"
|
||||||
:label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
|
:label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
|
||||||
</VListItem>
|
</li>
|
||||||
<VListItem class="preference-item">
|
<li class="flex items-center justify-between p-4">
|
||||||
<div class="preference-label">
|
<div class="flex flex-col">
|
||||||
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
|
<span class="font-medium text-gray-900">{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
|
||||||
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
|
<small class="text-sm text-gray-500">{{
|
||||||
|
$t('accountPage.notificationsSection.listUpdatesDescription')
|
||||||
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange"
|
<Switch v-model="preferences.listUpdates" @change="onPreferenceChange"
|
||||||
:label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle" />
|
:label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle" />
|
||||||
</VListItem>
|
</li>
|
||||||
<VListItem class="preference-item">
|
<li class="flex items-center justify-between p-4">
|
||||||
<div class="preference-label">
|
<div class="flex flex-col">
|
||||||
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
|
<span class="font-medium text-gray-900">{{
|
||||||
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
|
$t('accountPage.notificationsSection.groupActivitiesLabel')
|
||||||
|
}}</span>
|
||||||
|
<small class="text-sm text-gray-500">{{
|
||||||
|
$t('accountPage.notificationsSection.groupActivitiesDescription')
|
||||||
|
}}</small>
|
||||||
</div>
|
</div>
|
||||||
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange"
|
<Switch v-model="preferences.groupActivities" @change="onPreferenceChange"
|
||||||
:label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle" />
|
:label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle" />
|
||||||
</VListItem>
|
</li>
|
||||||
</VList>
|
</ul>
|
||||||
</VCard>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -97,16 +126,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming path
|
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming path
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import VHeading from '@/components/valerie/VHeading.vue';
|
import { Heading, Spinner, Alert, Card, Input, Button, Switch } from '@/components/ui';
|
||||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
|
||||||
import VAlert from '@/components/valerie/VAlert.vue';
|
|
||||||
import VCard from '@/components/valerie/VCard.vue';
|
|
||||||
import VFormField from '@/components/valerie/VFormField.vue';
|
|
||||||
import VInput from '@/components/valerie/VInput.vue';
|
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
|
||||||
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
|
|
||||||
import VList from '@/components/valerie/VList.vue';
|
|
||||||
import VListItem from '@/components/valerie/VListItem.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
24
fe/src/pages/DashboardPage.vue
Normal file
24
fe/src/pages/DashboardPage.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<PersonalStatusCard />
|
||||||
|
<QuickChoreAdd />
|
||||||
|
<ActivityFeed />
|
||||||
|
<UniversalFAB />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useGroupStore } from '@/stores/groupStore';
|
||||||
|
import PersonalStatusCard from '@/components/dashboard/PersonalStatusCard.vue';
|
||||||
|
import ActivityFeed from '@/components/dashboard/ActivityFeed.vue';
|
||||||
|
import UniversalFAB from '@/components/dashboard/UniversalFAB.vue';
|
||||||
|
import QuickChoreAdd from '@/components/QuickChoreAdd.vue';
|
||||||
|
|
||||||
|
const groupStore = useGroupStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
groupStore.fetchUserGroups();
|
||||||
|
});
|
||||||
|
</script>
|
@ -1,829 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<main class="p-4 max-w-screen-md mx-auto space-y-6">
|
||||||
<header v-if="!props.groupId" class="flex justify-between items-center">
|
<!-- Overview -->
|
||||||
<h1 style="margin-block-start: 0;">Expenses</h1>
|
<ExpenseOverview />
|
||||||
<button @click="openCreateExpenseModal" class="btn btn-primary">
|
|
||||||
Add Expense
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="mb-4 flex items-center gap-2 justify-between" v-if="!loading && !error">
|
<!-- Controls -->
|
||||||
<div class="text-sm font-medium" v-if="authStore.getUser">
|
<div class="flex justify-end">
|
||||||
Your outstanding balance: <span class="font-mono">{{ formatCurrency(userOutstanding, 'USD') }}</span>
|
<Button variant="solid" @click="showCreate = true">
|
||||||
</div>
|
<BaseIcon name="heroicons:plus-20-solid" class="w-5 h-5 mr-1" />
|
||||||
<label class="flex items-center text-sm"><input type="checkbox" v-model="showRecurringOnly"
|
{{ $t('expensePage.addExpense', 'Add Expense') }}
|
||||||
class="mr-2">Show recurring only</label>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center">
|
<!-- Error & Loading -->
|
||||||
<div class="spinner-dots">
|
<Spinner v-if="loading" :label="$t('expensePage.loading', 'Loading expenses')" />
|
||||||
<span></span>
|
<Alert v-else-if="error" type="error" :message="error" />
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
|
|
||||||
role="alert">
|
|
||||||
<strong class="font-bold">Error:</strong>
|
|
||||||
<span class="block sm:inline">{{ error }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="filteredExpenses.length === 0" class="empty-state-card">
|
<!-- Expense List -->
|
||||||
<h3>No Expenses Yet</h3>
|
<ExpenseList v-else :expenses="expenses" @edit="openEdit" @delete="confirmDelete" @settle="openSettle" />
|
||||||
<p>Get started by adding your first expense!</p>
|
|
||||||
<button class="btn btn-primary" @click="openCreateExpenseModal">
|
|
||||||
Add First Expense
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="schedule-list">
|
<!-- Sheets -->
|
||||||
<div v-for="group in groupedExpenses" :key="group.title" class="schedule-group">
|
<ExpenseCreationSheet v-model="showCreate" :expense="editingExpense" />
|
||||||
<h2 class="date-header">{{ group.title }}</h2>
|
<SettlementFlow v-model="showSettlement" :split="selectedSplit" :expense="selectedExpense" />
|
||||||
<div class="neo-item-list-container">
|
</main>
|
||||||
<ul class="neo-item-list">
|
|
||||||
<li v-for="expense in group.expenses" :key="expense.id" class="neo-list-item"
|
|
||||||
:class="{ 'is-expanded': expandedExpenseId === expense.id }">
|
|
||||||
<div class="neo-item-content">
|
|
||||||
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
|
|
||||||
<span class="checkbox-text-span">{{ expense.description }}</span>
|
|
||||||
<span v-if="isExpenseRecurring(expense)"
|
|
||||||
class="ml-2 inline-block rounded-full bg-indigo-100 text-indigo-700 px-2 py-0.5 text-xs font-semibold">Recurring
|
|
||||||
<template v-if="getNextOccurrence(expense)">
|
|
||||||
– next {{ getNextOccurrence(expense) }}
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
<div class="item-subtext">
|
|
||||||
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
|
|
||||||
'N/A'
|
|
||||||
}}
|
|
||||||
· {{ formatCurrency(expense.total_amount, expense.currency) }}
|
|
||||||
<span :class="getStatusClass(expense.overall_settlement_status)"
|
|
||||||
class="status-badge">
|
|
||||||
{{ expense.overall_settlement_status.replace('_', ' ') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="neo-item-actions">
|
|
||||||
<button @click.stop="openEditExpenseModal(expense)"
|
|
||||||
class="btn btn-sm btn-neutral">Edit</button>
|
|
||||||
<button @click.stop="handleDeleteExpense(expense.id)"
|
|
||||||
class="btn btn-sm btn-danger">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="expandedExpenseId === expense.id"
|
|
||||||
class="w-full mt-2 pt-2 border-t border-gray-200/50 expanded-details">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-700 mb-2 text-sm">Splits ({{
|
|
||||||
expense.split_type.replace('_', ' ') }})</h3>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li v-for="split in expense.splits" :key="split.id"
|
|
||||||
class="flex justify-between items-center py-1 text-sm gap-2 flex-wrap">
|
|
||||||
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|
|
||||||
|| 'N/A' }} owes</span>
|
|
||||||
<span class="font-mono text-gray-800 font-semibold">{{
|
|
||||||
formatCurrency(split.owed_amount, expense.currency) }}</span>
|
|
||||||
|
|
||||||
<!-- Settlement progress -->
|
|
||||||
<span
|
|
||||||
v-if="split.settlement_activities && split.settlement_activities.length"
|
|
||||||
class="text-xs text-gray-500">Paid: {{
|
|
||||||
formatCurrency(calculatePaidAmount(split), expense.currency) }}</span>
|
|
||||||
<span v-if="calculateRemainingAmount(split) > 0"
|
|
||||||
class="text-xs text-red-600">Remaining: {{
|
|
||||||
formatCurrency(calculateRemainingAmount(split), expense.currency)
|
|
||||||
}}</span>
|
|
||||||
|
|
||||||
<!-- Settle button for current user -->
|
|
||||||
<button
|
|
||||||
v-if="authStore.getUser && authStore.getUser.id === split.user_id && calculateRemainingAmount(split) > 0"
|
|
||||||
@click.stop="handleSettleSplit(split, expense)"
|
|
||||||
class="ml-auto btn btn-sm btn-primary">Settle</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create/Edit Expense Modal -->
|
|
||||||
<div v-if="showModal" class="modal-backdrop open" @click.self="closeModal">
|
|
||||||
<div class="modal-container">
|
|
||||||
<form @submit.prevent="handleFormSubmit">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>{{ editingExpense ? 'Edit Expense' : 'Create New Expense' }}</h3>
|
|
||||||
<button type="button" @click="closeModal" class="close-button">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="description">Description</label>
|
|
||||||
<input type="text" v-model="formState.description" id="description" class="form-input"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="total_amount" class="form-label">Total Amount</label>
|
|
||||||
<input type="number" step="0.01" min="0.01" v-model="formState.total_amount"
|
|
||||||
id="total_amount" class="form-input" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="currency" class="form-label">Currency</label>
|
|
||||||
<input type="text" v-model="formState.currency" id="currency" class="form-input"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="paid_by_user_id" class="form-label">Paid By (User ID)</label>
|
|
||||||
<input type="number" v-model="formState.paid_by_user_id" id="paid_by_user_id"
|
|
||||||
class="form-input" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="split_type" class="form-label">Split Type</label>
|
|
||||||
<select v-model="formState.split_type" id="split_type" class="form-input" required>
|
|
||||||
<option value="EQUAL">Equal</option>
|
|
||||||
<option value="EXACT_AMOUNTS">Exact Amounts</option>
|
|
||||||
<option value="PERCENTAGE">Percentage</option>
|
|
||||||
<option value="SHARES">Shares</option>
|
|
||||||
<option value="ITEM_BASED">Item Based</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="group_id" class="form-label">Group ID (optional)</label>
|
|
||||||
<input type="number" v-model="formState.group_id" id="group_id" class="form-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="list_id" class="form-label">List ID (optional)</label>
|
|
||||||
<input type="number" v-model="formState.list_id" id="list_id" class="form-input">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group flex items-center mt-4">
|
|
||||||
<input type="checkbox" v-model="formState.isRecurring" id="is_recurring"
|
|
||||||
class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
|
||||||
<label for="is_recurring" class="ml-2 block text-sm text-gray-900">This is a
|
|
||||||
recurring expense</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Placeholder for recurring pattern form -->
|
|
||||||
<div v-if="formState.isRecurring" class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
|
|
||||||
<p class="text-sm text-gray-500">Recurring expense options will be shown here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Placeholder for splits input form -->
|
|
||||||
<div v-if="formState.split_type === 'EXACT_AMOUNTS' || formState.split_type === 'PERCENTAGE' || formState.split_type === 'SHARES'"
|
|
||||||
class="md:col-span-2 p-4 bg-gray-50 rounded-md mt-2">
|
|
||||||
<p class="text-sm text-gray-500">Inputs for {{ formState.split_type }} splits
|
|
||||||
will be shown here.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="formError" class="mt-3 bg-red-100 border-l-4 border-red-500 text-red-700 p-3">
|
|
||||||
<p>{{ formError }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" @click="closeModal" class="btn btn-neutral">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
{{ editingExpense ? 'Update Expense' : 'Create Expense' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive, computed } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
|
import { useExpenses } from '@/composables/useExpenses'
|
||||||
import { apiClient } from '@/services/api'
|
import { Button, Spinner, Alert } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import BaseIcon from '@/components/BaseIcon.vue'
|
||||||
import { useNotificationStore } from '@/stores/notifications'
|
import ExpenseOverview from '@/components/expenses/ExpenseOverview.vue'
|
||||||
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
|
import ExpenseList from '@/components/expenses/ExpenseList.vue'
|
||||||
|
import ExpenseCreationSheet from '@/components/expenses/ExpenseCreationSheet.vue'
|
||||||
|
import SettlementFlow from '@/components/expenses/SettlementFlow.vue'
|
||||||
|
import type { Expense } from '@/types/expense'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ groupId?: number | string }>()
|
||||||
groupId?: number | string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Pinia store for current user context
|
const showCreate = ref(false)
|
||||||
const authStore = useAuthStore()
|
const showSettlement = ref(false)
|
||||||
const notifStore = useNotificationStore()
|
|
||||||
|
|
||||||
// Reactive state collections
|
|
||||||
const expenses = ref<Expense[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const expandedExpenseId = ref<number | null>(null)
|
|
||||||
const showModal = ref(false)
|
|
||||||
const editingExpense = ref<Expense | null>(null)
|
const editingExpense = ref<Expense | null>(null)
|
||||||
const formError = ref<string | null>(null)
|
const selectedSplit = ref<any | null>(null)
|
||||||
|
const selectedExpense = ref<Expense | null>(null)
|
||||||
|
|
||||||
// UI-level filters
|
const { expenses, loading, error, fetchExpenses, deleteExpense } = useExpenses()
|
||||||
const showRecurringOnly = ref(false)
|
|
||||||
|
|
||||||
// Aggregate outstanding balance for current user across expenses
|
onMounted(() => {
|
||||||
const userOutstanding = computed(() => {
|
load()
|
||||||
const userId = authStore.getUser?.id
|
|
||||||
if (!userId) return 0
|
|
||||||
let remaining = 0
|
|
||||||
expenses.value.forEach((exp) => {
|
|
||||||
exp.splits.forEach((sp) => {
|
|
||||||
if (sp.user_id === userId) {
|
|
||||||
remaining += calculateRemainingAmount(sp)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return remaining
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const initialFormState: CreateExpenseData = {
|
watch(
|
||||||
description: '',
|
() => props.groupId,
|
||||||
total_amount: '',
|
() => load(),
|
||||||
currency: 'USD',
|
)
|
||||||
split_type: 'EQUAL',
|
|
||||||
isRecurring: false,
|
async function load() {
|
||||||
paid_by_user_id: 0, // Should be current user id by default
|
await fetchExpenses({ group_id: props.groupId ? Number(props.groupId) : undefined })
|
||||||
list_id: undefined,
|
|
||||||
group_id: undefined,
|
|
||||||
splits_in: [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formState = reactive<any>({ ...initialFormState })
|
function openEdit(expense: Expense) {
|
||||||
|
|
||||||
const filteredExpenses = computed(() => {
|
|
||||||
let data = expenses.value
|
|
||||||
if (props.groupId) {
|
|
||||||
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
|
|
||||||
data = data.filter((expense) => expense.group_id === groupIdNum)
|
|
||||||
}
|
|
||||||
if (showRecurringOnly.value) {
|
|
||||||
data = data.filter((expense) => (expense as any).isRecurring || (expense as any).is_recurring)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
|
||||||
} catch (err: any) {
|
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch expenses.'
|
|
||||||
console.error(err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const groupedExpenses = computed(() => {
|
|
||||||
if (!filteredExpenses.value) return [];
|
|
||||||
const expensesByDate = filteredExpenses.value.reduce((acc, expense) => {
|
|
||||||
const dateKey = expense.expense_date ? new Date(expense.expense_date).toISOString().split('T')[0] : 'nodate';
|
|
||||||
if (!acc[dateKey]) {
|
|
||||||
acc[dateKey] = [];
|
|
||||||
}
|
|
||||||
acc[dateKey].push(expense);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, Expense[]>);
|
|
||||||
|
|
||||||
return Object.keys(expensesByDate)
|
|
||||||
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
|
|
||||||
.map(dateStr => {
|
|
||||||
const date = dateStr === 'nodate' ? null : new Date(dateStr);
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
title: date ? formatDateHeader(date) : 'No Date',
|
|
||||||
expenses: expensesByDate[dateStr]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleExpenseDetails = (expenseId: number) => {
|
|
||||||
expandedExpenseId.value = expandedExpenseId.value === expenseId ? null : expenseId
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateHeader = (date: Date) => {
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
const itemDate = new Date(date)
|
|
||||||
itemDate.setHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
const isToday = itemDate.getTime() === today.getTime()
|
|
||||||
|
|
||||||
if (isToday) {
|
|
||||||
return `Today, ${new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate)}`
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' }).format(itemDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString?: string | Date) => {
|
|
||||||
if (!dateString) return 'N/A'
|
|
||||||
return new Date(dateString).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric', month: 'long', day: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCurrency = (amount: string | number, currency: string = 'USD') => {
|
|
||||||
const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
|
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(numericAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusClass = (status: string) => {
|
|
||||||
const statusMap: Record<string, string> = {
|
|
||||||
unpaid: 'status-overdue',
|
|
||||||
partially_paid: 'status-due-today',
|
|
||||||
paid: 'status-completed',
|
|
||||||
}
|
|
||||||
return statusMap[status] || 'bg-gray-100 text-gray-800'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getNextOccurrence = (expense: Expense): string | null => {
|
|
||||||
const raw = (expense as any).next_occurrence ?? (expense as any).nextOccurrence ?? null
|
|
||||||
return raw ? formatDate(raw) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpenseRecurring = (expense: Expense): boolean => {
|
|
||||||
return Boolean((expense as any).isRecurring ?? (expense as any).is_recurring)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreateExpenseModal = () => {
|
|
||||||
editingExpense.value = null
|
|
||||||
Object.assign(formState, initialFormState)
|
|
||||||
if (props.groupId) {
|
|
||||||
formState.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
|
|
||||||
}
|
|
||||||
// TODO: Set formState.paid_by_user_id to current user's ID
|
|
||||||
// TODO: Fetch users/groups/lists for dropdowns
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditExpenseModal = (expense: Expense) => {
|
|
||||||
editingExpense.value = expense
|
editingExpense.value = expense
|
||||||
// Map snake_case from Expense to camelCase for CreateExpenseData/UpdateExpenseData
|
showCreate.value = true
|
||||||
formState.description = expense.description
|
|
||||||
formState.total_amount = expense.total_amount
|
|
||||||
formState.currency = expense.currency
|
|
||||||
formState.split_type = expense.split_type
|
|
||||||
formState.isRecurring = (expense as any).is_recurring ?? (expense as any).isRecurring ?? false
|
|
||||||
formState.list_id = expense.list_id
|
|
||||||
formState.group_id = expense.group_id
|
|
||||||
formState.item_id = expense.item_id
|
|
||||||
formState.paid_by_user_id = expense.paid_by_user_id
|
|
||||||
formState.version = expense.version
|
|
||||||
// recurrencePattern and splits_in would need more complex mapping
|
|
||||||
showModal.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeModal = () => {
|
watch(showCreate, val => {
|
||||||
showModal.value = false
|
if (!val) editingExpense.value = null
|
||||||
editingExpense.value = null
|
})
|
||||||
formError.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFormSubmit = async () => {
|
async function confirmDelete(expense: Expense) {
|
||||||
formError.value = null
|
if (confirm('Delete this expense?')) {
|
||||||
|
await deleteExpense(expense.id)
|
||||||
const data: any = { ...formState }
|
|
||||||
if (data.list_id === '' || data.list_id === null) data.list_id = undefined
|
|
||||||
if (data.group_id === '' || data.group_id === null) data.group_id = undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (editingExpense.value) {
|
|
||||||
const updateData: UpdateExpenseData = {
|
|
||||||
...data,
|
|
||||||
version: editingExpense.value.version,
|
|
||||||
}
|
|
||||||
const updatedExpense = (await expenseService.updateExpense(editingExpense.value.id, updateData)) as any as Expense;
|
|
||||||
const index = expenses.value.findIndex(e => e.id === updatedExpense.id)
|
|
||||||
if (index !== -1) {
|
|
||||||
expenses.value[index] = updatedExpense
|
|
||||||
}
|
|
||||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
|
||||||
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
|
|
||||||
} else {
|
|
||||||
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
|
|
||||||
expenses.value.unshift(newExpense)
|
|
||||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
|
||||||
notifStore.addNotification({ message: 'Expense created', type: 'success' })
|
|
||||||
}
|
|
||||||
closeModal()
|
|
||||||
} catch (err: any) {
|
|
||||||
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
|
|
||||||
console.error(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteExpense = async (expenseId: number) => {
|
function toggleDetails(expense: Expense) {
|
||||||
if (!confirm('Are you sure you want to delete this expense? This action cannot be undone.')) return
|
// TODO: open details expanded row or dialog
|
||||||
try {
|
console.debug('details', expense)
|
||||||
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
|
|
||||||
await expenseService.deleteExpense(expenseId)
|
|
||||||
expenses.value = expenses.value.filter(e => e.id !== expenseId)
|
|
||||||
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
|
|
||||||
} catch (err: any) {
|
|
||||||
error.value = err.response?.data?.detail || 'Failed to delete expense.'
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------
|
function openSettle(payload: { split: any; expense: Expense }) {
|
||||||
// Settlement-related helpers
|
selectedSplit.value = payload.split
|
||||||
// -----------------------------
|
selectedExpense.value = payload.expense
|
||||||
const calculatePaidAmount = (split: ExpenseSplit): number =>
|
showSettlement.value = true
|
||||||
(split.settlement_activities || []).reduce((sum: number, act: SettlementActivity) => sum + parseFloat(act.amount_paid), 0)
|
|
||||||
|
|
||||||
const calculateRemainingAmount = (split: ExpenseSplit): number =>
|
|
||||||
parseFloat(split.owed_amount) - calculatePaidAmount(split)
|
|
||||||
|
|
||||||
const handleSettleSplit = async (split: ExpenseSplit, parentExpense: Expense) => {
|
|
||||||
if (!authStore.getUser?.id) {
|
|
||||||
alert('You need to be logged in to settle an expense.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const remaining = calculateRemainingAmount(split)
|
|
||||||
if (remaining <= 0) return
|
|
||||||
|
|
||||||
if (!confirm(`Settle ${formatCurrency(remaining, parentExpense.currency)} now?`)) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiClient.post(`/financials/expense_splits/${split.id}/settle`, {
|
|
||||||
expense_split_id: split.id,
|
|
||||||
paid_by_user_id: authStore.getUser.id,
|
|
||||||
amount_paid: remaining.toFixed(2),
|
|
||||||
})
|
|
||||||
|
|
||||||
// refresh expense list to get updated data
|
|
||||||
expenses.value = (await expenseService.getExpenses()) as Expense[]
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to settle split', err)
|
|
||||||
alert(err.response?.data?.detail || 'Failed to settle split.')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped></style>
|
||||||
.container {
|
|
||||||
padding: 1rem;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-list {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-group {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-header {
|
|
||||||
font-size: clamp(1rem, 4vw, 1.2rem);
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--dark);
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 2px solid var(--dark);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background-color: var(--light);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-list-container {
|
|
||||||
border: 3px solid #111;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--light);
|
|
||||||
box-shadow: 6px 6px 0 #111;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
|
||||||
position: relative;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:hover {
|
|
||||||
background-color: #f8f8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
margin-left: auto;
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-list-item:hover .neo-item-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-text-span {
|
|
||||||
position: relative;
|
|
||||||
transition: color 0.4s ease, opacity 0.4s ease;
|
|
||||||
width: fit-content;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--dark);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-subtext {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.1rem 0.5rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: capitalize;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-unpaid {
|
|
||||||
background-color: #fef2f2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-partially_paid {
|
|
||||||
background-color: #fffbeb;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-paid {
|
|
||||||
background-color: #f0fdf4;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-expanded {
|
|
||||||
.expanded-details {
|
|
||||||
max-height: 500px;
|
|
||||||
/* or a suitable value */
|
|
||||||
transition: max-height 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-details {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
/* Indent details */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop.open {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-container {
|
|
||||||
background-color: white;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
width: 90%;
|
|
||||||
max-width: 600px;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop.open .modal-container {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input,
|
|
||||||
select.form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: #4f46e5;
|
|
||||||
color: white;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: #4338ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-neutral {
|
|
||||||
background-color: white;
|
|
||||||
color: #374151;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-neutral:hover {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background-color: #dc2626;
|
|
||||||
color: white;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background-color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-dots {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-dots span {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #4f46e5;
|
|
||||||
margin: 0 4px;
|
|
||||||
animation: spinner-grow 1.4s infinite ease-in-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-dots span:nth-child(1) {
|
|
||||||
animation-delay: -0.32s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-dots span:nth-child(2) {
|
|
||||||
animation-delay: -0.16s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner-grow {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
80%,
|
|
||||||
100% {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
40% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-card {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1.5rem;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
border: 2px dashed #e5e7eb;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-card h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-card p {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-card .btn {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-section-header {
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-recurring {
|
|
||||||
background-color: #e0e7ff;
|
|
||||||
color: #4338ca;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -2,17 +2,19 @@
|
|||||||
<main class="container page-padding">
|
<main class="container page-padding">
|
||||||
<div class="group-detail-container">
|
<div class="group-detail-container">
|
||||||
<div v-if="loading" class="text-center">
|
<div v-if="loading" class="text-center">
|
||||||
<VSpinner :label="t('groupDetailPage.loadingLabel')" />
|
<Spinner :label="t('groupDetailPage.loadingLabel')" />
|
||||||
</div>
|
</div>
|
||||||
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
|
<Alert v-else-if="error" type="error" :message="error" class="mb-3">
|
||||||
<template #actions>
|
<Button color="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}
|
||||||
<VButton variant="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}
|
</Button>
|
||||||
</VButton>
|
</Alert>
|
||||||
</template>
|
|
||||||
</VAlert>
|
|
||||||
<div v-else-if="group">
|
<div v-else-if="group">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4">
|
||||||
<VHeading :level="1" :text="group.name" class="header-title-text" />
|
<Heading :level="1">{{ group.name }}</Heading>
|
||||||
|
<Button variant="ghost" size="sm" @click="goToSettings"
|
||||||
|
:aria-label="t('groupDetailPage.settingsButton', 'Settings')" class="ml-2">
|
||||||
|
<BaseIcon name="heroicons:cog-6-tooth" class="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
<div class="member-avatar-list">
|
<div class="member-avatar-list">
|
||||||
<div ref="avatarsContainerRef" class="member-avatars">
|
<div ref="avatarsContainerRef" class="member-avatars">
|
||||||
<div v-for="member in group.members" :key="member.id" class="member-avatar">
|
<div v-for="member in group.members" :key="member.id" class="member-avatar">
|
||||||
@ -22,17 +24,20 @@
|
|||||||
<div v-show="activeMemberMenu === member.id" ref="memberMenuRef" class="member-menu" @click.stop>
|
<div v-show="activeMemberMenu === member.id" ref="memberMenuRef" class="member-menu" @click.stop>
|
||||||
<div class="popup-header">
|
<div class="popup-header">
|
||||||
<span class="font-semibold truncate">{{ member.email }}</span>
|
<span class="font-semibold truncate">{{ member.email }}</span>
|
||||||
<VButton variant="neutral" size="sm" :icon-only="true" iconLeft="x" @click="activeMemberMenu = null"
|
<Button variant="ghost" size="sm" @click="activeMemberMenu = null"
|
||||||
:aria-label="t('groupDetailPage.members.closeMenuLabel')" />
|
:aria-label="t('groupDetailPage.members.closeMenuLabel')">
|
||||||
|
<BaseIcon name="heroicons:x-mark" class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-menu-content">
|
<div class="member-menu-content">
|
||||||
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')"
|
<span :class="badgeClasses(member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary')">
|
||||||
:variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
|
{{ member.role || t('groupDetailPage.members.defaultRole') }}
|
||||||
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" class="w-full text-left"
|
</span>
|
||||||
|
<Button v-if="canRemoveMember(member)" color="danger" size="sm" class="w-full text-left mt-2"
|
||||||
@click="removeMember(member.id)" :disabled="removingMember === member.id">
|
@click="removeMember(member.id)" :disabled="removingMember === member.id">
|
||||||
<VSpinner v-if="removingMember === member.id" size="sm" class="mr-1" />
|
<Spinner v-if="removingMember === member.id" size="sm" class="mr-1" />
|
||||||
{{ t('groupDetailPage.members.removeButton') }}
|
{{ t('groupDetailPage.members.removeButton') }}
|
||||||
</VButton>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -44,25 +49,31 @@
|
|||||||
|
|
||||||
<div v-show="showInviteUI" ref="inviteUIRef" class="invite-popup">
|
<div v-show="showInviteUI" ref="inviteUIRef" class="invite-popup">
|
||||||
<div class="popup-header">
|
<div class="popup-header">
|
||||||
<VHeading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }}
|
<Heading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }}
|
||||||
</VHeading>
|
</Heading>
|
||||||
<VButton variant="neutral" size="sm" :icon-only="true" iconLeft="x" @click="showInviteUI = false"
|
<Button variant="ghost" size="sm" @click="showInviteUI = false"
|
||||||
:aria-label="t('groupDetailPage.invites.closeInviteLabel')" />
|
:aria-label="t('groupDetailPage.invites.closeInviteLabel')">
|
||||||
|
<BaseIcon name="heroicons:x-mark" class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 my-2">{{ t('groupDetailPage.invites.description') }}</p>
|
<p class="text-sm text-gray-500 my-2">{{ t('groupDetailPage.invites.description') }}</p>
|
||||||
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
|
<Button color="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
|
||||||
<VSpinner v-if="generatingInvite" size="sm" /> {{ inviteCode ?
|
<Spinner v-if="generatingInvite" size="sm" /> {{ inviteCode ?
|
||||||
t('groupDetailPage.invites.regenerateButton') :
|
t('groupDetailPage.invites.regenerateButton') :
|
||||||
t('groupDetailPage.invites.generateButton') }}
|
t('groupDetailPage.invites.generateButton') }}
|
||||||
</VButton>
|
</Button>
|
||||||
<div v-if="inviteCode" class="neo-invite-code mt-3">
|
<div v-if="inviteCode" class="neo-invite-code mt-3">
|
||||||
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
|
<div class="mb-1">
|
||||||
|
<label for="inviteCodeInput" class="block text-sm font-medium mb-1">{{
|
||||||
|
t('groupDetailPage.invites.activeCodeLabel') }}</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
|
<Input id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
|
||||||
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler"
|
<Button variant="outline" color="secondary" @click="copyInviteCodeHandler"
|
||||||
:aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
|
:aria-label="t('groupDetailPage.invites.copyButtonLabel')">
|
||||||
|
<BaseIcon name="heroicons:clipboard-document" class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</VFormField>
|
</div>
|
||||||
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}
|
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -84,9 +95,9 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="mt-4 neo-section">
|
<div class="mt-4 neo-section">
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
|
<Heading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</Heading>
|
||||||
<div v-if="groupHistoryLoading" class="text-center">
|
<div v-if="groupHistoryLoading" class="text-center">
|
||||||
<VSpinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
|
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
|
||||||
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
|
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
|
||||||
@ -98,160 +109,174 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
|
<Alert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
|
<Dialog v-model="showChoreDetailModal" class="max-w-2xl w-full">
|
||||||
<template #default>
|
<div v-if="selectedChore" class="chore-detail-content">
|
||||||
<div v-if="selectedChore" class="chore-detail-content">
|
<Heading :level="2" class="mb-4">{{ selectedChore.name }}</Heading>
|
||||||
<div class="chore-overview-section">
|
<div class="chore-overview-section">
|
||||||
<div class="chore-status-summary">
|
<div class="chore-status-summary">
|
||||||
<div class="status-badges">
|
<div class="status-badges flex flex-wrap gap-2 mb-4">
|
||||||
<VBadge :text="formatFrequency(selectedChore.frequency)"
|
<span :class="badgeClasses(getFrequencyBadgeVariant(selectedChore.frequency))">
|
||||||
:variant="getFrequencyBadgeVariant(selectedChore.frequency)" />
|
{{ formatFrequency(selectedChore.frequency) }}
|
||||||
<VBadge v-if="getDueDateStatus(selectedChore) === 'overdue'" text="Overdue" variant="danger" />
|
</span>
|
||||||
<VBadge v-if="getDueDateStatus(selectedChore) === 'due-today'" text="Due Today" variant="warning" />
|
<span v-if="getDueDateStatus(selectedChore) === 'overdue'"
|
||||||
<VBadge v-if="getChoreStatusInfo(selectedChore).isCompleted" text="Completed" variant="success" />
|
:class="badgeClasses('danger')">Overdue</span>
|
||||||
</div>
|
<span v-if="getDueDateStatus(selectedChore) === 'due-today'" :class="badgeClasses('warning')">Due
|
||||||
<div class="chore-meta-info">
|
Today</span>
|
||||||
<div class="meta-item">
|
<span v-if="getChoreStatusInfo(selectedChore).isCompleted"
|
||||||
<span class="label">Created by:</span>
|
:class="badgeClasses('success')">Completed</span>
|
||||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
</div>
|
||||||
|
<div class="chore-meta-info">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="label">Created by:</span>
|
||||||
|
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<span class="label">Created:</span>
|
|
||||||
<span class="value">{{ format(new Date(selectedChore.created_at), 'MMM d, yyyy') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<span class="label">Next due:</span>
|
|
||||||
<span class="value" :class="getDueDateStatus(selectedChore)">
|
|
||||||
{{ formatDate(selectedChore.next_due_date) }}
|
|
||||||
<span v-if="getDueDateStatus(selectedChore) === 'due-today'">(Today)</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedChore.custom_interval_days" class="meta-item">
|
|
||||||
<span class="label">Custom interval:</span>
|
|
||||||
<span class="value">Every {{ selectedChore.custom_interval_days }} days</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="meta-item">
|
||||||
<div v-if="selectedChore.description" class="chore-description-full">
|
<span class="label">Created:</span>
|
||||||
<VHeading :level="5">Description</VHeading>
|
<span class="value">{{ format(new Date(selectedChore.created_at), 'MMM d, yyyy') }}</span>
|
||||||
<p>{{ selectedChore.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="assignments-section">
|
|
||||||
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
|
|
||||||
<div v-if="loadingAssignments" class="loading-assignments">
|
|
||||||
<VSpinner size="sm" />
|
|
||||||
<span>Loading assignments...</span>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list">
|
|
||||||
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card">
|
|
||||||
<template v-if="editingAssignment?.id === assignment.id">
|
|
||||||
<div class="editing-assignment">
|
|
||||||
<VFormField label="Assigned to:">
|
|
||||||
<VSelect v-if="group?.members"
|
|
||||||
:options="group.members.map(m => ({ value: m.id, label: m.email }))"
|
|
||||||
:model-value="editingAssignment.assigned_to_user_id || 0"
|
|
||||||
@update:model-value="val => editingAssignment && (editingAssignment.assigned_to_user_id = val)" />
|
|
||||||
</VFormField>
|
|
||||||
<VFormField label="Due date:">
|
|
||||||
<VInput type="date" :model-value="editingAssignment.due_date ?? ''"
|
|
||||||
@update:model-value="val => editingAssignment && (editingAssignment.due_date = val)" />
|
|
||||||
</VFormField>
|
|
||||||
<div class="editing-actions">
|
|
||||||
<VButton @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</VButton>
|
|
||||||
<VButton @click="cancelAssignmentEdit" variant="neutral" size="sm">{{ t('shared.cancel') }}
|
|
||||||
</VButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="assignment-info">
|
|
||||||
<div class="assignment-header">
|
|
||||||
<div class="assigned-user-info">
|
|
||||||
<span class="user-name">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
|
||||||
|| 'Unknown User' }}</span>
|
|
||||||
<VBadge v-if="assignment.is_complete" text="Completed" variant="success" />
|
|
||||||
<VBadge v-else-if="isAssignmentOverdue(assignment)" text="Overdue" variant="danger" />
|
|
||||||
</div>
|
|
||||||
<div class="assignment-actions">
|
|
||||||
<VButton v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
|
|
||||||
variant="neutral">
|
|
||||||
{{ t('shared.edit') }}
|
|
||||||
</VButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="assignment-details">
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="label">Due:</span>
|
|
||||||
<span class="value">{{ formatDate(assignment.due_date) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="assignment.is_complete && assignment.completed_at" class="detail-item">
|
|
||||||
<span class="label">Completed:</span>
|
|
||||||
<span class="value">
|
|
||||||
{{ formatDate(assignment.completed_at) }}
|
|
||||||
({{ formatDistanceToNow(new Date(assignment.completed_at), { addSuffix: true }) }})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="meta-item">
|
||||||
<p v-else class="no-assignments">{{ t('groupDetailPage.choreDetailModal.noAssignments') }}</p>
|
<span class="label">Next due:</span>
|
||||||
</div>
|
<span class="value" :class="getDueDateStatus(selectedChore)">
|
||||||
|
{{ formatDate(selectedChore.next_due_date) }}
|
||||||
<div
|
<span v-if="getDueDateStatus(selectedChore) === 'due-today'">(Today)</span>
|
||||||
v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)"
|
</span>
|
||||||
class="assignment-history-section">
|
</div>
|
||||||
<VHeading :level="4">Assignment History</VHeading>
|
<div v-if="selectedChore.custom_interval_days" class="meta-item">
|
||||||
<div class="history-timeline">
|
<span class="label">Custom interval:</span>
|
||||||
<div v-for="assignment in selectedChore.assignments" :key="`history-${assignment.id}`">
|
<span class="value">Every {{ selectedChore.custom_interval_days }} days</span>
|
||||||
<div v-if="assignment.history && assignment.history.length > 0">
|
|
||||||
<h6 class="assignment-history-header">{{ assignment.assigned_user?.email || 'Unknown User' }}</h6>
|
|
||||||
<div v-for="historyEntry in assignment.history" :key="historyEntry.id" class="history-entry">
|
|
||||||
<div class="history-timestamp">{{ format(new Date(historyEntry.timestamp), 'MMM d, yyyy HH:mm') }}
|
|
||||||
</div>
|
|
||||||
<div class="history-event">{{ formatHistoryEntry(historyEntry) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedChore.description" class="chore-description-full">
|
||||||
<div class="chore-history-section">
|
<Heading :level="5">Description</Heading>
|
||||||
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
|
<p>{{ selectedChore.description }}</p>
|
||||||
<div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline">
|
|
||||||
<div v-for="entry in selectedChore.history" :key="entry.id" class="history-entry">
|
|
||||||
<div class="history-timestamp">{{ format(new Date(entry.timestamp), 'MMM d, yyyy HH:mm') }}</div>
|
|
||||||
<div class="history-event">{{ formatHistoryEntry(entry) }}</div>
|
|
||||||
<div v-if="entry.changed_by_user" class="history-user">by {{ entry.changed_by_user.email }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="no-history">{{ t('groupDetailPage.choreDetailModal.noHistory') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</VModal>
|
|
||||||
|
|
||||||
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
|
<div class="assignments-section">
|
||||||
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
|
<Heading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</Heading>
|
||||||
<VInput type="date" v-model="scheduleForm.start_date" />
|
<div v-if="loadingAssignments" class="loading-assignments">
|
||||||
</VFormField>
|
<Spinner size="sm" />
|
||||||
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
|
<span>Loading assignments...</span>
|
||||||
<VInput type="date" v-model="scheduleForm.end_date" />
|
</div>
|
||||||
</VFormField>
|
<div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list">
|
||||||
<template #footer>
|
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card">
|
||||||
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
|
<template v-if="editingAssignment?.id === assignment.id">
|
||||||
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
|
<div class="editing-assignment space-y-3">
|
||||||
t('groupDetailPage.generateScheduleModal.generateButton') }}</VButton>
|
<div>
|
||||||
</template>
|
<label class="block text-sm font-medium mb-1">Assigned to:</label>
|
||||||
</VModal>
|
<Listbox v-if="group?.members"
|
||||||
|
:options="group.members.map(m => ({ value: m.id, label: m.email }))"
|
||||||
|
:model-value="editingAssignment.assigned_to_user_id || 0"
|
||||||
|
@update:model-value="val => editingAssignment && (editingAssignment.assigned_to_user_id = Number(val))" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Due date:</label>
|
||||||
|
<Input type="date" :model-value="editingAssignment.due_date ?? ''"
|
||||||
|
@update:model-value="val => editingAssignment && (editingAssignment.due_date = val)" />
|
||||||
|
</div>
|
||||||
|
<div class="editing-actions flex gap-2">
|
||||||
|
<Button @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</Button>
|
||||||
|
<Button @click="cancelAssignmentEdit" variant="ghost" color="neutral" size="sm">{{
|
||||||
|
t('shared.cancel') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="assignment-info">
|
||||||
|
<div class="assignment-header">
|
||||||
|
<div class="assigned-user-info">
|
||||||
|
<span class="user-name">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||||
|
|| 'Unknown User' }}</span>
|
||||||
|
<span v-if="assignment.is_complete" :class="badgeClasses('success')">Completed</span>
|
||||||
|
<span v-else-if="isAssignmentOverdue(assignment)" :class="badgeClasses('danger')">Overdue</span>
|
||||||
|
</div>
|
||||||
|
<div class="assignment-actions">
|
||||||
|
<Button v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
|
||||||
|
variant="ghost" color="secondary">
|
||||||
|
{{ t('shared.edit') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="assignment-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Due:</span>
|
||||||
|
<span class="value">{{ formatDate(assignment.due_date) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="assignment.is_complete && assignment.completed_at" class="detail-item">
|
||||||
|
<span class="label">Completed:</span>
|
||||||
|
<span class="value">
|
||||||
|
{{ formatDate(assignment.completed_at) }}
|
||||||
|
({{ formatDistanceToNow(new Date(assignment.completed_at), { addSuffix: true }) }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="no-assignments">{{ t('groupDetailPage.choreDetailModal.noAssignments') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)"
|
||||||
|
class="assignment-history-section">
|
||||||
|
<Heading :level="4">Assignment History</Heading>
|
||||||
|
<div class="history-timeline">
|
||||||
|
<div v-for="assignment in selectedChore.assignments" :key="`history-${assignment.id}`">
|
||||||
|
<div v-if="assignment.history && assignment.history.length > 0">
|
||||||
|
<h6 class="assignment-history-header">{{ assignment.assigned_user?.email || 'Unknown User' }}</h6>
|
||||||
|
<div v-for="historyEntry in assignment.history" :key="historyEntry.id" class="history-entry">
|
||||||
|
<div class="history-timestamp">{{ format(new Date(historyEntry.timestamp), 'MMM d, yyyy HH:mm') }}
|
||||||
|
</div>
|
||||||
|
<div class="history-event">{{ formatHistoryEntry(historyEntry) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chore-history-section">
|
||||||
|
<Heading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</Heading>
|
||||||
|
<div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline">
|
||||||
|
<div v-for="entry in selectedChore.history" :key="entry.id" class="history-entry">
|
||||||
|
<div class="history-timestamp">{{ format(new Date(entry.timestamp), 'MMM d, yyyy HH:mm') }}</div>
|
||||||
|
<div class="history-event">{{ formatHistoryEntry(entry) }}</div>
|
||||||
|
<div v-if="entry.changed_by_user" class="history-user">by {{ entry.changed_by_user.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="no-history">{{ t('groupDetailPage.choreDetailModal.noHistory') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog v-model="showGenerateScheduleModal">
|
||||||
|
<Heading :level="3">{{ t('groupDetailPage.generateScheduleModal.title') }}</Heading>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">{{ t('groupDetailPage.generateScheduleModal.startDateLabel')
|
||||||
|
}}</label>
|
||||||
|
<Input type="date" v-model="scheduleForm.start_date" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">{{ t('groupDetailPage.generateScheduleModal.endDateLabel')
|
||||||
|
}}</label>
|
||||||
|
<Input type="date" v-model="scheduleForm.end_date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<Button @click="showGenerateScheduleModal = false" variant="ghost" color="neutral">{{ t('shared.cancel')
|
||||||
|
}}</Button>
|
||||||
|
<Button @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
|
||||||
|
t('groupDetailPage.generateScheduleModal.generateButton') }}</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@ -268,24 +293,21 @@ import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignm
|
|||||||
import { format, formatDistanceToNow, parseISO, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
|
import { format, formatDistanceToNow, parseISO, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type { BadgeVariant } from '@/components/valerie/VBadge.vue';
|
import {
|
||||||
import VHeading from '@/components/valerie/VHeading.vue';
|
Button,
|
||||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
Dialog,
|
||||||
import VAlert from '@/components/valerie/VAlert.vue';
|
Input,
|
||||||
import VCard from '@/components/valerie/VCard.vue';
|
Heading,
|
||||||
import VList from '@/components/valerie/VList.vue';
|
Spinner,
|
||||||
import VListItem from '@/components/valerie/VListItem.vue';
|
Alert,
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
Listbox,
|
||||||
import VBadge from '@/components/valerie/VBadge.vue';
|
} from '@/components/ui'
|
||||||
import VInput from '@/components/valerie/VInput.vue';
|
import BaseIcon from '@/components/BaseIcon.vue'
|
||||||
import VFormField from '@/components/valerie/VFormField.vue';
|
|
||||||
import VIcon from '@/components/valerie/VIcon.vue';
|
|
||||||
import VModal from '@/components/valerie/VModal.vue';
|
|
||||||
import VSelect from '@/components/valerie/VSelect.vue';
|
|
||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
import { groupService } from '../services/groupService';
|
import { groupService } from '../services/groupService';
|
||||||
import ChoresPage from './ChoresPage.vue';
|
import ChoresPage from './ChoresPage.vue';
|
||||||
import ExpensesPage from './ExpensesPage.vue';
|
import ExpensesPage from './ExpensesPage.vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@ -369,6 +391,22 @@ const groupHistoryLoading = ref(false);
|
|||||||
const loadingAssignments = ref(false);
|
const loadingAssignments = ref(false);
|
||||||
const selectedChoreAssignments = ref<ChoreAssignment[]>([]);
|
const selectedChoreAssignments = ref<ChoreAssignment[]>([]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const badgeClasses = (variant: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
primary: 'bg-primary text-white',
|
||||||
|
secondary: 'bg-secondary text-white',
|
||||||
|
success: 'bg-green-500 text-white',
|
||||||
|
warning: 'bg-yellow-500 text-white',
|
||||||
|
danger: 'bg-red-500 text-white',
|
||||||
|
neutral: 'bg-gray-500 text-white',
|
||||||
|
info: 'bg-blue-500 text-white',
|
||||||
|
accent: 'bg-purple-500 text-white'
|
||||||
|
};
|
||||||
|
return `inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colorMap[variant] || colorMap.neutral}`;
|
||||||
|
}
|
||||||
|
|
||||||
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
|
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
|
||||||
if (err && typeof err === 'object') {
|
if (err && typeof err === 'object') {
|
||||||
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
|
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
|
||||||
@ -579,8 +617,8 @@ const formatFrequency = (frequency: ChoreFrequency) => {
|
|||||||
return options[frequency] || frequency;
|
return options[frequency] || frequency;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
|
const getFrequencyBadgeVariant = (frequency: ChoreFrequency) => {
|
||||||
const colorMap: Record<ChoreFrequency, BadgeVariant> = {
|
const colorMap: Record<ChoreFrequency, string> = {
|
||||||
one_time: 'neutral',
|
one_time: 'neutral',
|
||||||
daily: 'info',
|
daily: 'info',
|
||||||
weekly: 'success',
|
weekly: 'success',
|
||||||
@ -761,6 +799,12 @@ const isAssignmentOverdue = (assignment: ChoreAssignment): boolean => {
|
|||||||
return dueDate < today;
|
return dueDate < today;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function goToSettings() {
|
||||||
|
if (groupId.value) {
|
||||||
|
router.push(`/groups/${groupId.value}/settings`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchGroupDetails();
|
fetchGroupDetails();
|
||||||
loadUpcomingChores();
|
loadUpcomingChores();
|
||||||
|
@ -76,7 +76,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="newGroupNameInput" class="form-label">{{ t('groupsPage.createDialog.groupNameLabel')
|
<label for="newGroupNameInput" class="form-label">{{ t('groupsPage.createDialog.groupNameLabel')
|
||||||
}}</label>
|
}}</label>
|
||||||
<input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required
|
<input type="text" id="newGroupNameInput" v-model="newGroupName" class="form-input" required
|
||||||
ref="newGroupNameInputRef" />
|
ref="newGroupNameInputRef" />
|
||||||
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
|
<p v-if="createGroupFormError" class="form-error-text">{{ createGroupFormError }}</p>
|
||||||
@ -96,7 +96,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="joinInviteCodeInput" class="form-label">{{ t('groupsPage.joinGroup.inputLabel', 'Invite Code')
|
<label for="joinInviteCodeInput" class="form-label">{{ t('groupsPage.joinGroup.inputLabel', 'Invite Code')
|
||||||
}}</label>
|
}}</label>
|
||||||
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
|
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
|
||||||
:placeholder="t('groupsPage.joinGroup.inputPlaceholder')" required ref="joinInviteCodeInputRef" />
|
:placeholder="t('groupsPage.joinGroup.inputPlaceholder')" required ref="joinInviteCodeInputRef" />
|
||||||
<p v-if="joinGroupFormError" class="form-error-text">{{ joinGroupFormError }}</p>
|
<p v-if="joinGroupFormError" class="form-error-text">{{ joinGroupFormError }}</p>
|
||||||
@ -127,8 +127,6 @@ import { useStorage } from '@vueuse/core';
|
|||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import CreateListModal from '@/components/CreateListModal.vue';
|
import CreateListModal from '@/components/CreateListModal.vue';
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
|
||||||
import VIcon from '@/components/valerie/VIcon.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
157
fe/src/pages/HouseholdSettings.vue
Normal file
157
fe/src/pages/HouseholdSettings.vue
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto max-w-3xl p-4">
|
||||||
|
<Heading :level="1" class="mb-6">{{ group?.name || t('householdSettings.title', 'Household Settings') }}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<!-- Rename household -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<Heading :level="3" class="mb-2">{{ t('householdSettings.rename.title', 'Rename Household') }}</Heading>
|
||||||
|
<div class="flex gap-2 w-full max-w-md">
|
||||||
|
<Input v-model="editedName" class="flex-1"
|
||||||
|
:placeholder="t('householdSettings.rename.placeholder', 'New household name')" />
|
||||||
|
<Button :disabled="savingName || !editedName.trim() || editedName === group?.name" @click="saveName">
|
||||||
|
<Spinner v-if="savingName" size="sm" class="mr-1" />
|
||||||
|
{{ t('shared.save', 'Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Alert v-if="nameError" type="error" :message="nameError" class="mt-2" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Members list -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<Heading :level="3" class="mb-4">{{ t('householdSettings.members.title', 'Members') }}</Heading>
|
||||||
|
<ul v-if="group" class="space-y-3">
|
||||||
|
<li v-for="member in group.members" :key="member.id"
|
||||||
|
class="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 p-3 rounded-md">
|
||||||
|
<span>{{ member.email }}</span>
|
||||||
|
<Button v-if="canRemove(member)" variant="ghost" color="danger" size="sm"
|
||||||
|
@click="confirmRemove(member)">
|
||||||
|
{{ t('householdSettings.members.remove', 'Remove') }}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Alert v-else type="info" :message="t('householdSettings.members.loading', 'Loading members...')" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Invite manager -->
|
||||||
|
<InviteManager v-if="group" :group-id="group.id" />
|
||||||
|
|
||||||
|
<!-- Remove member confirm dialog -->
|
||||||
|
<Dialog v-model="showRemoveDialog">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Heading :level="3">{{ t('householdSettings.members.confirmTitle', 'Remove member?') }}</Heading>
|
||||||
|
<p>{{ confirmPrompt }}</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" color="neutral" @click="showRemoveDialog = false">{{ t('shared.cancel',
|
||||||
|
'Cancel') }}</Button>
|
||||||
|
<Button variant="solid" color="danger" :disabled="removing" @click="removeConfirmed">
|
||||||
|
<Spinner v-if="removing" size="sm" class="mr-1" />
|
||||||
|
{{ t('shared.remove', 'Remove') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||||
|
import Heading from '@/components/ui/Heading.vue'
|
||||||
|
import Input from '@/components/ui/Input.vue'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
import Alert from '@/components/ui/Alert.vue'
|
||||||
|
import Spinner from '@/components/ui/Spinner.vue'
|
||||||
|
import Dialog from '@/components/ui/Dialog.vue'
|
||||||
|
import InviteManager from '@/components/InviteManager.vue'
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
members: Member[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const groupId = Number(route.params.id || route.params.groupId)
|
||||||
|
const group = ref<Group | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const editedName = ref('')
|
||||||
|
const savingName = ref(false)
|
||||||
|
const nameError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const showRemoveDialog = ref(false)
|
||||||
|
const memberToRemove = ref<Member | null>(null)
|
||||||
|
const removing = ref(false)
|
||||||
|
|
||||||
|
const confirmPrompt = computed(() => {
|
||||||
|
const email = memberToRemove.value?.email ?? ''
|
||||||
|
return t('householdSettings.members.confirmMessage', { email }, `Are you sure you want to remove ${email} from the household?`)
|
||||||
|
})
|
||||||
|
|
||||||
|
function canRemove(member: Member): boolean {
|
||||||
|
return member.role !== 'owner'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGroup() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)))
|
||||||
|
group.value = res.data as Group
|
||||||
|
editedName.value = group.value.name
|
||||||
|
} catch (err: any) {
|
||||||
|
loadError.value = err?.response?.data?.detail || err.message || t('householdSettings.loadError', 'Failed to load household info')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
if (!group.value || editedName.value.trim() === group.value.name) return
|
||||||
|
savingName.value = true
|
||||||
|
nameError.value = null
|
||||||
|
try {
|
||||||
|
const res = await apiClient.patch(API_ENDPOINTS.GROUPS.BY_ID(String(groupId)), { name: editedName.value.trim() })
|
||||||
|
group.value = { ...group.value, name: res.data.name }
|
||||||
|
} catch (err: any) {
|
||||||
|
nameError.value = err?.response?.data?.detail || err.message || t('householdSettings.rename.error', 'Failed to rename household')
|
||||||
|
} finally {
|
||||||
|
savingName.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRemove(member: Member) {
|
||||||
|
memberToRemove.value = member
|
||||||
|
showRemoveDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeConfirmed() {
|
||||||
|
if (!group.value || !memberToRemove.value) return
|
||||||
|
removing.value = true
|
||||||
|
try {
|
||||||
|
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId), String(memberToRemove.value.id)))
|
||||||
|
group.value.members = group.value.members.filter(m => m.id !== memberToRemove.value!.id)
|
||||||
|
showRemoveDialog.value = false
|
||||||
|
} catch (err: any) {
|
||||||
|
// show error alert inside dialog maybe
|
||||||
|
} finally {
|
||||||
|
removing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchGroup)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
File diff suppressed because it is too large
Load Diff
@ -1,24 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<main class="container page-padding">
|
||||||
|
|
||||||
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
|
<Alert v-if="error" type="error" :message="error" class="mb-3">
|
||||||
<template #actions>
|
<Button color="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</Button>
|
||||||
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
|
</Alert>
|
||||||
</template>
|
|
||||||
</VAlert>
|
|
||||||
|
|
||||||
<VCard v-else-if="filteredLists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
|
<Card v-else-if="filteredLists.length === 0 && !loading">
|
||||||
:empty-title="t(noListsMessageKey)">
|
<div class="text-center p-4">
|
||||||
<template #default>
|
<BaseIcon name="heroicons:clipboard-document-list" class="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">{{ t(noListsMessageKey) }}</h3>
|
||||||
<p v-else>{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
|
<p v-if="!currentGroupId" class="mt-1 text-sm text-gray-500">{{ t('listsPage.emptyState.personalGlobalInfo') }}
|
||||||
</template>
|
</p>
|
||||||
<template #empty-actions>
|
<p v-else class="mt-1 text-sm text-gray-500">{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
|
||||||
<VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
|
<div class="mt-6">
|
||||||
{{ t('listsPage.createNewListButton') }}
|
<Button @click="showCreateModal = true">
|
||||||
</VButton>
|
<BaseIcon name="heroicons:plus" class="-ml-1 mr-2 h-5 w-5" />
|
||||||
</template>
|
{{ t('listsPage.createNewListButton') }}
|
||||||
</VCard>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div v-else-if="loading && filteredLists.length === 0" class="loading-placeholder">
|
<div v-else-if="loading && filteredLists.length === 0" class="loading-placeholder">
|
||||||
{{ t('listsPage.loadingLists') }}
|
{{ t('listsPage.loadingLists') }}
|
||||||
@ -38,11 +39,12 @@
|
|||||||
<div v-if="actionsMenuVisibleFor === list.id" class="actions-dropdown">
|
<div v-if="actionsMenuVisibleFor === list.id" class="actions-dropdown">
|
||||||
<ul>
|
<ul>
|
||||||
<li @click.stop="archiveOrUnarchive(list)">
|
<li @click.stop="archiveOrUnarchive(list)">
|
||||||
<VIcon :name="list.archived_at ? 'unarchive' : 'archive'" style="margin-right: 0.5rem;" />
|
<BaseIcon :name="list.archived_at ? 'heroicons:archive-box-x-mark' : 'heroicons:archive-box'"
|
||||||
|
class="mr-2 h-5 w-5" />
|
||||||
<span>{{ list.archived_at ? 'Unarchive' : 'Archive' }}</span>
|
<span>{{ list.archived_at ? 'Unarchive' : 'Archive' }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li @click.stop="deleteList(list)" class="text-danger">
|
<li @click.stop="deleteList(list)" class="text-danger">
|
||||||
<VIcon name="delete" style="margin-right: 0.5rem;" />
|
<BaseIcon name="heroicons:trash" class="mr-2 h-5 w-5 text-danger" />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -90,11 +92,8 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import CreateListModal from '@/components/CreateListModal.vue';
|
import CreateListModal from '@/components/CreateListModal.vue';
|
||||||
import { useStorage, onClickOutside } from '@vueuse/core';
|
import { useStorage, onClickOutside } from '@vueuse/core';
|
||||||
import VAlert from '@/components/valerie/VAlert.vue';
|
import { Alert, Button, Card } from '@/components/ui'
|
||||||
import VCard from '@/components/valerie/VCard.vue';
|
import BaseIcon from '@/components/BaseIcon.vue'
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
|
||||||
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
|
|
||||||
import VIcon from '@/components/valerie/VIcon.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
@ -7,39 +7,38 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form @submit.prevent="onSubmit" class="form-layout">
|
<form @submit.prevent="onSubmit" class="form-layout">
|
||||||
<div class="form-group mb-2">
|
<div class="form-group mb-2">
|
||||||
<label for="email" class="form-label">{{ t('loginPage.emailLabel') }}</label>
|
<Input v-model="email" :label="t('loginPage.emailLabel')" type="email" id="email" :error="formErrors.email"
|
||||||
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
|
autocomplete="email" />
|
||||||
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group mb-3">
|
<div class="form-group mb-3">
|
||||||
<label for="password" class="form-label">{{ t('loginPage.passwordLabel') }}</label>
|
<Input v-model="password" :label="t('loginPage.passwordLabel')" :type="isPwdVisible ? 'text' : 'password'"
|
||||||
<div class="input-with-icon-append">
|
id="password" :error="formErrors.password" autocomplete="current-password" />
|
||||||
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
|
<button type="button" class="absolute right-3 top-[34px] text-sm text-gray-500"
|
||||||
required autocomplete="current-password" />
|
@click="isPwdVisible = !isPwdVisible" :aria-label="t('loginPage.togglePasswordVisibilityLabel')">
|
||||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
|
{{ isPwdVisible ? 'Hide' : 'Show' }}
|
||||||
:aria-label="t('loginPage.togglePasswordVisibilityLabel')">
|
</button>
|
||||||
<svg class="icon icon-sm">
|
|
||||||
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
|
<Button type="submit" :disabled="loading" class="w-full mt-2">
|
||||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
<span v-if="loading" class="animate-pulse">{{ t('loginPage.loginButton') }}</span>
|
||||||
{{ t('loginPage.loginButton') }}
|
<span v-else>{{ t('loginPage.loginButton') }}</span>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<div class="divider my-3">or</div>
|
<div class="divider my-3">or</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-secondary w-full" @click="handleGuestLogin" :disabled="loading">
|
<Button variant="outline" color="secondary" class="w-full" :disabled="loading" @click="handleGuestLogin">
|
||||||
Continue as Guest
|
Continue as Guest
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-outline w-full mt-2" @click="openSheet">
|
||||||
|
Email Magic Link
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<AuthenticationSheet ref="sheet" />
|
||||||
|
|
||||||
<div class="text-center mt-2">
|
<div class="text-center mt-2">
|
||||||
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
|
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
@ -58,6 +57,8 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
|
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
|
||||||
|
import AuthenticationSheet from '@/components/AuthenticationSheet.vue';
|
||||||
|
import { Input, Button } from '@/components/ui';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -71,6 +72,11 @@ const isPwdVisible = ref(false);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
|
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
|
||||||
|
|
||||||
|
const sheet = ref<InstanceType<typeof AuthenticationSheet>>()
|
||||||
|
function openSheet() {
|
||||||
|
sheet.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
const isValidEmail = (val: string): boolean => {
|
const isValidEmail = (val: string): boolean => {
|
||||||
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
|
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
|
||||||
return emailPattern.test(val);
|
return emailPattern.test(val);
|
||||||
|
@ -6,49 +6,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form @submit.prevent="onSubmit" class="form-layout">
|
<form @submit.prevent="onSubmit" class="form-layout">
|
||||||
<div class="form-group mb-2">
|
<div class="form-group mb-2 relative">
|
||||||
<label for="name" class="form-label">{{ $t('signupPage.fullNameLabel') }}</label>
|
<Input v-model="name" :label="$t('signupPage.fullNameLabel')" id="name" :error="formErrors.name"
|
||||||
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name" />
|
autocomplete="name" />
|
||||||
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group mb-2">
|
<div class="form-group mb-2">
|
||||||
<label for="email" class="form-label">{{ $t('signupPage.emailLabel') }}</label>
|
<Input v-model="email" :label="$t('signupPage.emailLabel')" type="email" id="email"
|
||||||
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
|
:error="formErrors.email" autocomplete="email" />
|
||||||
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group mb-2">
|
<div class="form-group mb-2 relative">
|
||||||
<label for="password" class="form-label">{{ $t('signupPage.passwordLabel') }}</label>
|
<Input v-model="password" :label="$t('signupPage.passwordLabel')" :type="isPwdVisible ? 'text' : 'password'"
|
||||||
<div class="input-with-icon-append">
|
id="password" :error="formErrors.password" autocomplete="new-password" />
|
||||||
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
|
<button type="button" class="absolute right-3 top-[34px] text-sm text-gray-500"
|
||||||
required autocomplete="new-password" />
|
@click="isPwdVisible = !isPwdVisible" :aria-label="$t('signupPage.togglePasswordVisibility')">
|
||||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
|
{{ isPwdVisible ? 'Hide' : 'Show' }}
|
||||||
:aria-label="$t('signupPage.togglePasswordVisibility')">
|
</button>
|
||||||
<svg class="icon icon-sm">
|
|
||||||
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
|
|
||||||
</svg> <!-- Placeholder for visibility icons -->
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group mb-3">
|
<div class="form-group mb-3">
|
||||||
<label for="confirmPassword" class="form-label">{{ $t('signupPage.confirmPasswordLabel') }}</label>
|
<Input v-model="confirmPassword" :label="$t('signupPage.confirmPasswordLabel')"
|
||||||
<input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
|
:type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" :error="formErrors.confirmPassword"
|
||||||
class="form-input" required autocomplete="new-password" />
|
autocomplete="new-password" />
|
||||||
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
|
<Button type="submit" class="w-full mt-2" :disabled="loading">
|
||||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
<span v-if="loading" class="animate-pulse">{{ $t('signupPage.submitButton') }}</span>
|
||||||
{{ $t('signupPage.submitButton') }}
|
<span v-else>{{ $t('signupPage.submitButton') }}</span>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<div class="text-center mt-2">
|
<div class="text-center mt-2">
|
||||||
<router-link to="auth/login" class="link-styled">{{ $t('signupPage.loginLink') }}</router-link>
|
<router-link to="/auth/login" class="link-styled">{{ $t('signupPage.loginLink') }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -62,6 +53,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
|
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
|
import { Input, Button } from '@/components/ui';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -1,266 +0,0 @@
|
|||||||
import { mount, flushPromises, DOMWrapper } from '@vue/test-utils';
|
|
||||||
import ChoresPage from '../ChoresPage.vue'; // Adjust path
|
|
||||||
import { choreService } from '@/services/choreService';
|
|
||||||
import { groupService } from '@/services/groupService'; // Assuming loadGroups will use this
|
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { format } from 'date-fns'; // Used by the component
|
|
||||||
|
|
||||||
// --- Mocks ---
|
|
||||||
vi.mock('@/services/choreService');
|
|
||||||
vi.mock('@/services/groupService');
|
|
||||||
vi.mock('@/stores/notifications', () => ({
|
|
||||||
useNotificationStore: vi.fn(() => ({
|
|
||||||
addNotification: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
vi.mock('vue-router', () => ({
|
|
||||||
useRoute: vi.fn(() => ({ query: {} })), // Default mock for useRoute
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockChoreService = choreService as vi.Mocked<typeof choreService>;
|
|
||||||
const mockGroupService = groupService as vi.Mocked<typeof groupService>;
|
|
||||||
let mockNotificationStore: ReturnType<typeof useNotificationStore>;
|
|
||||||
|
|
||||||
// --- Test Data ---
|
|
||||||
const mockUserGroups = [
|
|
||||||
{ id: 1, name: 'Family', members: [], owner_id: 1, created_at: '', updated_at: '' },
|
|
||||||
{ id: 2, name: 'Work', members: [], owner_id: 1, created_at: '', updated_at: '' },
|
|
||||||
];
|
|
||||||
const mockPersonalChores = [
|
|
||||||
{ id: 101, name: 'Personal Task 1', type: 'personal' as const, frequency: 'daily' as const, next_due_date: '2023-10-01', description: 'My personal chore' },
|
|
||||||
];
|
|
||||||
const mockFamilyChores = [
|
|
||||||
{ id: 201, name: 'Clean Kitchen', type: 'group' as const, group_id: 1, frequency: 'weekly' as const, next_due_date: '2023-10-05', description: 'Family group chore' },
|
|
||||||
];
|
|
||||||
const mockWorkChores = [
|
|
||||||
{ id: 301, name: 'Project Report', type: 'group' as const, group_id: 2, frequency: 'monthly' as const, next_due_date: '2023-10-10', description: 'Work group chore' },
|
|
||||||
];
|
|
||||||
const allMockChores = [...mockPersonalChores, ...mockFamilyChores, ...mockWorkChores];
|
|
||||||
|
|
||||||
// Helper function to create a wrapper
|
|
||||||
const createWrapper = (props = {}) => {
|
|
||||||
return mount(ChoresPage, {
|
|
||||||
props,
|
|
||||||
global: {
|
|
||||||
stubs: {
|
|
||||||
// Could stub complex child components if needed, but not strictly necessary here yet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('ChoresPage.vue', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockChoreService.getAllChores.mockResolvedValue([...allMockChores]); // Default success
|
|
||||||
// Mock for loadGroups, assuming it will call groupService.getUserGroups
|
|
||||||
// The component's loadGroups is a placeholder, so this mock is for when it's implemented.
|
|
||||||
// For now, we'll manually set groups ref in tests that need it for the modal.
|
|
||||||
mockGroupService.getUserGroups.mockResolvedValue([...mockUserGroups]);
|
|
||||||
|
|
||||||
mockNotificationStore = useNotificationStore() as vi.Mocked<ReturnType<typeof useNotificationStore>>;
|
|
||||||
(useNotificationStore as vi.Mock).mockReturnValue(mockNotificationStore);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rendering and Initial Data Fetching', () => {
|
|
||||||
it('fetches and displays chores on mount, grouped correctly', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await flushPromises(); // For onMounted, getAllChores, loadGroups
|
|
||||||
|
|
||||||
expect(mockChoreService.getAllChores).toHaveBeenCalled();
|
|
||||||
// expect(mockGroupService.getUserGroups).toHaveBeenCalled(); // If loadGroups was implemented
|
|
||||||
|
|
||||||
// Check personal chores
|
|
||||||
expect(wrapper.text()).toContain('Personal Chores');
|
|
||||||
expect(wrapper.text()).toContain('Personal Task 1');
|
|
||||||
expect(wrapper.text()).toContain(format(new Date(mockPersonalChores[0].next_due_date), 'MMM d, yyyy'));
|
|
||||||
|
|
||||||
|
|
||||||
// Check group chores
|
|
||||||
expect(wrapper.text()).toContain(mockUserGroups[0].name); // Family
|
|
||||||
expect(wrapper.text()).toContain('Clean Kitchen');
|
|
||||||
expect(wrapper.text()).toContain(format(new Date(mockFamilyChores[0].next_due_date), 'MMM d, yyyy'));
|
|
||||||
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain(mockUserGroups[1].name); // Work
|
|
||||||
expect(wrapper.text()).toContain('Project Report');
|
|
||||||
expect(wrapper.text()).toContain(format(new Date(mockWorkChores[0].next_due_date), 'MMM d, yyyy'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays "No chores found" message when no chores exist', async () => {
|
|
||||||
mockChoreService.getAllChores.mockResolvedValueOnce([]);
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.text()).toContain('No chores found. Get started by adding a new chore!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays "No chores in this group" if a group has no chores but others do', async () => {
|
|
||||||
mockChoreService.getAllChores.mockResolvedValueOnce([...mockPersonalChores]); // Only personal chores
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
// Manually set groups for the dropdown/display logic
|
|
||||||
wrapper.vm.groups = [...mockUserGroups];
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Personal Chores');
|
|
||||||
expect(wrapper.text()).toContain('Personal Task 1');
|
|
||||||
// Check for group sections and their "no chores" message
|
|
||||||
for (const group of mockUserGroups) {
|
|
||||||
expect(wrapper.text()).toContain(group.name); // Group title should be there
|
|
||||||
// This relies on group.chores being empty in the computed `groupedChores`
|
|
||||||
// which it will be if getAllChores returns no chores for that group.
|
|
||||||
const groupSection = wrapper.findAll('h2.chores-group-title').find(h => h.text() === group.name);
|
|
||||||
expect(groupSection).toBeTruthy();
|
|
||||||
// Find the <p>No chores in this group.</p> within this group's section.
|
|
||||||
// This is a bit tricky; better to add test-ids or more specific selectors.
|
|
||||||
// For now, a broad check:
|
|
||||||
const groupCards = groupSection?.element.parentElement?.querySelectorAll('.neo-grid .neo-card');
|
|
||||||
if (!groupCards || groupCards.length === 0) {
|
|
||||||
expect(groupSection?.element.parentElement?.textContent).toContain('No chores in this group.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles error during chore fetching', async () => {
|
|
||||||
const error = new Error('Failed to load');
|
|
||||||
mockChoreService.getAllChores.mockRejectedValueOnce(error);
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
|
||||||
message: 'Failed to load chores', type: 'error'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Chore Creation', () => {
|
|
||||||
it('opens create modal, submits form for personal chore, and reloads chores', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await flushPromises(); // Initial load
|
|
||||||
// Manually set groups for the dropdown in the modal
|
|
||||||
wrapper.vm.groups = [...mockUserGroups];
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
|
|
||||||
await wrapper.find('button.btn-primary').filter(b => b.text().includes('New Chore')).trigger('click');
|
|
||||||
expect(wrapper.vm.showChoreModal).toBe(true);
|
|
||||||
expect(wrapper.vm.isEditing).toBe(false);
|
|
||||||
|
|
||||||
// Fill form
|
|
||||||
await wrapper.find('#name').setValue('New Test Personal Chore');
|
|
||||||
await wrapper.find('input[type="radio"][value="personal"]').setValue(true);
|
|
||||||
await wrapper.find('#description').setValue('A description');
|
|
||||||
await wrapper.find('#dueDate').setValue('2023-12-01');
|
|
||||||
// Frequency is 'daily' by default
|
|
||||||
|
|
||||||
mockChoreService.createChore.mockResolvedValueOnce({ id: 999, name: 'New Test Personal Chore', type: 'personal' } as any);
|
|
||||||
mockChoreService.getAllChores.mockResolvedValueOnce([]); // For reload
|
|
||||||
|
|
||||||
await wrapper.find('.neo-modal-footer .btn-primary').trigger('click'); // Save button
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(mockChoreService.createChore).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
name: 'New Test Personal Chore',
|
|
||||||
type: 'personal',
|
|
||||||
description: 'A description',
|
|
||||||
next_due_date: '2023-12-01',
|
|
||||||
}));
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore created successfully', type: 'success' });
|
|
||||||
expect(wrapper.vm.showChoreModal).toBe(false);
|
|
||||||
expect(mockChoreService.getAllChores).toHaveBeenCalledTimes(2); // Initial + reload
|
|
||||||
});
|
|
||||||
|
|
||||||
it('submits form for group chore correctly', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await flushPromises();
|
|
||||||
wrapper.vm.groups = [...mockUserGroups];
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
await wrapper.find('button.btn-primary').filter(b => b.text().includes('New Chore')).trigger('click');
|
|
||||||
|
|
||||||
await wrapper.find('#name').setValue('New Test Group Chore');
|
|
||||||
await wrapper.find('input[type="radio"][value="group"]').setValue(true);
|
|
||||||
await wrapper.find('#group').setValue(mockUserGroups[0].id.toString()); // Select first group
|
|
||||||
await wrapper.find('#description').setValue('Group chore desc');
|
|
||||||
await wrapper.find('#dueDate').setValue('2023-12-02');
|
|
||||||
|
|
||||||
mockChoreService.createChore.mockResolvedValueOnce({ id: 998, name: 'New Test Group Chore', type: 'group', group_id: mockUserGroups[0].id } as any);
|
|
||||||
|
|
||||||
await wrapper.find('.neo-modal-footer .btn-primary').trigger('click'); // Save button
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(mockChoreService.createChore).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
name: 'New Test Group Chore',
|
|
||||||
type: 'group',
|
|
||||||
group_id: mockUserGroups[0].id,
|
|
||||||
description: 'Group chore desc',
|
|
||||||
next_due_date: '2023-12-02',
|
|
||||||
}));
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore created successfully', type: 'success' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Chore Editing', () => {
|
|
||||||
it('opens edit modal with chore data, submits changes, and reloads', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await flushPromises(); // Initial load
|
|
||||||
wrapper.vm.groups = [...mockUserGroups];
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
// Find the first "Edit" button (for the first personal chore)
|
|
||||||
const editButton = wrapper.findAll('.btn-primary.btn-sm').find(b => b.text().includes('Edit'));
|
|
||||||
expect(editButton).toBeTruthy();
|
|
||||||
await editButton!.trigger('click');
|
|
||||||
|
|
||||||
expect(wrapper.vm.showChoreModal).toBe(true);
|
|
||||||
expect(wrapper.vm.isEditing).toBe(true);
|
|
||||||
expect(wrapper.find<HTMLInputElement>('#name').element.value).toBe(mockPersonalChores[0].name);
|
|
||||||
|
|
||||||
await wrapper.find('#name').setValue('Updated Personal Chore Name');
|
|
||||||
// The next_due_date in form is 'yyyy-MM-dd', original data might be different format
|
|
||||||
// The component tries to format it.
|
|
||||||
expect(wrapper.find<HTMLInputElement>('#dueDate').element.value).toBe(format(new Date(mockPersonalChores[0].next_due_date), 'yyyy-MM-dd'));
|
|
||||||
|
|
||||||
mockChoreService.updateChore.mockResolvedValueOnce({ ...mockPersonalChores[0], name: 'Updated Personal Chore Name' } as any);
|
|
||||||
mockChoreService.getAllChores.mockResolvedValueOnce([]); // For reload
|
|
||||||
|
|
||||||
await wrapper.find('.neo-modal-footer .btn-primary').trigger('click'); // Save button
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(mockChoreService.updateChore).toHaveBeenCalledWith(
|
|
||||||
mockPersonalChores[0].id,
|
|
||||||
expect.objectContaining({ name: 'Updated Personal Chore Name' })
|
|
||||||
);
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore updated successfully', type: 'success' });
|
|
||||||
expect(wrapper.vm.showChoreModal).toBe(false);
|
|
||||||
expect(mockChoreService.getAllChores).toHaveBeenCalledTimes(2); // Initial + reload
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Chore Deletion', () => {
|
|
||||||
it('opens delete confirmation, confirms deletion, and reloads', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await flushPromises(); // Initial load
|
|
||||||
|
|
||||||
const choreToDelete = mockPersonalChores[0];
|
|
||||||
// Find the first "Delete" button
|
|
||||||
const deleteButton = wrapper.findAll('.btn-danger.btn-sm').find(b => b.text().includes('Delete'));
|
|
||||||
expect(deleteButton).toBeTruthy();
|
|
||||||
await deleteButton!.trigger('click');
|
|
||||||
|
|
||||||
expect(wrapper.vm.showDeleteDialog).toBe(true);
|
|
||||||
expect(wrapper.vm.selectedChore).toEqual(choreToDelete);
|
|
||||||
|
|
||||||
mockChoreService.deleteChore.mockResolvedValueOnce(); // Mock successful deletion
|
|
||||||
mockChoreService.getAllChores.mockResolvedValueOnce([]); // For reload
|
|
||||||
|
|
||||||
await wrapper.find('.neo-modal-footer .btn-danger').trigger('click'); // Confirm Delete button
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(mockChoreService.deleteChore).toHaveBeenCalledWith(choreToDelete.id, choreToDelete.type, choreToDelete.group_id);
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Chore deleted successfully', type: 'success' });
|
|
||||||
expect(wrapper.vm.showDeleteDialog).toBe(false);
|
|
||||||
expect(mockChoreService.getAllChores).toHaveBeenCalledTimes(2); // Initial + reload
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -5,7 +5,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('../layouts/MainLayout.vue'),
|
component: () => import('../layouts/MainLayout.vue'),
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirect: '/lists' },
|
{ path: '', redirect: '/dashboard' },
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../pages/DashboardPage.vue'),
|
||||||
|
meta: { keepAlive: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'lists',
|
path: 'lists',
|
||||||
name: 'PersonalLists',
|
name: 'PersonalLists',
|
||||||
@ -32,6 +38,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { keepAlive: true },
|
meta: { keepAlive: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'groups/:id/settings',
|
||||||
|
name: 'GroupSettings',
|
||||||
|
component: () => import('../pages/HouseholdSettings.vue'),
|
||||||
|
props: true,
|
||||||
|
meta: { keepAlive: false },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'groups/:groupId/lists',
|
path: 'groups/:groupId/lists',
|
||||||
name: 'GroupLists',
|
name: 'GroupLists',
|
||||||
|
20
fe/src/services/activityService.ts
Normal file
20
fe/src/services/activityService.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||||
|
import type { PaginatedActivityResponse } from '@/types/activity'
|
||||||
|
|
||||||
|
async function getGroupActivity(
|
||||||
|
groupId: number,
|
||||||
|
limit: number,
|
||||||
|
cursor?: number | null
|
||||||
|
): Promise<PaginatedActivityResponse> {
|
||||||
|
const endpoint = API_ENDPOINTS.ACTIVITY.GET_BY_GROUP(groupId);
|
||||||
|
const params: Record<string, any> = { limit };
|
||||||
|
if (cursor) {
|
||||||
|
params.cursor = cursor;
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(endpoint, { params });
|
||||||
|
return response.data as PaginatedActivityResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activityService = {
|
||||||
|
getGroupActivity,
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import type { Expense, RecurrencePattern } from '@/types/expense'
|
import type { Expense, RecurrencePattern, SettlementActivityCreate } from '@/types/expense'
|
||||||
import { api, API_ENDPOINTS } from '@/services/api'
|
import { api, API_ENDPOINTS } from '@/services/api'
|
||||||
|
|
||||||
export interface CreateExpenseData {
|
export interface CreateExpenseData {
|
||||||
@ -78,4 +78,10 @@ export const expenseService = {
|
|||||||
async getRecurringExpenses(): Promise<Expense[]> {
|
async getRecurringExpenses(): Promise<Expense[]> {
|
||||||
return this.getExpenses({ isRecurring: true })
|
return this.getExpenses({ isRecurring: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async settleExpenseSplit(expense_split_id: number, activity: SettlementActivityCreate) {
|
||||||
|
const endpoint = `/financials/expense_splits/${expense_split_id}/settle`
|
||||||
|
const response = await api.post(endpoint, activity)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
138
fe/src/stores/__tests__/choreStore.spec.ts
Normal file
138
fe/src/stores/__tests__/choreStore.spec.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useChoreStore } from '../choreStore'
|
||||||
|
import type { Chore } from '@/types/chore'
|
||||||
|
import { apiClient } from '@/services/api'
|
||||||
|
import type { AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
// Mock choreService
|
||||||
|
vi.mock('@/services/choreService', () => ({
|
||||||
|
choreService: {
|
||||||
|
getPersonalChores: vi.fn(),
|
||||||
|
getChores: vi.fn(),
|
||||||
|
createChore: vi.fn(),
|
||||||
|
updateChore: vi.fn(),
|
||||||
|
deleteChore: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { choreService } from '@/services/choreService'
|
||||||
|
|
||||||
|
const mockSvc = choreService as unknown as {
|
||||||
|
getPersonalChores: ReturnType<typeof vi.fn>
|
||||||
|
getChores: ReturnType<typeof vi.fn>
|
||||||
|
createChore: ReturnType<typeof vi.fn>
|
||||||
|
updateChore: ReturnType<typeof vi.fn>
|
||||||
|
deleteChore: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a mock Axios response
|
||||||
|
const mockAxiosResponse = <T>(data: T): AxiosResponse<T> => ({
|
||||||
|
data,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/services/api', () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
API_ENDPOINTS: {
|
||||||
|
CHORES: {
|
||||||
|
ASSIGNMENT_BY_ID: (id: number) => `/assignment/${id}`,
|
||||||
|
TIME_ENTRY: (id: number) => `/time-entry/${id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('choreStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(apiClient.get).mockClear()
|
||||||
|
vi.mocked(apiClient.post).mockClear()
|
||||||
|
vi.mocked(apiClient.put).mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPersonal populates personal chores', async () => {
|
||||||
|
const mock: Chore[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Dishwashing',
|
||||||
|
created_by_id: 1,
|
||||||
|
frequency: 'daily',
|
||||||
|
next_due_date: '',
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
type: 'personal',
|
||||||
|
assignments: [],
|
||||||
|
} as unknown as Chore,
|
||||||
|
]
|
||||||
|
mockSvc.getPersonalChores.mockResolvedValue(mock)
|
||||||
|
|
||||||
|
const store = useChoreStore()
|
||||||
|
await store.fetchPersonal()
|
||||||
|
|
||||||
|
expect(mockSvc.getPersonalChores).toHaveBeenCalled()
|
||||||
|
expect(store.personal).toEqual(mock)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches time entries for an assignment', async () => {
|
||||||
|
const store = useChoreStore()
|
||||||
|
const assignmentId = 1
|
||||||
|
const mockEntries = [{ id: 101, start_time: new Date().toISOString() }]
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValue(mockAxiosResponse(mockEntries))
|
||||||
|
|
||||||
|
await store.fetchTimeEntries(assignmentId)
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith(`/assignment/${assignmentId}/time-entries`)
|
||||||
|
expect(store.timeEntriesByAssignment[assignmentId]).toEqual(mockEntries)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a timer for an assignment', async () => {
|
||||||
|
const store = useChoreStore()
|
||||||
|
const assignmentId = 2
|
||||||
|
const newEntry = { id: 102, start_time: new Date().toISOString(), assignment_id: assignmentId }
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue(mockAxiosResponse(newEntry))
|
||||||
|
|
||||||
|
const result = await store.startTimer(assignmentId)
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(`/assignment/${assignmentId}/time-entries`)
|
||||||
|
expect(store.timeEntriesByAssignment[assignmentId]).toContainEqual(newEntry)
|
||||||
|
expect(result).toEqual(newEntry)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops a timer for a time entry', async () => {
|
||||||
|
const store = useChoreStore()
|
||||||
|
const assignmentId = 3
|
||||||
|
const timeEntryId = 103
|
||||||
|
const initialEntry = { id: timeEntryId, start_time: '2023-01-01T10:00:00Z', end_time: null, assignment_id: assignmentId }
|
||||||
|
const updatedEntry = { ...initialEntry, end_time: new Date().toISOString() }
|
||||||
|
|
||||||
|
store.timeEntriesByAssignment[assignmentId] = [initialEntry]
|
||||||
|
vi.mocked(apiClient.put).mockResolvedValue(mockAxiosResponse(updatedEntry))
|
||||||
|
|
||||||
|
const result = await store.stopTimer(assignmentId, timeEntryId)
|
||||||
|
|
||||||
|
expect(apiClient.put).toHaveBeenCalledWith(`/time-entry/${timeEntryId}`)
|
||||||
|
expect(store.timeEntriesByAssignment[assignmentId][0]).toEqual(updatedEntry)
|
||||||
|
expect(result).toEqual(updatedEntry)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors when fetching time entries', async () => {
|
||||||
|
const store = useChoreStore()
|
||||||
|
const assignmentId = 4
|
||||||
|
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network Error'))
|
||||||
|
|
||||||
|
await store.fetchTimeEntries(assignmentId)
|
||||||
|
|
||||||
|
expect(store.error).toContain('Failed to fetch time entries')
|
||||||
|
expect(store.timeEntriesByAssignment[assignmentId]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
56
fe/src/stores/__tests__/itemStore.spec.ts
Normal file
56
fe/src/stores/__tests__/itemStore.spec.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useItemStore } from '../itemStore'
|
||||||
|
import type { Item } from '@/types/item'
|
||||||
|
import { API_ENDPOINTS } from '@/config/api-config'
|
||||||
|
|
||||||
|
// Mock the apiClient used inside the store
|
||||||
|
vi.mock('@/services/api', () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
API_ENDPOINTS,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { apiClient } from '@/services/api'
|
||||||
|
|
||||||
|
const mockApi = apiClient as unknown as {
|
||||||
|
get: ReturnType<typeof vi.fn>
|
||||||
|
post: ReturnType<typeof vi.fn>
|
||||||
|
put: ReturnType<typeof vi.fn>
|
||||||
|
delete: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('itemStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchItems populates state', async () => {
|
||||||
|
const listId = 1
|
||||||
|
const mockItems: Item[] = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
name: 'Milk',
|
||||||
|
quantity: 1,
|
||||||
|
is_complete: false,
|
||||||
|
list_id: listId,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mockApi.get.mockResolvedValue({ data: mockItems })
|
||||||
|
|
||||||
|
const store = useItemStore()
|
||||||
|
await store.fetchItems(listId)
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.LISTS.ITEMS(String(listId)))
|
||||||
|
expect(store.itemsByList[listId]).toEqual(mockItems)
|
||||||
|
})
|
||||||
|
})
|
93
fe/src/stores/activityStore.ts
Normal file
93
fe/src/stores/activityStore.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { activityService } from '@/services/activityService'
|
||||||
|
import { API_BASE_URL } from '@/config/api-config'
|
||||||
|
import type { Activity } from '@/types/activity'
|
||||||
|
|
||||||
|
export const useActivityStore = defineStore('activity', () => {
|
||||||
|
const activities = ref<Activity[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const cursor = ref<number | null>(null)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const currentGroupId = ref<number | null>(null)
|
||||||
|
const socket = ref<WebSocket | null>(null)
|
||||||
|
|
||||||
|
async function fetchActivities(groupId: number, limit: number = 20) {
|
||||||
|
if (isLoading.value || !hasMore.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
if (groupId !== currentGroupId.value) {
|
||||||
|
// Reset if group changes
|
||||||
|
activities.value = []
|
||||||
|
cursor.value = null
|
||||||
|
hasMore.value = true
|
||||||
|
}
|
||||||
|
currentGroupId.value = groupId
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await activityService.getGroupActivity(groupId, limit, cursor.value)
|
||||||
|
activities.value.push(...response.items)
|
||||||
|
cursor.value = response.cursor
|
||||||
|
if (!response.cursor || response.items.length < limit) {
|
||||||
|
hasMore.value = false
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to fetch activities'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(groupId: number, token: string | null): string {
|
||||||
|
const base = import.meta.env.DEV ? `ws://${location.host}/ws/${groupId}` : `${API_BASE_URL.replace('https', 'wss').replace('http', 'ws')}/ws/${groupId}`
|
||||||
|
return token ? `${base}?token=${token}` : base
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket(groupId: number, token: string | null) {
|
||||||
|
if (socket.value) {
|
||||||
|
socket.value.close()
|
||||||
|
}
|
||||||
|
const url = buildWsUrl(groupId, token)
|
||||||
|
socket.value = new WebSocket(url)
|
||||||
|
socket.value.onopen = () => {
|
||||||
|
console.debug('[WS] Connected to', url)
|
||||||
|
}
|
||||||
|
socket.value.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data && data.event_type) {
|
||||||
|
activities.value.unshift(data as Activity)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] failed to parse message', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.value.onclose = () => {
|
||||||
|
console.debug('[WS] closed')
|
||||||
|
socket.value = null
|
||||||
|
}
|
||||||
|
socket.value.onerror = (e) => {
|
||||||
|
console.error('[WS] error', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWebSocket() {
|
||||||
|
if (socket.value) {
|
||||||
|
socket.value.close()
|
||||||
|
socket.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activities,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
hasMore,
|
||||||
|
fetchActivities,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
}
|
||||||
|
})
|
@ -141,6 +141,19 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
await router.push('/auth/login')
|
await router.push('/auth/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestMagicLink = async (email: string) => {
|
||||||
|
const response = await api.post(API_ENDPOINTS.AUTH.MAGIC_LINK, { email })
|
||||||
|
return response.data as { detail: string; token: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyMagicLink = async (token: string) => {
|
||||||
|
const response = await api.get(API_ENDPOINTS.AUTH.MAGIC_LINK_VERIFY, { params: { token } })
|
||||||
|
const { access_token, refresh_token } = response.data
|
||||||
|
setTokens({ access_token, refresh_token })
|
||||||
|
await fetchCurrentUser()
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
user,
|
user,
|
||||||
@ -158,5 +171,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
loginAsGuest,
|
loginAsGuest,
|
||||||
signup,
|
signup,
|
||||||
logout,
|
logout,
|
||||||
|
requestMagicLink,
|
||||||
|
verifyMagicLink,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
211
fe/src/stores/choreStore.ts
Normal file
211
fe/src/stores/choreStore.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment } from '@/types/chore'
|
||||||
|
import { choreService } from '@/services/choreService'
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||||
|
import type { TimeEntry } from '@/types/time_entry'
|
||||||
|
|
||||||
|
export const useChoreStore = defineStore('chores', () => {
|
||||||
|
// ---- State ----
|
||||||
|
const personal = ref<Chore[]>([])
|
||||||
|
const groupById = ref<Record<number, Chore[]>>({})
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const timeEntriesByAssignment = ref<Record<number, TimeEntry[]>>({})
|
||||||
|
|
||||||
|
// ---- Getters ----
|
||||||
|
const allChores = computed<Chore[]>(() => [
|
||||||
|
...personal.value,
|
||||||
|
...Object.values(groupById.value).flat(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const activeTimerEntry = computed<TimeEntry | null>(() => {
|
||||||
|
for (const assignmentId in timeEntriesByAssignment.value) {
|
||||||
|
const entry = timeEntriesByAssignment.value[assignmentId].find((te) => !te.end_time);
|
||||||
|
if (entry) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setError(message: string) {
|
||||||
|
error.value = message
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
async function fetchPersonal() {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
personal.value = await choreService.getPersonalChores()
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to load personal chores')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGroup(groupId: number) {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const chores = await choreService.getChores(groupId)
|
||||||
|
groupById.value[groupId] = chores
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to load group chores')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(chore: ChoreCreate) {
|
||||||
|
try {
|
||||||
|
const created = await choreService.createChore(chore)
|
||||||
|
if (created.type === 'personal') {
|
||||||
|
personal.value.push(created)
|
||||||
|
} else if (created.group_id != null) {
|
||||||
|
if (!groupById.value[created.group_id]) groupById.value[created.group_id] = []
|
||||||
|
groupById.value[created.group_id].push(created)
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to create chore')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(choreId: number, updates: ChoreUpdate, original: Chore) {
|
||||||
|
try {
|
||||||
|
const updated = await choreService.updateChore(choreId, updates, original)
|
||||||
|
// Remove from previous list
|
||||||
|
if (original.type === 'personal') {
|
||||||
|
personal.value = personal.value.filter((c) => c.id !== choreId)
|
||||||
|
} else if (original.group_id != null) {
|
||||||
|
groupById.value[original.group_id] = (groupById.value[original.group_id] || []).filter((c) => c.id !== choreId)
|
||||||
|
}
|
||||||
|
// Add to new list
|
||||||
|
if (updated.type === 'personal') {
|
||||||
|
personal.value.push(updated)
|
||||||
|
} else if (updated.group_id != null) {
|
||||||
|
if (!groupById.value[updated.group_id]) groupById.value[updated.group_id] = []
|
||||||
|
groupById.value[updated.group_id].push(updated)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to update chore')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(choreId: number, choreType: ChoreType, groupId?: number) {
|
||||||
|
try {
|
||||||
|
await choreService.deleteChore(choreId, choreType, groupId)
|
||||||
|
if (choreType === 'personal') {
|
||||||
|
personal.value = personal.value.filter((c) => c.id !== choreId)
|
||||||
|
} else if (groupId != null) {
|
||||||
|
groupById.value[groupId] = (groupById.value[groupId] || []).filter((c) => c.id !== choreId)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to delete chore')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTimeEntries(assignmentId: number) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`)
|
||||||
|
timeEntriesByAssignment.value[assignmentId] = response.data
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || `Failed to fetch time entries for assignment ${assignmentId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTimer(assignmentId: number) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`)
|
||||||
|
if (!timeEntriesByAssignment.value[assignmentId]) {
|
||||||
|
timeEntriesByAssignment.value[assignmentId] = []
|
||||||
|
}
|
||||||
|
timeEntriesByAssignment.value[assignmentId].push(response.data)
|
||||||
|
return response.data
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || `Failed to start timer for assignment ${assignmentId}`)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTimer(assignmentId: number, timeEntryId: number) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`${API_ENDPOINTS.CHORES.TIME_ENTRY(timeEntryId)}`)
|
||||||
|
const entries = timeEntriesByAssignment.value[assignmentId] || []
|
||||||
|
const index = entries.findIndex((t) => t.id === timeEntryId)
|
||||||
|
if (index > -1) {
|
||||||
|
entries[index] = response.data
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || `Failed to stop timer for entry ${timeEntryId}`)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCompletion(chore: Chore, assignment: ChoreAssignment | null) {
|
||||||
|
try {
|
||||||
|
let currentAssignment = assignment;
|
||||||
|
if (!currentAssignment) {
|
||||||
|
// create assignment default user
|
||||||
|
currentAssignment = await choreService.createAssignment({
|
||||||
|
chore_id: chore.id,
|
||||||
|
assigned_to_user_id: chore.created_by_id,
|
||||||
|
due_date: chore.next_due_date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const newStatus = !currentAssignment.is_complete;
|
||||||
|
let updatedAssignment: ChoreAssignment;
|
||||||
|
if (newStatus) {
|
||||||
|
updatedAssignment = await choreService.completeAssignment(currentAssignment.id);
|
||||||
|
} else {
|
||||||
|
updatedAssignment = await choreService.updateAssignment(currentAssignment.id, { is_complete: false });
|
||||||
|
}
|
||||||
|
// update local state: find chore and update assignment status.
|
||||||
|
function applyToList(list: Chore[]) {
|
||||||
|
const cIndex = list.findIndex((c) => c.id === chore.id);
|
||||||
|
if (cIndex === -1) return;
|
||||||
|
const c = list[cIndex];
|
||||||
|
const aIndex = c.assignments.findIndex((a) => a.id === updatedAssignment.id);
|
||||||
|
if (aIndex !== -1) {
|
||||||
|
c.assignments[aIndex] = updatedAssignment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chore.type === 'personal') {
|
||||||
|
applyToList(personal.value);
|
||||||
|
} else if (chore.group_id != null) {
|
||||||
|
applyToList(groupById.value[chore.group_id] || []);
|
||||||
|
}
|
||||||
|
return updatedAssignment;
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Failed to toggle completion');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
personal,
|
||||||
|
groupById,
|
||||||
|
allChores,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchPersonal,
|
||||||
|
fetchGroup,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
setError,
|
||||||
|
timeEntriesByAssignment,
|
||||||
|
activeTimerEntry,
|
||||||
|
fetchTimeEntries,
|
||||||
|
startTimer,
|
||||||
|
stopTimer,
|
||||||
|
toggleCompletion
|
||||||
|
}
|
||||||
|
})
|
@ -1,37 +1,52 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { groupService, type Group } from '@/services/groupService';
|
import { ref, computed } from 'vue';
|
||||||
|
import { groupService } from '@/services/groupService';
|
||||||
|
import type { GroupPublic as Group } from '@/types/group';
|
||||||
|
|
||||||
export const useGroupStore = defineStore('group', {
|
export const useGroupStore = defineStore('group', () => {
|
||||||
state: () => ({
|
const groups = ref<Group[]>([]);
|
||||||
groups: [] as Group[],
|
const isLoading = ref(false);
|
||||||
isLoading: false,
|
const currentGroupId = ref<number | null>(null);
|
||||||
error: null as Error | null,
|
|
||||||
}),
|
const fetchUserGroups = async () => {
|
||||||
actions: {
|
isLoading.value = true;
|
||||||
async fetchGroups() {
|
try {
|
||||||
// Small cache implemented to prevent re-fetching on every mount
|
groups.value = await groupService.getUserGroups();
|
||||||
if (this.groups.length > 0) {
|
// Set the first group as current by default if not already set
|
||||||
return;
|
if (!currentGroupId.value && groups.value.length > 0) {
|
||||||
|
currentGroupId.value = groups.value[0].id;
|
||||||
}
|
}
|
||||||
this.isLoading = true;
|
} catch (error) {
|
||||||
this.error = null;
|
console.error('Failed to fetch groups:', error);
|
||||||
try {
|
} finally {
|
||||||
this.groups = await groupService.getUserGroups();
|
isLoading.value = false;
|
||||||
} catch (error) {
|
|
||||||
this.error = error as Error;
|
|
||||||
console.error('Failed to fetch groups:', error);
|
|
||||||
} finally {
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getters: {
|
|
||||||
groupCount: (state) => state.groups.length,
|
|
||||||
firstGroupId: (state): number | null => {
|
|
||||||
if (state.groups.length === 1) {
|
|
||||||
return state.groups[0].id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const setCurrentGroupId = (id: number) => {
|
||||||
|
currentGroupId.value = id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentGroup = computed(() => {
|
||||||
|
if (!currentGroupId.value) return null;
|
||||||
|
return groups.value.find(g => g.id === currentGroupId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupCount = computed(() => groups.value.length);
|
||||||
|
const firstGroupId = computed(() => groups.value[0]?.id ?? null);
|
||||||
|
|
||||||
|
// Alias for backward compatibility
|
||||||
|
const fetchGroups = fetchUserGroups;
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
isLoading,
|
||||||
|
currentGroupId,
|
||||||
|
currentGroup,
|
||||||
|
fetchUserGroups,
|
||||||
|
fetchGroups,
|
||||||
|
setCurrentGroupId,
|
||||||
|
groupCount,
|
||||||
|
firstGroupId,
|
||||||
|
};
|
||||||
});
|
});
|
111
fe/src/stores/itemStore.ts
Normal file
111
fe/src/stores/itemStore.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Item } from '@/types/item'
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||||
|
|
||||||
|
interface CreateItemPayload {
|
||||||
|
listId: number
|
||||||
|
data: {
|
||||||
|
name: string
|
||||||
|
quantity?: string | number | null
|
||||||
|
category_id?: number | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateItemPayload {
|
||||||
|
listId: number
|
||||||
|
itemId: number
|
||||||
|
data: Partial<
|
||||||
|
Omit<
|
||||||
|
Item,
|
||||||
|
| 'id'
|
||||||
|
| 'list_id'
|
||||||
|
| 'added_by_id'
|
||||||
|
| 'completed_by_id'
|
||||||
|
| 'added_by_user'
|
||||||
|
| 'completed_by_user'
|
||||||
|
| 'created_at'
|
||||||
|
| 'updated_at'
|
||||||
|
> & { version: number }
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useItemStore = defineStore('items', () => {
|
||||||
|
const itemsByList = ref<Record<number, Item[]>>({})
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function setError(message: string) {
|
||||||
|
error.value = message
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchItems(listId: number) {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const endpoint = API_ENDPOINTS.LISTS.ITEMS(String(listId))
|
||||||
|
const { data } = await apiClient.get(endpoint)
|
||||||
|
itemsByList.value[listId] = data as Item[]
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.response?.data?.detail || e.message || 'Failed to load items')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addItem(payload: CreateItemPayload) {
|
||||||
|
const { listId, data } = payload
|
||||||
|
try {
|
||||||
|
const endpoint = API_ENDPOINTS.LISTS.ITEMS(String(listId))
|
||||||
|
const { data: created } = await apiClient.post(endpoint, data)
|
||||||
|
if (!itemsByList.value[listId]) itemsByList.value[listId] = []
|
||||||
|
itemsByList.value[listId].push(created as Item)
|
||||||
|
return created as Item
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.response?.data?.detail || e.message || 'Failed to add item')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateItem(payload: UpdateItemPayload) {
|
||||||
|
const { listId, itemId, data } = payload
|
||||||
|
try {
|
||||||
|
const endpoint = API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId))
|
||||||
|
const { data: updated } = await apiClient.put(endpoint, data)
|
||||||
|
const arr = itemsByList.value[listId]
|
||||||
|
if (arr) {
|
||||||
|
const idx = arr.findIndex((it) => it.id === itemId)
|
||||||
|
if (idx !== -1) arr[idx] = updated as Item
|
||||||
|
}
|
||||||
|
return updated as Item
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.response?.data?.detail || e.message || 'Failed to update item')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(listId: number, itemId: number, expectedVersion?: number) {
|
||||||
|
try {
|
||||||
|
const endpoint = API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId))
|
||||||
|
const config = expectedVersion ? { params: { expected_version: expectedVersion } } : {}
|
||||||
|
await apiClient.delete(endpoint, config)
|
||||||
|
const arr = itemsByList.value[listId]
|
||||||
|
if (arr) itemsByList.value[listId] = arr.filter((it) => it.id !== itemId)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.response?.data?.detail || e.message || 'Failed to delete item')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemsByList,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchItems,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
setError,
|
||||||
|
}
|
||||||
|
})
|
311
fe/src/stores/listsStore.ts
Normal file
311
fe/src/stores/listsStore.ts
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
|
import type { List } from '@/types/list';
|
||||||
|
import type { Item } from '@/types/item';
|
||||||
|
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
|
||||||
|
import { useAuthStore } from './auth';
|
||||||
|
import { API_BASE_URL } from '@/config/api-config';
|
||||||
|
|
||||||
|
export interface ListWithDetails extends List {
|
||||||
|
items: Item[];
|
||||||
|
expenses: Expense[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useListsStore = defineStore('lists', () => {
|
||||||
|
// --- STATE ---
|
||||||
|
const currentList = ref<ListWithDetails | null>(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const isSettlingSplit = ref(false);
|
||||||
|
const socket = ref<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// Properties to support legacy polling logic in ListDetailPage, will be removed later.
|
||||||
|
const lastListUpdate = ref<string | null>(null);
|
||||||
|
const lastItemCount = ref<number | null>(null);
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const items = computed(() => currentList.value?.items || []);
|
||||||
|
const expenses = computed(() => currentList.value?.expenses || []);
|
||||||
|
|
||||||
|
function getPaidAmountForSplit(splitId: number): number {
|
||||||
|
let totalPaid = 0;
|
||||||
|
if (currentList.value && currentList.value.expenses) {
|
||||||
|
for (const expense of currentList.value.expenses) {
|
||||||
|
const split = expense.splits.find((s) => s.id === splitId);
|
||||||
|
if (split && split.settlement_activities) {
|
||||||
|
totalPaid = split.settlement_activities.reduce((sum, activity) => {
|
||||||
|
return sum + parseFloat(activity.amount_paid);
|
||||||
|
}, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalPaid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ACTIONS ---
|
||||||
|
async function fetchListDetails(listId: string) {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const listResponse = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(listId));
|
||||||
|
const itemsResponse = await apiClient.get(API_ENDPOINTS.LISTS.ITEMS(listId));
|
||||||
|
const expensesResponse = await apiClient.get(API_ENDPOINTS.LISTS.EXPENSES(listId));
|
||||||
|
|
||||||
|
currentList.value = {
|
||||||
|
...listResponse.data,
|
||||||
|
items: itemsResponse.data,
|
||||||
|
expenses: expensesResponse.data,
|
||||||
|
};
|
||||||
|
// Update polling properties
|
||||||
|
lastListUpdate.value = listResponse.data.updated_at;
|
||||||
|
lastItemCount.value = itemsResponse.data.length;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to fetch list details.';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimItem(itemId: number) {
|
||||||
|
const item = currentList.value?.items.find((i: Item) => i.id === itemId);
|
||||||
|
if (!item || !currentList.value) return;
|
||||||
|
|
||||||
|
const originalClaimedById = item.claimed_by_user_id;
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const userId = Number(authStore.user!.id);
|
||||||
|
item.claimed_by_user_id = userId;
|
||||||
|
item.claimed_by_user = { id: userId, name: authStore.user!.name, email: authStore.user!.email };
|
||||||
|
item.claimed_at = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/items/${itemId}/claim`);
|
||||||
|
item.version = response.data.version;
|
||||||
|
} catch (err: any) {
|
||||||
|
item.claimed_by_user_id = originalClaimedById;
|
||||||
|
item.claimed_by_user = null;
|
||||||
|
item.claimed_at = null;
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to claim item.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unclaimItem(itemId: number) {
|
||||||
|
const item = currentList.value?.items.find((i: Item) => i.id === itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const originalClaimedById = item.claimed_by_user_id;
|
||||||
|
const originalClaimedByUser = item.claimed_by_user;
|
||||||
|
const originalClaimedAt = item.claimed_at;
|
||||||
|
|
||||||
|
item.claimed_by_user_id = null;
|
||||||
|
item.claimed_by_user = null;
|
||||||
|
item.claimed_at = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/items/${itemId}/claim`);
|
||||||
|
item.version = response.data.version;
|
||||||
|
} catch (err: any) {
|
||||||
|
item.claimed_by_user_id = originalClaimedById;
|
||||||
|
item.claimed_by_user = originalClaimedByUser;
|
||||||
|
item.claimed_at = originalClaimedAt;
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to unclaim item.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleExpenseSplit(payload: { expense_split_id: number; activity_data: SettlementActivityCreate }) {
|
||||||
|
isSettlingSplit.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const endpoint = `/financials/expense_splits/${payload.expense_split_id}/settle`;
|
||||||
|
await apiClient.post(endpoint, payload.activity_data);
|
||||||
|
if (currentList.value?.id) {
|
||||||
|
await fetchListDetails(String(currentList.value.id));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to settle expense split.';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isSettlingSplit.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(listId: number, token: string | null): string {
|
||||||
|
const base = import.meta.env.DEV
|
||||||
|
? `ws://${location.host}/ws/lists/${listId}`
|
||||||
|
: `${API_BASE_URL.replace(/^http/, 'ws')}/ws/lists/${listId}`;
|
||||||
|
return token ? `${base}?token=${token}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket(listId: number, token: string | null) {
|
||||||
|
if (socket.value) {
|
||||||
|
socket.value.close();
|
||||||
|
}
|
||||||
|
const url = buildWsUrl(listId, token);
|
||||||
|
socket.value = new WebSocket(url);
|
||||||
|
socket.value.onopen = () => console.debug(`[WS] Connected to list channel ${listId}`);
|
||||||
|
|
||||||
|
socket.value.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'item_claimed') {
|
||||||
|
handleItemClaimed(data.payload);
|
||||||
|
} else if (data.type === 'item_unclaimed') {
|
||||||
|
handleItemUnclaimed(data.payload);
|
||||||
|
}
|
||||||
|
// Handle other item events here
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WS] Failed to parse message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.value.onclose = () => {
|
||||||
|
console.debug(`[WS] Disconnected from list channel ${listId}`);
|
||||||
|
socket.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.value.onerror = (e) => console.error('[WS] Error:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWebSocket() {
|
||||||
|
if (socket.value) {
|
||||||
|
socket.value.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WEBSOCKET HANDLERS ---
|
||||||
|
function handleItemClaimed(payload: any) {
|
||||||
|
if (!currentList.value) return;
|
||||||
|
const item = currentList.value.items.find((i: Item) => i.id === payload.item_id);
|
||||||
|
if (item) {
|
||||||
|
item.claimed_by_user_id = payload.claimed_by.id;
|
||||||
|
item.claimed_by_user = payload.claimed_by;
|
||||||
|
item.claimed_at = payload.claimed_at;
|
||||||
|
item.version = payload.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemUnclaimed(payload: any) {
|
||||||
|
const item = items.value.find((i: Item) => i.id === payload.item_id);
|
||||||
|
if (item) {
|
||||||
|
item.claimed_by_user_id = null;
|
||||||
|
item.claimed_by_user = null;
|
||||||
|
item.claimed_at = null;
|
||||||
|
item.version = payload.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addItem(listId: string | number, payload: { name: string; quantity?: string | null; category_id?: number | null }) {
|
||||||
|
if (!currentList.value) return;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
// Optimistic: push placeholder item with temporary id (-Date.now())
|
||||||
|
const tempId = -Date.now();
|
||||||
|
const tempItem: Item = {
|
||||||
|
id: tempId,
|
||||||
|
name: payload.name,
|
||||||
|
quantity: payload.quantity ?? null,
|
||||||
|
is_complete: false,
|
||||||
|
price: null,
|
||||||
|
list_id: Number(listId),
|
||||||
|
category_id: payload.category_id ?? null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
completed_at: null,
|
||||||
|
version: 0,
|
||||||
|
added_by_id: useAuthStore().user?.id ?? 0,
|
||||||
|
completed_by_id: null,
|
||||||
|
} as unknown as Item;
|
||||||
|
currentList.value.items.push(tempItem);
|
||||||
|
|
||||||
|
const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(listId)), payload);
|
||||||
|
// Replace temp item with actual item
|
||||||
|
const index = currentList.value.items.findIndex(i => i.id === tempId);
|
||||||
|
if (index !== -1) {
|
||||||
|
currentList.value.items[index] = response.data;
|
||||||
|
} else {
|
||||||
|
currentList.value.items.push(response.data);
|
||||||
|
}
|
||||||
|
// Update lastListUpdate etc.
|
||||||
|
lastItemCount.value = currentList.value.items.length;
|
||||||
|
lastListUpdate.value = response.data.updated_at;
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
// Rollback optimistic
|
||||||
|
currentList.value.items = currentList.value.items.filter(i => i.id >= 0);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to add item.';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateItem(listId: string | number, itemId: number, updates: Partial<Item> & { version: number }) {
|
||||||
|
if (!currentList.value) return;
|
||||||
|
error.value = null;
|
||||||
|
const item = currentList.value.items.find(i => i.id === itemId);
|
||||||
|
if (!item) return;
|
||||||
|
const original = { ...item };
|
||||||
|
// Optimistic merge
|
||||||
|
Object.assign(item, updates);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId)), updates);
|
||||||
|
// ensure store item updated with server version
|
||||||
|
const index = currentList.value.items.findIndex(i => i.id === itemId);
|
||||||
|
if (index !== -1) {
|
||||||
|
currentList.value.items[index] = response.data;
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
// rollback
|
||||||
|
const index = currentList.value.items.findIndex(i => i.id === itemId);
|
||||||
|
if (index !== -1) {
|
||||||
|
currentList.value.items[index] = original;
|
||||||
|
}
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to update item.';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(listId: string | number, itemId: number, version: number) {
|
||||||
|
if (!currentList.value) return;
|
||||||
|
error.value = null;
|
||||||
|
const index = currentList.value.items.findIndex(i => i.id === itemId);
|
||||||
|
if (index === -1) return;
|
||||||
|
const [removed] = currentList.value.items.splice(index, 1);
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`${API_ENDPOINTS.LISTS.ITEM(String(listId), String(itemId))}?expected_version=${version}`);
|
||||||
|
lastItemCount.value = currentList.value.items.length;
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
// rollback
|
||||||
|
currentList.value.items.splice(index, 0, removed);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to delete item.';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentList,
|
||||||
|
items,
|
||||||
|
expenses,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isSettlingSplit,
|
||||||
|
lastListUpdate,
|
||||||
|
lastItemCount,
|
||||||
|
fetchListDetails,
|
||||||
|
claimItem,
|
||||||
|
unclaimItem,
|
||||||
|
settleExpenseSplit,
|
||||||
|
getPaidAmountForSplit,
|
||||||
|
handleItemClaimed,
|
||||||
|
handleItemUnclaimed,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
deleteItem,
|
||||||
|
};
|
||||||
|
});
|
@ -1,79 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
|
||||||
import type { TimeEntry, TimeEntryCreate, TimeEntryUpdate } from '@/types';
|
|
||||||
|
|
||||||
export interface TimeEntry {
|
|
||||||
id: number;
|
|
||||||
chore_assignment_id: number;
|
|
||||||
user_id: number;
|
|
||||||
start_time: string;
|
|
||||||
end_time?: string | null;
|
|
||||||
duration_seconds?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTimeEntryStore = defineStore('timeEntry', () => {
|
|
||||||
const timeEntries = ref<Record<number, TimeEntry[]>>({});
|
|
||||||
const loading = ref(false);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
|
|
||||||
async function fetchTimeEntriesForAssignment(assignmentId: number) {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`);
|
|
||||||
timeEntries.value[assignmentId] = response.data;
|
|
||||||
} catch (e) {
|
|
||||||
error.value = 'Failed to fetch time entries.';
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startTimeEntry(assignmentId: number) {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const response = await apiClient.post(`${API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId)}/time-entries`);
|
|
||||||
if (!timeEntries.value[assignmentId]) {
|
|
||||||
timeEntries.value[assignmentId] = [];
|
|
||||||
}
|
|
||||||
timeEntries.value[assignmentId].push(response.data);
|
|
||||||
} catch (e) {
|
|
||||||
error.value = 'Failed to start time entry.';
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopTimeEntry(assignmentId: number, timeEntryId: number) {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const response = await apiClient.put(`${API_ENDPOINTS.CHORES.TIME_ENTRY(timeEntryId)}`);
|
|
||||||
const assignmentEntries = timeEntries.value[assignmentId];
|
|
||||||
if (assignmentEntries) {
|
|
||||||
const index = assignmentEntries.findIndex(te => te.id === timeEntryId);
|
|
||||||
if (index !== -1) {
|
|
||||||
assignmentEntries[index] = response.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error.value = 'Failed to stop time entry.';
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeEntries,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
fetchTimeEntriesForAssignment,
|
|
||||||
startTimeEntry,
|
|
||||||
stopTimeEntry,
|
|
||||||
};
|
|
||||||
});
|
|
28
fe/src/types/activity.ts
Normal file
28
fe/src/types/activity.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
export enum ActivityEventType {
|
||||||
|
CHORE_COMPLETED = "chore_completed",
|
||||||
|
CHORE_CREATED = "chore_created",
|
||||||
|
EXPENSE_CREATED = "expense_created",
|
||||||
|
EXPENSE_SETTLED = "expense_settled",
|
||||||
|
ITEM_ADDED = "item_added",
|
||||||
|
ITEM_COMPLETED = "item_completed",
|
||||||
|
USER_JOINED_GROUP = "user_joined_group",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityUser {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: string;
|
||||||
|
event_type: ActivityEventType;
|
||||||
|
timestamp: string; // ISO 8601 date string
|
||||||
|
user: ActivityUser;
|
||||||
|
details: Record<string, any>;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedActivityResponse {
|
||||||
|
items: Activity[];
|
||||||
|
cursor: number | null;
|
||||||
|
}
|
@ -77,6 +77,7 @@ export interface ChoreWithCompletion extends Chore {
|
|||||||
updating: boolean;
|
updating: boolean;
|
||||||
assigned_user_name?: string;
|
assigned_user_name?: string;
|
||||||
completed_by_name?: string;
|
completed_by_name?: string;
|
||||||
|
assigned_to_user_id?: number | null;
|
||||||
parent_chore_id?: number | null;
|
parent_chore_id?: number | null;
|
||||||
child_chores?: ChoreWithCompletion[];
|
child_chores?: ChoreWithCompletion[];
|
||||||
subtext?: string;
|
subtext?: string;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import type { UserPublic } from './user';
|
||||||
|
|
||||||
export interface UserReference {
|
export interface UserReference {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -13,9 +15,13 @@ export interface Item {
|
|||||||
category_id?: number | null;
|
category_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
completed_at?: string | null;
|
||||||
version: number;
|
version: number;
|
||||||
added_by_id?: number;
|
added_by_id?: number;
|
||||||
completed_by_id?: number | null;
|
completed_by_id?: number | null;
|
||||||
added_by_user?: { id: number; name: string };
|
added_by_user?: UserPublic;
|
||||||
completed_by_user?: { id: number; name: string } | null;
|
completed_by_user?: UserPublic | null;
|
||||||
|
claimed_by_user_id?: number | null;
|
||||||
|
claimed_at?: string | null;
|
||||||
|
claimed_by_user?: UserPublic | null;
|
||||||
}
|
}
|
||||||
|
9
fe/src/types/time_entry.ts
Normal file
9
fe/src/types/time_entry.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface TimeEntry {
|
||||||
|
id: number;
|
||||||
|
chore_assignment_id: number;
|
||||||
|
start_time: string; // ISO
|
||||||
|
end_time: string | null;
|
||||||
|
duration_seconds?: number | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user