Merge pull request 'feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support' (#66) from ph5 into prod

Reviewed-on: #66
This commit is contained in:
mo 2025-06-09 21:03:27 +02:00
commit 453ce9e45f
68 changed files with 3110 additions and 509 deletions

32
.cursor/rules/fastapi.mdc Normal file
View File

@ -0,0 +1,32 @@
---
description:
globs:
alwaysApply: true
---
# FastAPI-Specific Guidelines:
- Use functional components (plain functions) and Pydantic models for input validation and response schemas.
- Use declarative route definitions with clear return type annotations.
- Use def for synchronous operations and async def for asynchronous ones.
- Minimize @app.on_event("startup") and @app.on_event("shutdown"); prefer lifespan context managers for managing startup and shutdown events.
- Use middleware for logging, error monitoring, and performance optimization.
- Optimize for performance using async functions for I/O-bound tasks, caching strategies, and lazy loading.
- Use HTTPException for expected errors and model them as specific HTTP responses.
- Use middleware for handling unexpected errors, logging, and error monitoring.
- Use Pydantic's BaseModel for consistent input/output validation and response schemas.
Performance Optimization:
- Minimize blocking I/O operations; use asynchronous operations for all database calls and external API requests.
- Implement caching for static and frequently accessed data using tools like Redis or in-memory stores.
- Optimize data serialization and deserialization with Pydantic.
- Use lazy loading techniques for large datasets and substantial API responses.
Key Conventions
1. Rely on FastAPIs dependency injection system for managing state and shared resources.
2. Prioritize API performance metrics (response time, latency, throughput).
3. Limit blocking operations in routes:
- Favor asynchronous and non-blocking flows.
- Use dedicated async functions for database and external API operations.
- Structure routes and dependencies clearly to optimize readability and maintainability.
Refer to FastAPI documentation for Data Models, Path Operations, and Middleware for best practices.

37
.cursor/rules/vue.mdc Normal file
View File

@ -0,0 +1,37 @@
---
description:
globs:
alwaysApply: true
---
You have extensive expertise in Vue 3, TypeScript, Node.js, Vite, Vue Router, Pinia, VueUse, and CSS. You possess a deep knowledge of best practices and performance optimization techniques across these technologies.
Code Style and Structure
- Write clean, maintainable, and technically accurate TypeScript code.
- Emphasize iteration and modularization and minimize code duplication.
- Prefer Composition API <script setup> style.
- Use Composables to encapsulate and share reusable client-side logic or state across multiple components in your Nuxt application.
Fetching Data
1. Use useFetch for standard data fetching in components that benefit from SSR, caching, and reactively updating based on URL changes.
2. Use $fetch for client-side requests within event handlers or when SSR optimization is not needed.
3. Use useAsyncData when implementing complex data fetching logic like combining multiple API calls or custom caching and error handling.
4. Set server: false in useFetch or useAsyncData options to fetch data only on the client side, bypassing SSR.
5. Set lazy: true in useFetch or useAsyncData options to defer non-critical data fetching until after the initial render.
Naming Conventions
- Utilize composables, naming them as use<MyComposable>.
- Use **PascalCase** for component file names (e.g., components/MyComponent.vue).
- Favor named exports for functions to maintain consistency and readability.
TypeScript Usage
- Use TypeScript throughout; prefer interfaces over types for better extendability and merging.
- Avoid enums, opting for maps for improved type safety and flexibility.
- Use functional components with TypeScript interfaces.
UI and Styling.
- Implement responsive design; use a mobile-first approach.

View File

@ -0,0 +1,91 @@
"""feature_updates_phase1
Revision ID: bdf7427ccfa3
Revises: 05bf96a9e18b
Create Date: 2025-06-09 18:00:11.083651
"""
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 = 'bdf7427ccfa3'
down_revision: Union[str, None] = '05bf96a9e18b'
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.create_table('financial_audit_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('action_type', sa.String(), nullable=False),
sa.Column('entity_type', sa.String(), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('details', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_financial_audit_log_action_type'), 'financial_audit_log', ['action_type'], unique=False)
op.create_index(op.f('ix_financial_audit_log_id'), 'financial_audit_log', ['id'], unique=False)
op.create_table('categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope')
)
op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=False)
op.create_table('time_entries',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chore_assignment_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['chore_assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_time_entries_id'), 'time_entries', ['id'], unique=False)
op.add_column('chores', sa.Column('parent_chore_id', sa.Integer(), nullable=True))
op.create_index(op.f('ix_chores_parent_chore_id'), 'chores', ['parent_chore_id'], unique=False)
op.create_foreign_key(None, 'chores', 'chores', ['parent_chore_id'], ['id'])
op.add_column('items', sa.Column('category_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'items', 'categories', ['category_id'], ['id'])
op.add_column('lists', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_lists_archived_at'), 'lists', ['archived_at'], unique=False)
op.add_column('users', sa.Column('is_guest', sa.Boolean(), nullable=False, server_default='f'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'is_guest')
op.drop_index(op.f('ix_lists_archived_at'), table_name='lists')
op.drop_column('lists', 'archived_at')
op.drop_constraint(None, 'items', type_='foreignkey')
op.drop_column('items', 'category_id')
op.drop_constraint(None, 'chores', type_='foreignkey')
op.drop_index(op.f('ix_chores_parent_chore_id'), table_name='chores')
op.drop_column('chores', 'parent_chore_id')
op.drop_index(op.f('ix_time_entries_id'), table_name='time_entries')
op.drop_table('time_entries')
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_id'), table_name='categories')
op.drop_table('categories')
op.drop_index(op.f('ix_financial_audit_log_id'), table_name='financial_audit_log')
op.drop_index(op.f('ix_financial_audit_log_action_type'), table_name='financial_audit_log')
op.drop_table('financial_audit_log')
# ### end Alembic commands ###

View File

@ -0,0 +1,51 @@
"""add_updated_at_and_version_to_groups
Revision ID: c693ade3601c
Revises: bdf7427ccfa3
Create Date: 2025-06-09 19:22:36.244072
"""
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 = 'c693ade3601c'
down_revision: Union[str, None] = 'bdf7427ccfa3'
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.add_column('groups', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
op.add_column('groups', sa.Column('version', sa.Integer(), server_default='1', nullable=False))
op.alter_column('users', 'is_guest',
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_guest',
existing_type=sa.BOOLEAN(),
server_default=sa.text('false'),
existing_nullable=False)
op.drop_column('groups', 'version')
op.drop_column('groups', 'updated_at')
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 ###

55
be/app/api/auth/guest.py Normal file
View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from app import models
from app.schemas.user import UserCreate, UserClaim, UserPublic
from app.schemas.token import Token
from app.database import get_session
from app.auth import current_active_user
from app.core.security import create_access_token, get_password_hash
from app.crud import user as crud_user
router = APIRouter()
@router.post("/guest", response_model=Token)
async def create_guest_user(db: AsyncSession = Depends(get_session)):
"""
Creates a new guest user.
"""
guest_email = f"guest_{uuid.uuid4()}@guest.mitlist.app"
guest_password = uuid.uuid4().hex
user_in = UserCreate(email=guest_email, password=guest_password)
user = await crud_user.create_user(db, user_in=user_in, is_guest=True)
access_token = create_access_token(data={"sub": user.email})
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/guest/claim", response_model=UserPublic)
async def claim_guest_account(
claim_in: UserClaim,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
"""
Claims a guest account, converting it to a full user.
"""
if not current_user.is_guest:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Not a guest account.")
existing_user = await crud_user.get_user_by_email(db, email=claim_in.email)
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered.")
hashed_password = get_password_hash(claim_in.password)
current_user.email = claim_in.email
current_user.hashed_password = hashed_password
current_user.is_guest = False
current_user.is_verified = False # Require email verification
db.add(current_user)
await db.commit()
await db.refresh(current_user)
return current_user

26
be/app/api/auth/jwt.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter
from app.auth import auth_backend, fastapi_users
from app.schemas.user import UserCreate, UserPublic, UserUpdate
router = APIRouter()
router.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/jwt",
tags=["auth"],
)
router.include_router(
fastapi_users.get_register_router(UserPublic, UserCreate),
prefix="",
tags=["auth"],
)
router.include_router(
fastapi_users.get_reset_password_router(),
prefix="",
tags=["auth"],
)
router.include_router(
fastapi_users.get_verify_router(UserPublic),
prefix="",
tags=["auth"],
)

View File

@ -9,7 +9,10 @@ from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import costs from app.api.v1.endpoints import costs
from app.api.v1.endpoints import financials from app.api.v1.endpoints import financials
from app.api.v1.endpoints import chores from app.api.v1.endpoints import chores
from app.api.auth import oauth from app.api.v1.endpoints import history
from app.api.v1.endpoints import categories
from app.api.v1.endpoints import users
from app.api.auth import oauth, guest, jwt
api_router_v1 = APIRouter() api_router_v1 = APIRouter()
@ -22,4 +25,9 @@ api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"]) api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"]) api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"]) api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
api_router_v1.include_router(history.router, prefix="/history", tags=["History"])
api_router_v1.include_router(categories.router, prefix="/categories", tags=["Categories"])
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"]) 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(jwt.router, prefix="/auth", tags=["Auth"])
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from app import models
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryPublic
from app.database import get_session
from app.auth import current_active_user
from app.crud import category as crud_category, group as crud_group
router = APIRouter()
@router.post("/", response_model=CategoryPublic)
async def create_category(
category_in: CategoryCreate,
group_id: Optional[int] = None,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
if group_id:
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=group_id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
return await crud_category.create_category(db=db, category_in=category_in, user_id=current_user.id, group_id=group_id)
@router.get("/", response_model=List[CategoryPublic])
async def read_categories(
group_id: Optional[int] = None,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
if group_id:
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=group_id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
return await crud_category.get_group_categories(db=db, group_id=group_id)
return await crud_category.get_user_categories(db=db, user_id=current_user.id)
@router.put("/{category_id}", response_model=CategoryPublic)
async def update_category(
category_id: int,
category_in: CategoryUpdate,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
db_category = await crud_category.get_category(db, category_id=category_id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
if db_category.user_id != current_user.id:
if not db_category.group_id:
raise HTTPException(status_code=403, detail="Not your category")
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=db_category.group_id)
if not is_member:
raise HTTPException(status_code=403, detail="Not a member of this group")
return await crud_category.update_category(db=db, db_category=db_category, category_in=category_in)
@router.delete("/{category_id}", response_model=CategoryPublic)
async def delete_category(
category_id: int,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
):
db_category = await crud_category.get_category(db, category_id=category_id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
if db_category.user_id != current_user.id:
if not db_category.group_id:
raise HTTPException(status_code=403, detail="Not your category")
is_member = await crud_group.is_user_member(db, user_id=current_user.id, group_id=db_category.group_id)
if not is_member:
raise HTTPException(status_code=403, detail="Not a member of this group")
return await crud_category.delete_category(db=db, db_category=db_category)

View File

@ -1,18 +1,21 @@
# app/api/v1/endpoints/chores.py # app/api/v1/endpoints/chores.py
import logging import logging
from typing import List as PyList, Optional from typing import List as PyList, Optional
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_transactional_session, get_session from app.database import get_transactional_session, get_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum, TimeEntry
from app.schemas.chore import ( from app.schemas.chore import (
ChoreCreate, ChoreUpdate, ChorePublic, ChoreCreate, ChoreUpdate, ChorePublic,
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
ChoreHistoryPublic, ChoreAssignmentHistoryPublic ChoreHistoryPublic, ChoreAssignmentHistoryPublic
) )
from app.schemas.time_entry import TimeEntryPublic
from app.crud import chore as crud_chore from app.crud import chore as crud_chore
from app.crud import history as crud_history from app.crud import history as crud_history
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
@ -507,3 +510,122 @@ async def get_chore_assignment_history(
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}") logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id) return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
# === TIME ENTRY ENDPOINTS ===
@router.get(
"/assignments/{assignment_id}/time-entries",
response_model=PyList[TimeEntryPublic],
summary="Get Time Entries",
tags=["Time Tracking"]
)
async def get_time_entries_for_assignment(
assignment_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all time entries for a specific chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
# Permission check
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
if chore.type == ChoreTypeEnum.group:
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
# For now, return time entries for the current user only
time_entries = await db.execute(
select(TimeEntry)
.where(TimeEntry.chore_assignment_id == assignment_id)
.where(TimeEntry.user_id == current_user.id)
.order_by(TimeEntry.start_time.desc())
)
return time_entries.scalars().all()
@router.post(
"/assignments/{assignment_id}/time-entries",
response_model=TimeEntryPublic,
status_code=status.HTTP_201_CREATED,
summary="Start Time Entry",
tags=["Time Tracking"]
)
async def start_time_entry(
assignment_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Starts a new time entry for a chore assignment."""
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
if not assignment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
# Permission check - only assigned user can track time
if assignment.assigned_to_user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only assigned user can track time")
# Check if there's already an active time entry
existing_active = await db.execute(
select(TimeEntry)
.where(TimeEntry.chore_assignment_id == assignment_id)
.where(TimeEntry.user_id == current_user.id)
.where(TimeEntry.end_time.is_(None))
)
if existing_active.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already active")
# Create new time entry
time_entry = TimeEntry(
chore_assignment_id=assignment_id,
user_id=current_user.id,
start_time=datetime.now(timezone.utc)
)
db.add(time_entry)
await db.commit()
await db.refresh(time_entry)
return time_entry
@router.put(
"/time-entries/{time_entry_id}",
response_model=TimeEntryPublic,
summary="Stop Time Entry",
tags=["Time Tracking"]
)
async def stop_time_entry(
time_entry_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Stops an active time entry."""
time_entry = await db.get(TimeEntry, time_entry_id)
if not time_entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Time entry not found")
if time_entry.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
if time_entry.end_time:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already stopped")
# Stop the time entry
end_time = datetime.now(timezone.utc)
time_entry.end_time = end_time
time_entry.duration_seconds = int((end_time - time_entry.start_time).total_seconds())
await db.commit()
await db.refresh(time_entry)
return time_entry

View File

@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app import models
from app.schemas.audit import FinancialAuditLogPublic
from app.database import get_session
from app.auth import current_active_user
from app.crud import audit as crud_audit, group as crud_group
router = APIRouter()
@router.get("/financial/group/{group_id}", response_model=List[FinancialAuditLogPublic])
async def read_financial_history_for_group(
group_id: int,
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
skip: int = 0,
limit: int = 100,
):
"""
Retrieve financial audit history for a specific group.
"""
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group")
history = await crud_audit.get_financial_audit_logs_for_group(
db=db, group_id=group_id, skip=skip, limit=limit
)
return history
@router.get("/financial/user/me", response_model=List[FinancialAuditLogPublic])
async def read_financial_history_for_user(
db: AsyncSession = Depends(get_session),
current_user: models.User = Depends(current_active_user),
skip: int = 0,
limit: int = 100,
):
"""
Retrieve financial audit history for the current user.
"""
history = await crud_audit.get_financial_audit_logs_for_user(
db=db, user_id=current_user.id, skip=skip, limit=limit
)
return history

View File

@ -94,6 +94,24 @@ async def read_lists(
return lists return lists
@router.get(
"/archived",
response_model=PyList[ListDetail],
summary="List Archived Lists",
tags=["Lists"]
)
async def read_archived_lists(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves archived lists for the current user.
"""
logger.info(f"Fetching archived lists for user: {current_user.email}")
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id, include_archived=True)
return [l for l in lists if l.archived_at]
@router.get( @router.get(
"/statuses", "/statuses",
response_model=PyList[ListStatusWithId], response_model=PyList[ListStatusWithId],
@ -185,29 +203,29 @@ async def update_list(
@router.delete( @router.delete(
"/{list_id}", "/{list_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
summary="Delete List", summary="Archive List",
tags=["Lists"], tags=["Lists"],
responses={ responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"} status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot archive specified version"}
} }
) )
async def delete_list( async def archive_list_endpoint(
list_id: int, list_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."), expected_version: Optional[int] = Query(None, description="The expected version of the list to archive for optimistic locking."),
db: AsyncSession = Depends(get_transactional_session), db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
""" """
Deletes a list. Requires user to be the creator of the list. Archives a list. Requires user to be the creator of the list.
If `expected_version` is provided and does not match the list's current version, If `expected_version` is provided and does not match the list's current version,
a 409 Conflict is returned. a 409 Conflict is returned.
""" """
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}") logger.info(f"User {current_user.email} attempting to archive list ID: {list_id}, expected version: {expected_version}")
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True) list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
if expected_version is not None and list_db.version != expected_version: if expected_version is not None and list_db.version != expected_version:
logger.warning( logger.warning(
f"Conflict deleting list {list_id} for user {current_user.email}. " f"Conflict archiving list {list_id} for user {current_user.email}. "
f"Expected version {expected_version}, actual version {list_db.version}." f"Expected version {expected_version}, actual version {list_db.version}."
) )
raise HTTPException( raise HTTPException(
@ -215,11 +233,37 @@ async def delete_list(
detail=f"List has been modified. Expected version {expected_version}, but current version is {list_db.version}. Please refresh." detail=f"List has been modified. Expected version {expected_version}, but current version is {list_db.version}. Please refresh."
) )
await crud_list.delete_list(db=db, list_db=list_db) await crud_list.archive_list(db=db, list_db=list_db)
logger.info(f"List {list_id} (version: {list_db.version}) deleted successfully by user {current_user.email}.") logger.info(f"List {list_id} (version: {list_db.version}) archived successfully by user {current_user.email}.")
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/{list_id}/unarchive",
response_model=ListPublic,
summary="Unarchive List",
tags=["Lists"]
)
async def unarchive_list_endpoint(
list_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Restores an archived list.
"""
logger.info(f"User {current_user.email} attempting to unarchive list ID: {list_id}")
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
if not list_db.archived_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="List is not archived.")
updated_list = await crud_list.unarchive_list(db=db, list_db=list_db)
logger.info(f"List {list_id} unarchived successfully by user {current_user.email}.")
return updated_list
@router.get( @router.get(
"/{list_id}/status", "/{list_id}/status",
response_model=ListStatus, response_model=ListStatus,

View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.auth import fastapi_users
from app.schemas.user import UserPublic, UserUpdate
router = APIRouter()
router.include_router(
fastapi_users.get_users_router(UserPublic, UserUpdate),
prefix="",
tags=["Users"],
)

View File

@ -116,7 +116,7 @@ async def get_user_db(session: AsyncSession = Depends(get_session)):
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db) yield UserManager(user_db)
bearer_transport = BearerTransportWithRefresh(tokenUrl="auth/jwt/login") bearer_transport = BearerTransportWithRefresh(tokenUrl="/api/v1/auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy: def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60) return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)

78
be/app/core/cache.py Normal file
View File

@ -0,0 +1,78 @@
import json
import hashlib
from functools import wraps
from typing import Any, Callable, Optional
from app.core.redis import get_redis
import pickle
def generate_cache_key(func_name: str, args: tuple, kwargs: dict) -> str:
"""Generate a unique cache key based on function name and arguments."""
# Create a string representation of args and kwargs
key_data = {
'function': func_name,
'args': str(args),
'kwargs': str(sorted(kwargs.items()))
}
key_string = json.dumps(key_data, sort_keys=True)
# Use SHA256 hash for consistent, shorter keys
return f"cache:{hashlib.sha256(key_string.encode()).hexdigest()}"
def cache(expire_time: int = 3600, key_prefix: Optional[str] = None):
"""
Decorator to cache function results in Redis.
Args:
expire_time: Expiration time in seconds (default: 1 hour)
key_prefix: Optional prefix for cache keys
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs) -> Any:
redis_client = await get_redis()
# Generate cache key
cache_key = generate_cache_key(func.__name__, args, kwargs)
if key_prefix:
cache_key = f"{key_prefix}:{cache_key}"
try:
# Try to get from cache
cached_result = await redis_client.get(cache_key)
if cached_result:
# Deserialize and return cached result
return pickle.loads(cached_result)
# Cache miss - execute function
result = await func(*args, **kwargs)
# Store result in cache
serialized_result = pickle.dumps(result)
await redis_client.setex(cache_key, expire_time, serialized_result)
return result
except Exception as e:
# If caching fails, still execute the function
print(f"Cache error: {e}")
return await func(*args, **kwargs)
return wrapper
return decorator
async def invalidate_cache_pattern(pattern: str):
"""Invalidate all cache keys matching a pattern."""
redis_client = await get_redis()
try:
keys = await redis_client.keys(pattern)
if keys:
await redis_client.delete(*keys)
except Exception as e:
print(f"Cache invalidation error: {e}")
async def clear_all_cache():
"""Clear all cache entries."""
redis_client = await get_redis()
try:
await redis_client.flushdb()
except Exception as e:
print(f"Cache clear error: {e}")

7
be/app/core/redis.py Normal file
View File

@ -0,0 +1,7 @@
import redis.asyncio as redis
from app.config import settings
redis_pool = redis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async def get_redis():
return redis_pool

View File

@ -1,4 +1,7 @@
from passlib.context import CryptContext from passlib.context import CryptContext
from datetime import datetime, timedelta
from jose import jwt
from typing import Optional
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -33,3 +36,39 @@ def hash_password(password: str) -> str:
The resulting hash string. The resulting hash string.
""" """
return pwd_context.hash(password) return pwd_context.hash(password)
# Alias for compatibility with guest.py
def get_password_hash(password: str) -> str:
"""
Alias for hash_password function for backward compatibility.
Args:
password: The plain text password to hash.
Returns:
The resulting hash string.
"""
return hash_password(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
data: The data to encode in the token (typically {"sub": email}).
expires_delta: Optional custom expiration time.
Returns:
The encoded JWT token.
"""
from app.config import settings
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
return encoded_jwt

77
be/app/crud/audit.py Normal file
View File

@ -0,0 +1,77 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import union_all, or_
from typing import List, Optional
from app.models import FinancialAuditLog, Base, User, Group, Expense, Settlement
from app.schemas.audit import FinancialAuditLogCreate
async def create_financial_audit_log(
db: AsyncSession,
*,
user_id: int | None,
action_type: str,
entity: Base,
details: dict | None = None
) -> FinancialAuditLog:
log_entry_data = FinancialAuditLogCreate(
user_id=user_id,
action_type=action_type,
entity_type=entity.__class__.__name__,
entity_id=entity.id,
details=details
)
log_entry = FinancialAuditLog(**log_entry_data.dict())
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
return log_entry
async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:
"""
Get financial audit logs for all entities that belong to a specific group.
This includes Expenses and Settlements that are linked to the group.
"""
# Get all expense IDs for this group
expense_ids_query = select(Expense.id).where(Expense.group_id == group_id)
expense_result = await db.execute(expense_ids_query)
expense_ids = [row[0] for row in expense_result.fetchall()]
# Get all settlement IDs for this group
settlement_ids_query = select(Settlement.id).where(Settlement.group_id == group_id)
settlement_result = await db.execute(settlement_ids_query)
settlement_ids = [row[0] for row in settlement_result.fetchall()]
# Build conditions for the audit log query
conditions = []
if expense_ids:
conditions.append(
(FinancialAuditLog.entity_type == 'Expense') &
(FinancialAuditLog.entity_id.in_(expense_ids))
)
if settlement_ids:
conditions.append(
(FinancialAuditLog.entity_type == 'Settlement') &
(FinancialAuditLog.entity_id.in_(settlement_ids))
)
# If no entities exist for this group, return empty list
if not conditions:
return []
# Query audit logs for all relevant entities
query = select(FinancialAuditLog).where(
or_(*conditions)
).order_by(FinancialAuditLog.timestamp.desc()).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_financial_audit_logs_for_user(db: AsyncSession, *, user_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:
result = await db.execute(
select(FinancialAuditLog)
.where(FinancialAuditLog.user_id == user_id)
.order_by(FinancialAuditLog.timestamp.desc())
.offset(skip).limit(limit)
)
return result.scalars().all()

38
be/app/crud/category.py Normal file
View File

@ -0,0 +1,38 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from typing import List, Optional
from app.models import Category
from app.schemas.category import CategoryCreate, CategoryUpdate
async def create_category(db: AsyncSession, category_in: CategoryCreate, user_id: int, group_id: Optional[int] = None) -> Category:
db_category = Category(**category_in.dict(), user_id=user_id, group_id=group_id)
db.add(db_category)
await db.commit()
await db.refresh(db_category)
return db_category
async def get_user_categories(db: AsyncSession, user_id: int) -> List[Category]:
result = await db.execute(select(Category).where(Category.user_id == user_id))
return result.scalars().all()
async def get_group_categories(db: AsyncSession, group_id: int) -> List[Category]:
result = await db.execute(select(Category).where(Category.group_id == group_id))
return result.scalars().all()
async def get_category(db: AsyncSession, category_id: int) -> Optional[Category]:
return await db.get(Category, category_id)
async def update_category(db: AsyncSession, db_category: Category, category_in: CategoryUpdate) -> Category:
update_data = category_in.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_category, key, value)
db.add(db_category)
await db.commit()
await db.refresh(db_category)
return db_category
async def delete_category(db: AsyncSession, db_category: Category):
await db.delete(db_category)
await db.commit()
return db_category

View File

@ -39,7 +39,8 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
selectinload(Chore.creator), selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history), selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history) selectinload(Chore.history),
selectinload(Chore.child_chores)
) )
.order_by(Chore.next_due_date, Chore.name) .order_by(Chore.next_due_date, Chore.name)
) )
@ -57,7 +58,8 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
selectinload(Chore.group), selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history), selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history) selectinload(Chore.history),
selectinload(Chore.child_chores)
) )
.order_by(Chore.next_due_date, Chore.name) .order_by(Chore.next_due_date, Chore.name)
) )
@ -85,8 +87,14 @@ async def create_chore(
if group_id: if group_id:
raise ValueError("group_id must be None for personal chores") raise ValueError("group_id must be None for personal chores")
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'})
if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
if not parent_chore:
raise ChoreNotFoundError(chore_data['parent_chore_id'])
db_chore = Chore( db_chore = Chore(
**chore_in.model_dump(exclude_unset=True, exclude={'group_id'}), **chore_data,
group_id=group_id, group_id=group_id,
created_by_id=user_id, created_by_id=user_id,
) )
@ -115,7 +123,8 @@ async def create_chore(
selectinload(Chore.group), selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history), selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history) selectinload(Chore.history),
selectinload(Chore.child_chores)
) )
) )
return result.scalar_one() return result.scalar_one()
@ -133,7 +142,8 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
selectinload(Chore.group), selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history), selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history) selectinload(Chore.history),
selectinload(Chore.child_chores)
) )
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@ -168,7 +178,8 @@ async def get_personal_chores(
selectinload(Chore.creator), selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history), selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history) selectinload(Chore.history),
selectinload(Chore.child_chores)
) )
.order_by(Chore.next_due_date, Chore.name) .order_by(Chore.next_due_date, Chore.name)
) )
@ -193,7 +204,8 @@ async def get_chores_by_group_id(
selectinload(Chore.creator), selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history), selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history) selectinload(Chore.history),
selectinload(Chore.child_chores)
) )
.order_by(Chore.next_due_date, Chore.name) .order_by(Chore.next_due_date, Chore.name)
) )
@ -229,6 +241,14 @@ async def update_chore(
update_data = chore_in.model_dump(exclude_unset=True) update_data = chore_in.model_dump(exclude_unset=True)
if 'parent_chore_id' in update_data:
if update_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, update_data['parent_chore_id'])
if not parent_chore:
raise ChoreNotFoundError(update_data['parent_chore_id'])
# Setting parent_chore_id to None is allowed
setattr(db_chore, 'parent_chore_id', update_data['parent_chore_id'])
if 'type' in update_data: if 'type' in update_data:
new_type = update_data['type'] new_type = update_data['type']
if new_type == ChoreTypeEnum.group and not group_id: if new_type == ChoreTypeEnum.group and not group_id:
@ -289,7 +309,8 @@ async def update_chore(
selectinload(Chore.group), selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user), selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history), selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history) selectinload(Chore.history),
selectinload(Chore.child_chores)
) )
) )
return result.scalar_one() return result.scalar_one()
@ -379,6 +400,7 @@ async def create_chore_assignment(
.where(ChoreAssignment.id == db_assignment.id) .where(ChoreAssignment.id == db_assignment.id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user), selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history) selectinload(ChoreAssignment.history)
) )
@ -395,6 +417,7 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
.where(ChoreAssignment.id == assignment_id) .where(ChoreAssignment.id == assignment_id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user), selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history) selectinload(ChoreAssignment.history)
) )
@ -414,6 +437,7 @@ async def get_user_assignments(
query = query.options( query = query.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user), selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history) selectinload(ChoreAssignment.history)
).order_by(ChoreAssignment.due_date, ChoreAssignment.id) ).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
@ -443,6 +467,7 @@ async def get_chore_assignments(
.where(ChoreAssignment.chore_id == chore_id) .where(ChoreAssignment.chore_id == chore_id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user), selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history) selectinload(ChoreAssignment.history)
) )
@ -456,75 +481,72 @@ async def update_chore_assignment(
assignment_in: ChoreAssignmentUpdate, assignment_in: ChoreAssignmentUpdate,
user_id: int user_id: int
) -> Optional[ChoreAssignment]: ) -> Optional[ChoreAssignment]:
"""Updates a chore assignment. Only the assignee can mark it complete.""" """Updates a chore assignment, e.g., to mark it as complete."""
async with db.begin_nested() if db.in_transaction() else db.begin(): async with db.begin_nested() if db.in_transaction() else db.begin():
db_assignment = await get_chore_assignment_by_id(db, assignment_id) db_assignment = await get_chore_assignment_by_id(db, assignment_id)
if not db_assignment: if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id) return None
chore = await get_chore_by_id(db, db_assignment.chore_id) # Permission Check: only assigned user or group owner can update
if not chore: is_allowed = db_assignment.assigned_to_user_id == user_id
raise ChoreNotFoundError(chore_id=db_assignment.chore_id) if not is_allowed and db_assignment.chore.group_id:
user_role = await get_user_role_in_group(db, db_assignment.chore.group_id, user_id)
is_allowed = user_role == UserRoleEnum.owner
can_manage = False if not is_allowed:
if chore.type == ChoreTypeEnum.personal: raise PermissionDeniedError("You cannot update this chore assignment.")
can_manage = chore.created_by_id == user_id
else:
can_manage = await is_user_member(db, chore.group_id, user_id)
can_complete = db_assignment.assigned_to_user_id == user_id
original_status = db_assignment.is_complete
update_data = assignment_in.model_dump(exclude_unset=True) update_data = assignment_in.model_dump(exclude_unset=True)
original_assignee = db_assignment.assigned_to_user_id
original_due_date = db_assignment.due_date
if 'is_complete' in update_data and not can_complete:
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
if 'due_date' in update_data and update_data['due_date'] != original_due_date:
if not can_manage:
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.DUE_DATE_CHANGED, event_data={"old": original_due_date.isoformat(), "new": update_data['due_date'].isoformat()})
if 'assigned_to_user_id' in update_data and update_data['assigned_to_user_id'] != original_assignee:
if not can_manage:
raise PermissionDeniedError(detail="Only chore managers can reassign assignments")
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']})
if 'is_complete' in update_data:
if update_data['is_complete'] and not db_assignment.is_complete:
update_data['completed_at'] = datetime.utcnow()
chore.last_completed_at = update_data['completed_at']
chore.next_due_date = calculate_next_due_date(
current_due_date=chore.next_due_date,
frequency=chore.frequency,
custom_interval_days=chore.custom_interval_days,
last_completed_date=chore.last_completed_at
)
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
elif not update_data['is_complete'] and db_assignment.is_complete:
update_data['completed_at'] = None
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(db_assignment, field, value) setattr(db_assignment, field, value)
if 'is_complete' in update_data:
new_status = update_data['is_complete']
history_event = None
if new_status and not original_status:
db_assignment.completed_at = datetime.utcnow()
history_event = ChoreHistoryEventTypeEnum.COMPLETED
# Advance the next_due_date of the parent chore
if db_assignment.chore:
db_assignment.chore.last_completed_at = db_assignment.completed_at
db_assignment.chore.next_due_date = calculate_next_due_date(
db_assignment.chore.frequency,
db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else date.today(),
db_assignment.chore.custom_interval_days
)
elif not new_status and original_status:
db_assignment.completed_at = None
history_event = ChoreHistoryEventTypeEnum.REOPENED
# Policy: Do not automatically roll back parent chore's due date.
if history_event:
await create_assignment_history_entry(
db=db,
assignment_id=assignment_id,
changed_by_user_id=user_id,
event_type=history_event,
event_data={"new_status": new_status}
)
await db.flush()
try: try:
await db.flush()
result = await db.execute( result = await db.execute(
select(ChoreAssignment) select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id) .where(ChoreAssignment.id == assignment_id)
.options( .options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator), selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user), selectinload(ChoreAssignment.assigned_user)
selectinload(ChoreAssignment.history)
) )
) )
return result.scalar_one() return result.scalar_one()
except Exception as e: except Exception as e:
logger.error(f"Error updating chore assignment {assignment_id}: {e}", exc_info=True) logger.error(f"Error updating assignment: {e}", exc_info=True)
raise DatabaseIntegrityError(f"Could not update chore assignment {assignment_id}. Error: {str(e)}") await db.rollback()
raise DatabaseIntegrityError(f"Could not update assignment. Error: {str(e)}")
async def delete_chore_assignment( async def delete_chore_assignment(
db: AsyncSession, db: AsyncSession,

View File

@ -7,6 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # A
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
from datetime import datetime, timezone # Added timezone from datetime import datetime, timezone # Added timezone
import json
from app.models import ( from app.models import (
Expense as ExpenseModel, Expense as ExpenseModel,
@ -34,6 +35,7 @@ from app.core.exceptions import (
ExpenseOperationError # Added specific exception ExpenseOperationError # Added specific exception
) )
from app.models import RecurrencePattern from app.models import RecurrencePattern
from app.crud.audit import create_financial_audit_log
# Placeholder for InvalidOperationError if not defined in app.core.exceptions # Placeholder for InvalidOperationError if not defined in app.core.exceptions
# This should be a proper HTTPException subclass if used in API layer # This should be a proper HTTPException subclass if used in API layer
@ -215,6 +217,13 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us
# await transaction.rollback() # Should be handled by context manager # await transaction.rollback() # Should be handled by context manager
raise ExpenseOperationError("Failed to load expense after creation.") raise ExpenseOperationError("Failed to load expense after creation.")
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_CREATED",
entity=loaded_expense,
)
# await transaction.commit() # Explicit commit removed, context manager handles it. # await transaction.commit() # Explicit commit removed, context manager handles it.
return loaded_expense return loaded_expense
@ -611,22 +620,28 @@ async def get_user_accessible_expenses(db: AsyncSession, user_id: int, skip: int
) )
return result.scalars().all() return result.scalars().all()
async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate) -> ExpenseModel: async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate, current_user_id: int) -> ExpenseModel:
""" """
Updates an existing expense. Updates an expense. For now, only allows simple field updates.
Only allows updates to description, currency, and expense_date to avoid split complexities. More complex updates (like changing split logic) would require a more sophisticated approach.
Requires version matching for optimistic locking.
""" """
if expense_in.version is None:
raise InvalidOperationError("Version is required for updating an expense.")
if expense_db.version != expense_in.version: if expense_db.version != expense_in.version:
raise InvalidOperationError( raise InvalidOperationError(
f"Expense '{expense_db.description}' (ID: {expense_db.id}) has been modified. " f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be updated. "
f"Your version is {expense_in.version}, current version is {expense_db.version}. Please refresh.", f"Your expected version {expense_in.version} does not match current version {expense_db.version}. Please refresh.",
# status_code=status.HTTP_409_CONFLICT # This would be for the API layer to set
) )
update_data = expense_in.model_dump(exclude_unset=True, exclude={"version"}) # Exclude version itself from data before_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in expense_in.dict(exclude_unset=True)}
# A simple way to handle non-serializable types for JSON
for k, v in before_state.items():
if isinstance(v, (datetime, Decimal)):
before_state[k] = str(v)
update_data = expense_in.dict(exclude_unset=True, exclude={"version"})
# Fields that are safe to update without affecting splits or core logic
allowed_to_update = {"description", "currency", "expense_date"} allowed_to_update = {"description", "currency", "expense_date"}
updated_something = False updated_something = False
@ -635,38 +650,41 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
setattr(expense_db, field, value) setattr(expense_db, field, value)
updated_something = True updated_something = True
else: else:
# If any other field is present in the update payload, it's an invalid operation for this simple update
raise InvalidOperationError(f"Field '{field}' cannot be updated. Only {', '.join(allowed_to_update)} are allowed.") raise InvalidOperationError(f"Field '{field}' cannot be updated. Only {', '.join(allowed_to_update)} are allowed.")
if not updated_something and not expense_in.model_fields_set.intersection(allowed_to_update): if not updated_something:
# No actual updatable fields were provided in the payload, even if others (like version) were. pass
# This could be a non-issue, or an indication of a misuse of the endpoint.
# For now, if only version was sent, we still increment if it matched.
pass # Or raise InvalidOperationError("No updatable fields provided.")
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin():
expense_db.version += 1 expense_db.version += 1
expense_db.updated_at = datetime.now(timezone.utc) # Manually update timestamp expense_db.updated_at = datetime.now(timezone.utc)
# db.add(expense_db) # Not strictly necessary as expense_db is already tracked by the session
await db.flush() # Persist changes to the DB and run constraints await db.flush()
await db.refresh(expense_db) # Refresh the object from the DB
return expense_db after_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in update_data}
except InvalidOperationError: # Re-raise validation errors to be handled by the caller for k, v in after_state.items():
raise if isinstance(v, (datetime, Decimal)):
after_state[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_UPDATED",
entity=expense_db,
details={"before": before_state, "after": after_state}
)
await db.refresh(expense_db)
return expense_db
except IntegrityError as e: except IntegrityError as e:
logger.error(f"Database integrity error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True) logger.error(f"Database integrity error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseIntegrityError(f"Failed to update expense ID {expense_db.id} due to database integrity issue.") from e raise DatabaseIntegrityError(f"Failed to update expense ID {expense_db.id} due to database integrity issue.") from e
except SQLAlchemyError as e: # Catch other SQLAlchemy errors except SQLAlchemyError as e:
logger.error(f"Database transaction error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True) logger.error(f"Database transaction error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseTransactionError(f"Failed to update expense ID {expense_db.id} due to a database transaction error.") from e raise DatabaseTransactionError(f"Failed to update expense ID {expense_db.id} due to a database transaction error.") from e
# No generic Exception catch here, let other unexpected errors propagate if not SQLAlchemy related.
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_version: Optional[int] = None) -> None:
""" """
Deletes an expense. Requires version matching if expected_version is provided. Deletes an expense. Requires version matching if expected_version is provided.
Associated ExpenseSplits are cascade deleted by the database foreign key constraint. Associated ExpenseSplits are cascade deleted by the database foreign key constraint.
@ -675,23 +693,33 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_ve
raise InvalidOperationError( raise InvalidOperationError(
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be deleted. " f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be deleted. "
f"Your expected version {expected_version} does not match current version {expense_db.version}. Please refresh.", f"Your expected version {expected_version} does not match current version {expense_db.version}. Please refresh.",
# status_code=status.HTTP_409_CONFLICT
) )
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin():
details = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns}
for k, v in details.items():
if isinstance(v, (datetime, Decimal)):
details[k] = str(v)
expense_id_for_log = expense_db.id
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="EXPENSE_DELETED",
entity=expense_db,
details=details
)
await db.delete(expense_db) await db.delete(expense_db)
await db.flush() # Ensure the delete operation is sent to the database await db.flush()
except InvalidOperationError: # Re-raise validation errors
raise
except IntegrityError as e: except IntegrityError as e:
logger.error(f"Database integrity error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True) logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
# The transaction context manager (begin_nested/begin) handles rollback. raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_db.id} due to database integrity issue.") from e except SQLAlchemyError as e:
except SQLAlchemyError as e: # Catch other SQLAlchemy errors logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
logger.error(f"Database transaction error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True) raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e
# The transaction context manager (begin_nested/begin) handles rollback.
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_db.id} due to a database transaction error.") from e
return None return None
# Note: The InvalidOperationError is a simple ValueError placeholder. # Note: The InvalidOperationError is a simple ValueError placeholder.

View File

@ -1,13 +1,14 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload, joinedload, contains_eager
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import delete, func from sqlalchemy import delete, func, and_, or_, update, desc
import logging import logging
from datetime import datetime, timezone, timedelta
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel, List as ListModel, Chore as ChoreModel, ChoreAssignment as ChoreAssignmentModel
from app.schemas.group import GroupCreate from app.schemas.group import GroupCreate, GroupPublic
from app.models import UserRoleEnum from app.models import UserRoleEnum
from app.core.exceptions import ( from app.core.exceptions import (
GroupOperationError, GroupOperationError,
@ -17,8 +18,10 @@ from app.core.exceptions import (
DatabaseQueryError, DatabaseQueryError,
DatabaseTransactionError, DatabaseTransactionError,
GroupMembershipError, GroupMembershipError,
GroupPermissionError # Import GroupPermissionError GroupPermissionError,
PermissionDeniedError
) )
from app.core.cache import cache
logger = logging.getLogger(__name__) # Initialize logger logger = logging.getLogger(__name__) # Initialize logger
@ -88,22 +91,18 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
except SQLAlchemyError as e: except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query user groups: {str(e)}") raise DatabaseQueryError(f"Failed to query user groups: {str(e)}")
@cache(expire_time=1800, key_prefix="group") # Cache for 30 minutes
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]: async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
"""Gets a single group by its ID, optionally loading members.""" """Get a group by its ID with caching, including member associations and chore history."""
try: result = await db.execute(
result = await db.execute( select(GroupModel)
select(GroupModel) .where(GroupModel.id == group_id)
.where(GroupModel.id == group_id) .options(
.options( selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user), selectinload(GroupModel.chore_history)
selectinload(GroupModel.chore_history) # Eager load chore history
)
) )
return result.scalars().first() )
except OperationalError as e: return result.scalar_one_or_none()
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query group: {str(e)}")
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool: async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Checks if a user is a member of a specific group.""" """Checks if a user is a member of a specific group."""

View File

@ -33,6 +33,7 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
db_item = ItemModel( db_item = ItemModel(
name=item_in.name, name=item_in.name,
quantity=item_in.quantity, quantity=item_in.quantity,
category_id=item_in.category_id,
list_id=list_id, list_id=list_id,
added_by_id=user_id, added_by_id=user_id,
is_complete=False, is_complete=False,
@ -116,6 +117,9 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'}) update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
if 'category_id' in update_data:
item_db.category_id = update_data.pop('category_id')
if 'position' in update_data: if 'position' in update_data:
new_position = update_data.pop('position') new_position = update_data.pop('position')

View File

@ -5,6 +5,7 @@ from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList from typing import Optional, List as PyList
import logging import logging
from datetime import datetime, timezone
from app.schemas.list import ListStatus from app.schemas.list import ListStatus
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
@ -62,7 +63,7 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
logger.error(f"Unexpected SQLAlchemy error during list creation: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error during list creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create list: {str(e)}") raise DatabaseTransactionError(f"Failed to create list: {str(e)}")
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]: async def get_lists_for_user(db: AsyncSession, user_id: int, include_archived: bool = False) -> PyList[ListModel]:
"""Gets all lists accessible by a user.""" """Gets all lists accessible by a user."""
try: try:
group_ids_result = await db.execute( group_ids_result = await db.execute(
@ -76,19 +77,19 @@ async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel
if user_group_ids: if user_group_ids:
conditions.append(ListModel.group_id.in_(user_group_ids)) conditions.append(ListModel.group_id.in_(user_group_ids))
query = ( query = select(ListModel).where(or_(*conditions))
select(ListModel)
.where(or_(*conditions)) if not include_archived:
.options( query = query.where(ListModel.archived_at.is_(None))
selectinload(ListModel.creator),
selectinload(ListModel.group), query = query.options(
selectinload(ListModel.items).options( selectinload(ListModel.creator),
joinedload(ItemModel.added_by_user), selectinload(ListModel.group),
joinedload(ItemModel.completed_by_user) selectinload(ListModel.items).options(
) joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user)
) )
.order_by(ListModel.updated_at.desc()) ).order_by(ListModel.updated_at.desc())
)
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@ -169,17 +170,35 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
logger.error(f"Unexpected SQLAlchemy error during list update: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error during list update: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to update list: {str(e)}") raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
async def delete_list(db: AsyncSession, list_db: ListModel) -> None: async def archive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Deletes a list record. Version check should be done by the caller (API endpoint).""" """Archives a list record by setting the archived_at timestamp."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
await db.delete(list_db) list_db.archived_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e: except OperationalError as e:
logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True) logger.error(f"Database connection error while archiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while deleting list: {str(e)}") raise DatabaseConnectionError(f"Database connection error while archiving list: {str(e)}")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting list: {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error while archiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete list: {str(e)}") raise DatabaseTransactionError(f"Failed to archive list: {str(e)}")
async def unarchive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Unarchives a list record by setting the archived_at timestamp to None."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
list_db.archived_at = None
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e:
logger.error(f"Database connection error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while unarchiving list: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to unarchive list: {str(e)}")
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel: async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
"""Fetches a list and verifies user permission.""" """Fetches a list and verifies user permission."""

View File

@ -26,6 +26,7 @@ from app.core.exceptions import (
SettlementOperationError, SettlementOperationError,
ConflictError ConflictError
) )
from app.crud.audit import create_financial_audit_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -76,6 +77,13 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
if loaded_settlement is None: if loaded_settlement is None:
raise SettlementOperationError("Failed to load settlement after creation.") raise SettlementOperationError("Failed to load settlement after creation.")
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_CREATED",
entity=loaded_settlement,
)
return loaded_settlement return loaded_settlement
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e: except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
raise raise
@ -160,7 +168,7 @@ async def get_settlements_involving_user(
raise DatabaseQueryError(f"DB query error fetching user settlements: {str(e)}") raise DatabaseQueryError(f"DB query error fetching user settlements: {str(e)}")
async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate) -> SettlementModel: async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate, current_user_id: int) -> SettlementModel:
""" """
Updates an existing settlement. Updates an existing settlement.
Only allows updates to description and settlement_date. Only allows updates to description and settlement_date.
@ -179,6 +187,11 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
f"Your version {settlement_in.version} does not match current version {settlement_db.version}. Please refresh." f"Your version {settlement_in.version} does not match current version {settlement_db.version}. Please refresh."
) )
before_state = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns if c.name in settlement_in.dict(exclude_unset=True)}
for k, v in before_state.items():
if isinstance(v, (datetime, Decimal)):
before_state[k] = str(v)
update_data = settlement_in.model_dump(exclude_unset=True, exclude={"version"}) update_data = settlement_in.model_dump(exclude_unset=True, exclude={"version"})
allowed_to_update = {"description", "settlement_date"} allowed_to_update = {"description", "settlement_date"}
updated_something = False updated_something = False
@ -211,6 +224,19 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
if updated_settlement is None: if updated_settlement is None:
raise SettlementOperationError("Failed to load settlement after update.") raise SettlementOperationError("Failed to load settlement after update.")
after_state = {c.name: getattr(updated_settlement, c.name) for c in updated_settlement.__table__.columns if c.name in update_data}
for k, v in after_state.items():
if isinstance(v, (datetime, Decimal)):
after_state[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_UPDATED",
entity=updated_settlement,
details={"before": before_state, "after": after_state}
)
return updated_settlement return updated_settlement
except ConflictError as e: except ConflictError as e:
raise raise
@ -227,7 +253,7 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
raise DatabaseTransactionError(f"DB transaction error during settlement update: {str(e)}") raise DatabaseTransactionError(f"DB transaction error during settlement update: {str(e)}")
async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, expected_version: Optional[int] = None) -> None: async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
""" """
Deletes a settlement. Requires version matching if expected_version is provided. Deletes a settlement. Requires version matching if expected_version is provided.
Assumes SettlementModel has a version field. Assumes SettlementModel has a version field.
@ -241,6 +267,19 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh." f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh."
) )
details = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns}
for k, v in details.items():
if isinstance(v, (datetime, Decimal)):
details[k] = str(v)
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_DELETED",
entity=settlement_db,
details=details
)
await db.delete(settlement_db) await db.delete(settlement_db)
except ConflictError as e: except ConflictError as e:
raise raise

View File

@ -15,6 +15,7 @@ from app.models import (
ExpenseOverallStatusEnum, ExpenseOverallStatusEnum,
) )
from pydantic import BaseModel from pydantic import BaseModel
from app.crud.audit import create_financial_audit_log
class SettlementActivityCreatePlaceholder(BaseModel): class SettlementActivityCreatePlaceholder(BaseModel):
@ -140,6 +141,13 @@ async def create_settlement_activity(
db.add(db_settlement_activity) db.add(db_settlement_activity)
await db.flush() await db.flush()
await create_financial_audit_log(
db=db,
user_id=current_user_id,
action_type="SETTLEMENT_ACTIVITY_CREATED",
entity=db_settlement_activity,
)
# Update statuses # Update statuses
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id) updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
if updated_split and updated_split.expense_id: if updated_split and updated_split.expense_id:

View File

@ -39,7 +39,7 @@ async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]
logger.error(f"Unexpected SQLAlchemy error while fetching user by email '{email}': {str(e)}", exc_info=True) logger.error(f"Unexpected SQLAlchemy error while fetching user by email '{email}': {str(e)}", exc_info=True)
raise DatabaseQueryError(f"Failed to query user: {str(e)}") raise DatabaseQueryError(f"Failed to query user: {str(e)}")
async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel: async def create_user(db: AsyncSession, user_in: UserCreate, is_guest: bool = False) -> UserModel:
"""Creates a new user record in the database with common relationships loaded.""" """Creates a new user record in the database with common relationships loaded."""
try: try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
@ -47,7 +47,8 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
db_user = UserModel( db_user = UserModel(
email=user_in.email, email=user_in.email,
hashed_password=_hashed_password, hashed_password=_hashed_password,
name=user_in.name name=user_in.name,
is_guest=is_guest
) )
db.add(db_user) db.add(db_user)
await db.flush() await db.flush()

View File

@ -57,32 +57,6 @@ app.add_middleware(
expose_headers=["*"] expose_headers=["*"]
) )
app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
tags=["auth"],
)
app.include_router(
fastapi_users.get_register_router(UserPublic, UserCreate),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(UserPublic),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(UserPublic, UserUpdate),
prefix="/users",
tags=["users"],
)
app.include_router(api_router, prefix=settings.API_PREFIX) app.include_router(api_router, prefix=settings.API_PREFIX)
@app.get("/health", tags=["Health"]) @app.get("/health", tags=["Health"])

View File

@ -93,6 +93,7 @@ class User(Base):
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False) is_superuser = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False) is_verified = Column(Boolean, default=False, nullable=False)
is_guest = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships --- # --- Relationships ---
@ -112,6 +113,9 @@ class User(Base):
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan") assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan") chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan") assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
financial_audit_logs = relationship("FinancialAuditLog", back_populates="user")
time_entries = relationship("TimeEntry", back_populates="user")
categories = relationship("Category", back_populates="user")
class Group(Base): class Group(Base):
__tablename__ = "groups" __tablename__ = "groups"
@ -120,6 +124,8 @@ class Group(Base):
name = Column(String, index=True, nullable=False) name = Column(String, index=True, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
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)
version = Column(Integer, nullable=False, default=1, server_default='1')
creator = relationship("User", back_populates="created_groups") creator = relationship("User", back_populates="created_groups")
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
@ -174,6 +180,7 @@ class List(Base):
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')
archived_at = Column(DateTime(timezone=True), nullable=True, index=True)
creator = relationship("User", back_populates="created_lists") creator = relationship("User", back_populates="created_lists")
group = relationship("Group", back_populates="lists") group = relationship("Group", back_populates="lists")
@ -199,6 +206,7 @@ class Item(Base):
is_complete = Column(Boolean, default=False, nullable=False) is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) price = Column(Numeric(10, 2), nullable=True)
position = Column(Integer, nullable=False, server_default='0') position = Column(Integer, nullable=False, server_default='0')
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)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@ -210,6 +218,7 @@ class Item(Base):
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")
expenses = relationship("Expense", back_populates="item") expenses = relationship("Expense", back_populates="item")
category = relationship("Category", back_populates="items")
class Expense(Base): class Expense(Base):
__tablename__ = "expenses" __tablename__ = "expenses"
@ -248,7 +257,7 @@ class Expense(Base):
last_occurrence = Column(DateTime(timezone=True), nullable=True) last_occurrence = Column(DateTime(timezone=True), nullable=True)
__table_args__ = ( __table_args__ = (
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'), CheckConstraint('group_id IS NOT NULL OR list_id IS NOT NULL', name='ck_expense_group_or_list'),
) )
class ExpenseSplit(Base): class ExpenseSplit(Base):
@ -335,6 +344,7 @@ class Chore(Base):
name = Column(String, nullable=False, index=True) name = Column(String, nullable=False, index=True)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
parent_chore_id = Column(Integer, ForeignKey('chores.id'), nullable=True, index=True)
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False) frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
custom_interval_days = Column(Integer, nullable=True) custom_interval_days = Column(Integer, nullable=True)
@ -349,6 +359,8 @@ class Chore(Base):
creator = relationship("User", back_populates="created_chores") creator = relationship("User", back_populates="created_chores")
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan") assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan") history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
parent_chore = relationship("Chore", remote_side=[id], back_populates="child_chores")
child_chores = relationship("Chore", back_populates="parent_chore", cascade="all, delete-orphan")
# --- ChoreAssignment Model --- # --- ChoreAssignment Model ---
@ -369,6 +381,7 @@ class ChoreAssignment(Base):
chore = relationship("Chore", back_populates="assignments") chore = relationship("Chore", back_populates="assignments")
assigned_user = relationship("User", back_populates="assigned_chores") assigned_user = relationship("User", back_populates="assigned_chores")
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan") history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
time_entries = relationship("TimeEntry", back_populates="assignment", cascade="all, delete-orphan")
# === NEW: RecurrencePattern Model === # === NEW: RecurrencePattern Model ===
@ -419,3 +432,41 @@ class ChoreAssignmentHistory(Base):
assignment = relationship("ChoreAssignment", back_populates="history") assignment = relationship("ChoreAssignment", back_populates="history")
changed_by_user = relationship("User", back_populates="assignment_history_entries") changed_by_user = relationship("User", back_populates="assignment_history_entries")
# --- New Models from Roadmap ---
class FinancialAuditLog(Base):
__tablename__ = 'financial_audit_log'
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
action_type = Column(String, nullable=False, index=True)
entity_type = Column(String, nullable=False)
entity_id = Column(Integer, nullable=False)
details = Column(JSONB, nullable=True)
user = relationship("User", back_populates="financial_audit_logs")
class Category(Base):
__tablename__ = 'categories'
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
group_id = Column(Integer, ForeignKey('groups.id'), nullable=True)
user = relationship("User", back_populates="categories")
items = relationship("Item", back_populates="category")
__table_args__ = (UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope'),)
class TimeEntry(Base):
__tablename__ = 'time_entries'
id = Column(Integer, primary_key=True, index=True)
chore_assignment_id = Column(Integer, ForeignKey('chore_assignments.id', ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True)
assignment = relationship("ChoreAssignment", back_populates="time_entries")
user = relationship("User", back_populates="time_entries")

20
be/app/schemas/audit.py Normal file
View File

@ -0,0 +1,20 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, Dict, Any
class FinancialAuditLogBase(BaseModel):
action_type: str
entity_type: str
entity_id: int
details: Optional[Dict[str, Any]] = None
class FinancialAuditLogCreate(FinancialAuditLogBase):
user_id: Optional[int] = None
class FinancialAuditLogPublic(FinancialAuditLogBase):
id: int
timestamp: datetime
user_id: Optional[int] = None
class Config:
orm_mode = True

View File

@ -0,0 +1,19 @@
from pydantic import BaseModel
from typing import Optional
class CategoryBase(BaseModel):
name: str
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(CategoryBase):
pass
class CategoryPublic(CategoryBase):
id: int
user_id: Optional[int] = None
group_id: Optional[int] = None
class Config:
orm_mode = True

View File

@ -1,3 +1,4 @@
from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from typing import Optional, List, Any from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
@ -55,6 +56,7 @@ class ChoreBase(BaseModel):
class ChoreCreate(ChoreBase): class ChoreCreate(ChoreBase):
group_id: Optional[int] = None group_id: Optional[int] = None
parent_chore_id: Optional[int] = None
@field_validator('group_id') @field_validator('group_id')
@classmethod @classmethod
@ -89,11 +91,13 @@ class ChorePublic(ChoreBase):
group_id: Optional[int] = None group_id: Optional[int] = None
created_by_id: int created_by_id: int
last_completed_at: Optional[datetime] = None last_completed_at: Optional[datetime] = None
parent_chore_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
creator: Optional[UserPublic] = None # Embed creator UserPublic schema creator: Optional[UserPublic] = None # Embed creator UserPublic schema
assignments: List[ChoreAssignmentPublic] = [] assignments: List[ChoreAssignmentPublic] = []
history: List[ChoreHistoryPublic] = [] history: List[ChoreHistoryPublic] = []
child_chores: List[ChorePublic] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -20,6 +20,7 @@ class ItemPublic(BaseModel):
class ItemCreate(BaseModel): class ItemCreate(BaseModel):
name: str name: str
quantity: Optional[str] = None quantity: Optional[str] = None
category_id: Optional[int] = None
class ItemUpdate(BaseModel): class ItemUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
@ -27,4 +28,5 @@ class ItemUpdate(BaseModel):
is_complete: Optional[bool] = None is_complete: Optional[bool] = None
price: Optional[Decimal] = None price: Optional[Decimal] = None
position: Optional[int] = None position: Optional[int] = None
category_id: Optional[int] = None
version: int version: int

View File

@ -0,0 +1,22 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class TimeEntryBase(BaseModel):
chore_assignment_id: int
start_time: datetime
end_time: Optional[datetime] = None
duration_seconds: Optional[int] = None
class TimeEntryCreate(TimeEntryBase):
pass
class TimeEntryUpdate(BaseModel):
end_time: datetime
class TimeEntryPublic(TimeEntryBase):
id: int
user_id: int
class Config:
orm_mode = True

8
be/app/schemas/token.py Normal file
View File

@ -0,0 +1,8 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: str | None = None

View File

@ -25,6 +25,10 @@ class UserUpdate(UserBase):
is_superuser: Optional[bool] = None is_superuser: Optional[bool] = None
is_verified: Optional[bool] = None is_verified: Optional[bool] = None
class UserClaim(BaseModel):
email: EmailStr
password: str
class UserInDBBase(UserBase): class UserInDBBase(UserBase):
id: int id: int
password_hash: str password_hash: str

View File

@ -25,3 +25,4 @@ aiosqlite>=0.19.0 # For async SQLite support in tests
# Scheduler # Scheduler
APScheduler==3.10.4 APScheduler==3.10.4
redis>=5.0.0

View File

@ -0,0 +1,47 @@
<template>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="category-name">Category Name</label>
<input type="text" id="category-name" v-model="categoryName" required />
</div>
<div class="form-actions">
<button type="submit" :disabled="loading">
{{ isEditing ? 'Update' : 'Create' }}
</button>
<button type="button" @click="emit('cancel')" :disabled="loading">
Cancel
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, onMounted, computed } from 'vue';
import type { Category } from '../stores/categoryStore';
const props = defineProps<{
category?: Category | null;
loading: boolean;
}>();
const emit = defineEmits<{
(e: 'submit', data: { name: string }): void;
(e: 'cancel'): void;
}>();
const categoryName = ref('');
const isEditing = computed(() => !!props.category);
onMounted(() => {
if (props.category) {
categoryName.value = props.category.name;
}
});
const handleSubmit = () => {
if (categoryName.value.trim()) {
emit('submit', { name: categoryName.value.trim() });
}
};
</script>

View File

@ -0,0 +1,128 @@
<template>
<li class="neo-list-item" :class="`status-${getDueDateStatus(chore)}`">
<div class="neo-item-content">
<label class="neo-checkbox-label">
<input type="checkbox" :checked="chore.is_completed" @change="emit('toggle-completion', chore)">
<div class="checkbox-content">
<div class="chore-main-info">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<div class="chore-badges">
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
<span v-if="getDueDateStatus(chore) === 'overdue'"
class="badge badge-overdue">Overdue</span>
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
Today</span>
</div>
</div>
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
<div v-if="totalTime > 0" class="total-time">
Total Time: {{ formatDuration(totalTime) }}
</div>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed">
{{ isActiveTimer ? 'Stop' : 'Start' }}
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
📋
</button>
<button class="btn btn-sm btn-neutral" @click="emit('open-history', chore)" title="View History">
📅
</button>
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
Edit
</button>
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
Delete
</button>
</div>
</div>
<ul v-if="chore.child_chores && chore.child_chores.length" class="child-chore-list">
<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)"
@edit="emit('edit', $event)" @delete="emit('delete', $event)"
@open-details="emit('open-details', $event)" @open-history="emit('open-history', $event)"
@start-timer="emit('start-timer', $event)"
@stop-timer="(chore, timeEntryId) => emit('stop-timer', chore, timeEntryId)" />
</ul>
</li>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import type { ChoreWithCompletion } from '../types/chore';
import type { TimeEntry } from '../stores/timeEntryStore';
import { formatDuration } from '../utils/formatters';
const props = defineProps<{
chore: ChoreWithCompletion;
timeEntries: TimeEntry[];
activeTimer: TimeEntry | null;
}>();
const emit = defineEmits<{
(e: 'toggle-completion', chore: ChoreWithCompletion): void;
(e: 'edit', chore: ChoreWithCompletion): void;
(e: 'delete', chore: ChoreWithCompletion): void;
(e: 'open-details', chore: ChoreWithCompletion): void;
(e: 'open-history', chore: ChoreWithCompletion): void;
(e: 'start-timer', chore: ChoreWithCompletion): void;
(e: 'stop-timer', chore: ChoreWithCompletion, timeEntryId: number): void;
}>();
const isActiveTimer = computed(() => {
return props.activeTimer && props.activeTimer.chore_assignment_id === props.chore.current_assignment_id;
});
const totalTime = computed(() => {
return props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0);
});
const toggleTimer = () => {
if (isActiveTimer.value) {
emit('stop-timer', props.chore, props.activeTimer!.id);
} else {
emit('start-timer', props.chore);
}
};
const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (chore.is_completed) return 'completed';
const today = new Date();
today.setHours(0, 0, 0, 0);
const dueDate = new Date(chore.next_due_date);
dueDate.setHours(0, 0, 0, 0);
if (dueDate < today) return 'overdue';
if (dueDate.getTime() === today.getTime()) return 'due-today';
return 'upcoming';
};
</script>
<script lang="ts">
export default {
name: 'ChoreItem'
}
</script>
<style scoped lang="scss">
.child-chore-list {
list-style: none;
padding-left: 2rem;
margin-top: 0.5rem;
border-left: 2px solid #e5e7eb;
}
.total-time {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
</style>

View File

@ -189,7 +189,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense'; import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';

View File

@ -20,7 +20,7 @@
<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 { apiClient, API_ENDPOINTS } from '@/config/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 VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue'; import VFormField from '@/components/valerie/VFormField.vue';

View File

@ -29,7 +29,7 @@
<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'; // onClickOutside removed
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming this path is correct
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import VModal from '@/components/valerie/VModal.vue'; import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue'; import VFormField from '@/components/valerie/VFormField.vue';
@ -38,6 +38,7 @@ import VTextarea from '@/components/valerie/VTextarea.vue';
import VSelect from '@/components/valerie/VSelect.vue'; import VSelect from '@/components/valerie/VSelect.vue';
import VButton from '@/components/valerie/VButton.vue'; import VButton from '@/components/valerie/VButton.vue';
import VSpinner from '@/components/valerie/VSpinner.vue'; import VSpinner from '@/components/valerie/VSpinner.vue';
import type { Group } from '@/types/group';
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;

View File

@ -1,10 +1,5 @@
<template> <template>
<button <button :type="type" :class="buttonClasses" :disabled="disabled" @click="handleClick">
:type="type"
:class="buttonClasses"
:disabled="disabled"
@click="handleClick"
>
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" /> <VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" /> <VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }"> <span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
@ -15,10 +10,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, PropType } from 'vue'; import { defineComponent, computed, type PropType } from 'vue';
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger'; type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially
type ButtonType = 'button' | 'submit' | 'reset'; type ButtonType = 'button' | 'submit' | 'reset';
@ -35,7 +30,7 @@ export default defineComponent({
variant: { variant: {
type: String as PropType<ButtonVariant>, type: String as PropType<ButtonVariant>,
default: 'primary', default: 'primary',
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger'].includes(value), validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger', 'success'].includes(value),
}, },
size: { size: {
type: String as PropType<ButtonSize>, type: String as PropType<ButtonSize>,
@ -162,6 +157,12 @@ export default defineComponent({
border-color: #dc3545; border-color: #dc3545;
} }
.btn-success {
background-color: #28a745; // Example success color
color: white;
border-color: #28a745;
}
// Sizes // Sizes
.btn-sm { .btn-sm {
padding: 0.25em 0.5em; padding: 0.25em 0.5em;
@ -180,9 +181,18 @@ export default defineComponent({
// Icon only // Icon only
.btn-icon-only { .btn-icon-only {
padding: 0.5em; // Adjust padding for icon-only buttons padding: 0.5em; // Adjust padding for icon-only buttons
// Ensure VIcon fills the space or adjust VIcon size if needed // Ensure VIcon fills the space or adjust VIcon size if needed
& .mr-1 { margin-right: 0 !important; } // Remove margin if accidentally applied & .mr-1 {
& .ml-1 { margin-left: 0 !important; } // Remove margin if accidentally applied margin-right: 0 !important;
}
// Remove margin if accidentally applied
& .ml-1 {
margin-left: 0 !important;
}
// Remove margin if accidentally applied
} }
.sr-only { .sr-only {
@ -201,6 +211,7 @@ export default defineComponent({
.mr-1 { .mr-1 {
margin-right: 0.25em; margin-right: 0.25em;
} }
.ml-1 { .ml-1 {
margin-left: 0.25em; margin-left: 0.25em;
} }

View File

@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
// Auth // Auth
AUTH: { AUTH: {
LOGIN: '/auth/jwt/login', LOGIN: '/auth/jwt/login',
GUEST: '/auth/guest',
SIGNUP: '/auth/register', SIGNUP: '/auth/register',
LOGOUT: '/auth/jwt/logout', LOGOUT: '/auth/jwt/logout',
REFRESH: '/auth/jwt/refresh', REFRESH: '/auth/jwt/refresh',
@ -21,11 +22,11 @@ export const API_ENDPOINTS = {
USERS: { USERS: {
PROFILE: '/users/me', PROFILE: '/users/me',
UPDATE_PROFILE: '/users/me', UPDATE_PROFILE: '/users/me',
PASSWORD: '/api/v1/users/password', PASSWORD: '/users/password',
AVATAR: '/api/v1/users/avatar', AVATAR: '/users/avatar',
SETTINGS: '/api/v1/users/settings', SETTINGS: '/users/settings',
NOTIFICATIONS: '/api/v1/users/notifications', NOTIFICATIONS: '/users/notifications',
PREFERENCES: '/api/v1/users/preferences', PREFERENCES: '/users/preferences',
}, },
// Lists // Lists
@ -42,10 +43,16 @@ export const API_ENDPOINTS = {
COMPLETE: (listId: string) => `/lists/${listId}/complete`, COMPLETE: (listId: string) => `/lists/${listId}/complete`,
REOPEN: (listId: string) => `/lists/${listId}/reopen`, REOPEN: (listId: string) => `/lists/${listId}/reopen`,
ARCHIVE: (listId: string) => `/lists/${listId}/archive`, ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
RESTORE: (listId: string) => `/lists/${listId}/restore`, UNARCHIVE: (listId: string) => `/lists/${listId}/unarchive`,
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`, DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
EXPORT: (listId: string) => `/lists/${listId}/export`, EXPORT: (listId: string) => `/lists/${listId}/export`,
IMPORT: '/lists/import', IMPORT: '/lists/import',
ARCHIVED: '/lists/archived',
},
CATEGORIES: {
BASE: '/categories',
BY_ID: (id: string) => `/categories/${id}`,
}, },
// Groups // Groups
@ -129,5 +136,7 @@ export const API_ENDPOINTS = {
HISTORY: (id: number) => `/chores/${id}/history`, HISTORY: (id: number) => `/chores/${id}/history`,
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`, ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`, ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
TIME_ENTRIES: (assignmentId: number) => `/chores/assignments/${assignmentId}/time-entries`,
TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`,
}, },
} }

View File

@ -1,17 +0,0 @@
import { api } from '@/services/api';
import { API_BASE_URL, API_VERSION } from './api-config';
export { API_ENDPOINTS } from './api-config';
// Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => {
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
};
// Helper function to make API calls
export const apiClient = {
get: (endpoint: string, config = {}) => api.get(getApiUrl(endpoint), config),
post: (endpoint: string, data = {}, config = {}) => api.post(getApiUrl(endpoint), data, config),
put: (endpoint: string, data = {}, config = {}) => api.put(getApiUrl(endpoint), data, config),
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
};

View File

@ -89,6 +89,10 @@
<span class="material-icons">task_alt</span> <span class="material-icons">task_alt</span>
<span class="tab-text">Chores</span> <span class="tab-text">Chores</span>
</router-link> </router-link>
<router-link to="/expenses" class="tab-item" active-class="active">
<span class="material-icons">payments</span>
<span class="tab-text">Expenses</span>
</router-link>
</nav> </nav>
</footer> </footer>

View File

@ -15,7 +15,9 @@
<form v-else @submit.prevent="onSubmitProfile"> <form v-else @submit.prevent="onSubmitProfile">
<!-- Profile Section --> <!-- Profile Section -->
<VCard class="mb-3"> <VCard class="mb-3">
<template #header><VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading></template> <template #header>
<VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;"> <div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow"> <VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
<VInput id="profileName" v-model="profile.name" required /> <VInput id="profileName" v-model="profile.name" required />
@ -35,7 +37,9 @@
<!-- Password Section --> <!-- Password Section -->
<form @submit.prevent="onChangePassword"> <form @submit.prevent="onChangePassword">
<VCard class="mb-3"> <VCard class="mb-3">
<template #header><VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading></template> <template #header>
<VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading>
</template>
<div class="flex flex-wrap" style="gap: 1rem;"> <div class="flex flex-wrap" style="gap: 1rem;">
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow"> <VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
<VInput type="password" id="currentPassword" v-model="password.current" required /> <VInput type="password" id="currentPassword" v-model="password.current" required />
@ -54,28 +58,33 @@
<!-- Notifications Section --> <!-- Notifications Section -->
<VCard> <VCard>
<template #header><VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading></template> <template #header>
<VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading>
</template>
<VList class="preference-list"> <VList class="preference-list">
<VListItem class="preference-item"> <VListItem class="preference-item">
<div class="preference-label"> <div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span> <span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small> <small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
</div> </div>
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" /> <VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
</VListItem> </VListItem>
<VListItem class="preference-item"> <VListItem class="preference-item">
<div class="preference-label"> <div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span> <span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small> <small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
</div> </div>
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle"/> <VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle" />
</VListItem> </VListItem>
<VListItem class="preference-item"> <VListItem class="preference-item">
<div class="preference-label"> <div class="preference-label">
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span> <span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small> <small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
</div> </div>
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle"/> <VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange"
:label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle" />
</VListItem> </VListItem>
</VList> </VList>
</VCard> </VCard>
@ -83,9 +92,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path import { useAuthStore } from '@/stores/auth';
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 VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue'; import VSpinner from '@/components/valerie/VSpinner.vue';
@ -130,6 +140,8 @@ const preferences = ref<Preferences>({
groupActivities: true, groupActivities: true,
}); });
const authStore = useAuthStore();
const fetchProfile = async () => { const fetchProfile = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;

View File

@ -0,0 +1,65 @@
<template>
<div>
<h1>Category Management</h1>
<CategoryForm v-if="showForm" :category="selectedCategory" :loading="loading" @submit="handleFormSubmit"
@cancel="cancelForm" />
<div v-else>
<button @click="showCreateForm">Create Category</button>
<ul v-if="categories.length">
<li v-for="category in categories" :key="category.id">
{{ category.name }}
<button @click="showEditForm(category)">Edit</button>
<button @click="handleDelete(category.id)" :disabled="loading">Delete</button>
</li>
</ul>
<p v-else-if="!loading">No categories found.</p>
</div>
<p v-if="loading">Loading...</p>
<p v-if="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useCategoryStore, type Category } from '../stores/categoryStore';
import CategoryForm from '../components/CategoryForm.vue';
import { storeToRefs } from 'pinia';
const categoryStore = useCategoryStore();
const { categories, loading, error } = storeToRefs(categoryStore);
const showForm = ref(false);
const selectedCategory = ref<Category | null>(null);
onMounted(() => {
categoryStore.fetchCategories();
});
const showCreateForm = () => {
selectedCategory.value = null;
showForm.value = true;
};
const showEditForm = (category: Category) => {
selectedCategory.value = category;
showForm.value = true;
};
const cancelForm = () => {
showForm.value = false;
selectedCategory.value = null;
};
const handleFormSubmit = async (data: { name: string }) => {
if (selectedCategory.value) {
await categoryStore.updateCategory(selectedCategory.value.id, data);
} else {
await categoryStore.createCategory(data);
}
cancelForm();
};
const handleDelete = async (id: number) => {
await categoryStore.deleteCategory(id);
};
</script>

View File

@ -4,23 +4,20 @@ import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns' import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
import { choreService } from '../services/choreService' import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory } from '../types/chore' import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory, ChoreWithCompletion } from '../types/chore'
import { groupService } from '../services/groupService' import { groupService } from '../services/groupService'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import ChoreItem from '@/components/ChoreItem.vue';
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ groupId?: number | string }>(); const props = defineProps<{ groupId?: number | string }>();
// Types // Types
interface ChoreWithCompletion extends Chore { // ChoreWithCompletion is now imported from ../types/chore
current_assignment_id: number | null;
is_completed: boolean;
completed_at: string | null;
updating: boolean;
assigned_user_name?: string;
completed_by_name?: string;
}
interface ChoreFormData { interface ChoreFormData {
name: string; name: string;
@ -30,6 +27,7 @@ interface ChoreFormData {
next_due_date: string; next_due_date: string;
type: 'personal' | 'group'; type: 'personal' | 'group';
group_id: number | undefined; group_id: number | undefined;
parent_chore_id?: number | null;
} }
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
@ -60,11 +58,26 @@ const initialChoreFormState: ChoreFormData = {
next_due_date: format(new Date(), 'yyyy-MM-dd'), next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal', type: 'personal',
group_id: undefined, group_id: undefined,
parent_chore_id: null,
} }
const choreForm = ref({ ...initialChoreFormState }) const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true) const isLoading = ref(true)
const authStore = useAuthStore();
const { isGuest } = storeToRefs(authStore);
const timeEntryStore = useTimeEntryStore();
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
const activeTimer = computed(() => {
for (const assignmentId in timeEntries.value) {
const entry = timeEntries.value[assignmentId].find(te => !te.end_time);
if (entry) return entry;
}
return null;
});
const loadChores = async () => { const loadChores = async () => {
const now = Date.now(); const now = Date.now();
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) { if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
@ -108,8 +121,16 @@ const loadGroups = async () => {
} }
} }
const loadTimeEntries = async () => {
chores.value.forEach(chore => {
if (chore.current_assignment_id) {
timeEntryStore.fetchTimeEntriesForAssignment(chore.current_assignment_id);
}
});
};
onMounted(() => { onMounted(() => {
loadChores() loadChores().then(loadTimeEntries);
loadGroups() loadGroups()
}) })
@ -173,17 +194,50 @@ const filteredChores = computed(() => {
return chores.value; return chores.value;
}); });
const groupedChores = computed(() => { const availableParentChores = computed(() => {
if (!filteredChores.value) return [] return chores.value.filter(c => {
// A chore cannot be its own parent
const choresByDate = filteredChores.value.reduce((acc, chore) => { if (isEditing.value && selectedChore.value && c.id === selectedChore.value.id) {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd') return false;
if (!acc[dueDate]) {
acc[dueDate] = []
} }
acc[dueDate].push(chore) // A chore that is already a subtask cannot be a parent
return acc if (c.parent_chore_id) {
}, {} as Record<string, ChoreWithCompletion[]>) return false;
}
// If a group is selected, only show chores from that group or personal chores
if (choreForm.value.group_id) {
return c.group_id === choreForm.value.group_id || c.type === 'personal';
}
// If no group is selected, only show personal chores that are not in a group
return c.type === 'personal' && !c.group_id;
});
});
const groupedChores = computed(() => {
if (!filteredChores.value) return [];
const choreMap = new Map<number, ChoreWithCompletion>();
filteredChores.value.forEach(chore => {
choreMap.set(chore.id, { ...chore, child_chores: [] });
});
const rootChores: ChoreWithCompletion[] = [];
choreMap.forEach(chore => {
if (chore.parent_chore_id && choreMap.has(chore.parent_chore_id)) {
choreMap.get(chore.parent_chore_id)?.child_chores?.push(chore);
} else {
rootChores.push(chore);
}
});
const choresByDate = rootChores.reduce((acc, chore) => {
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd');
if (!acc[dueDate]) {
acc[dueDate] = [];
}
acc[dueDate].push(chore);
return acc;
}, {} as Record<string, ChoreWithCompletion[]>);
return Object.keys(choresByDate) return Object.keys(choresByDate)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) .sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
@ -198,7 +252,7 @@ const groupedChores = computed(() => {
...chore, ...chore,
subtext: getChoreSubtext(chore) subtext: getChoreSubtext(chore)
})) }))
} };
}); });
}); });
@ -238,6 +292,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
next_due_date: chore.next_due_date, next_due_date: chore.next_due_date,
type: chore.type, type: chore.type,
group_id: chore.group_id ?? undefined, group_id: chore.group_id ?? undefined,
parent_chore_id: chore.parent_chore_id,
} }
showChoreModal.value = true showChoreModal.value = true
} }
@ -412,10 +467,29 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
if (isEqual(dueDate, today)) return 'due-today'; if (isEqual(dueDate, today)) return 'due-today';
return 'upcoming'; return 'upcoming';
}; };
const startTimer = async (chore: ChoreWithCompletion) => {
if (chore.current_assignment_id) {
await timeEntryStore.startTimeEntry(chore.current_assignment_id);
}
};
const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
if (chore.current_assignment_id) {
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
}
};
</script> </script>
<template> <template>
<div class="container"> <div class="container">
<div v-if="isGuest" class="guest-banner">
<p>
You are using a guest account.
<router-link to="/auth/signup">Sign up</router-link>
to save your data permanently.
</p>
</div>
<header v-if="!props.groupId" class="flex justify-between items-center"> <header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1> <h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal"> <button class="btn btn-primary" @click="openCreateChoreModal">
@ -444,44 +518,11 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2> <h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
<div class="neo-item-list-container"> <div class="neo-item-list-container">
<ul class="neo-item-list"> <ul class="neo-item-list">
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item" <ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
:class="`status-${getDueDateStatus(chore)}`"> :time-entries="chore.current_assignment_id ? timeEntries[chore.current_assignment_id] || [] : []"
<div class="neo-item-content"> :active-timer="activeTimer" @toggle-completion="toggleCompletion" @edit="openEditChoreModal"
<label class="neo-checkbox-label"> @delete="confirmDelete" @open-details="openChoreDetailModal" @open-history="openHistoryModal"
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)"> @start-timer="startTimer" @stop-timer="stopTimer" />
<div class="checkbox-content">
<div class="chore-main-info">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
{{ chore.name }}
</span>
<div class="chore-badges">
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
<span v-if="getDueDateStatus(chore) === 'overdue'" class="badge badge-overdue">Overdue</span>
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
Today</span>
</div>
</div>
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
</div>
</label>
<div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="openChoreDetailModal(chore)" title="View Details">
📋
</button>
<button class="btn btn-sm btn-neutral" @click="openHistoryModal(chore)" title="View History">
📅
</button>
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
{{ t('choresPage.edit', 'Edit') }}
</button>
<button class="btn btn-sm btn-danger" @click="confirmDelete(chore)">
{{ t('choresPage.delete', 'Delete') }}
</button>
</div>
</div>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -523,7 +564,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div> </div>
<div v-if="choreForm.frequency === 'custom'" class="form-group"> <div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)') <label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label> }}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days" <input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1"> class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div> </div>
@ -544,16 +585,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div> </div>
<div v-if="choreForm.type === 'group'" class="form-group"> <div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group') <label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label> }}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input"> <select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option> <option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select> </select>
</div> </div>
<div class="form-group">
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
}}</label>
<select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input">
<option :value="null">None</option>
<option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id">
{{ parent.name }}
</option>
</select>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{ <button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel') t('choresPage.form.cancel', 'Cancel')
}}</button> }}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') : <button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button> t('choresPage.form.create', 'Create') }}</button>
</div> </div>
@ -578,7 +629,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button> t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{ <button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete') t('choresPage.deleteConfirm.delete', 'Delete')
}}</button> }}</button>
</div> </div>
</div> </div>
</div> </div>
@ -603,7 +654,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div class="detail-item"> <div class="detail-item">
<span class="label">Created by:</span> <span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown' <span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span> }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="label">Due date:</span> <span class="label">Due date:</span>
@ -635,7 +686,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item"> <div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main"> <div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email <span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span> }}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }"> <span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }} {{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span> </span>
@ -693,6 +744,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.guest-banner {
background-color: #fffbeb;
color: #92400e;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border: 1px solid #fBBF24;
text-align: center;
}
.guest-banner p {
margin: 0;
}
.guest-banner a {
color: #92400e;
text-decoration: underline;
font-weight: bold;
}
.schedule-group { .schedule-group {
margin-bottom: 2rem; margin-bottom: 2rem;
position: relative; position: relative;

View File

@ -0,0 +1,738 @@
<template>
<div class="container">
<header class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">Expenses</h1>
<button @click="openCreateExpenseModal" class="btn btn-primary">
Add Expense
</button>
</header>
<div v-if="loading" class="flex justify-center mt-4">
<div class="spinner-dots">
<span></span>
<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="expenses.length === 0" class="empty-state-card">
<h3>No Expenses Yet</h3>
<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">
<div v-for="group in groupedExpenses" :key="group.title" class="schedule-group">
<h2 class="date-header">{{ group.title }}</h2>
<div class="neo-item-list-container">
<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>
<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">
<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>
</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">&times;</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>
<script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
// Types are kept local to this component
interface UserPublic {
id: number;
email: string;
full_name?: string;
}
interface ExpenseSplit {
id: number;
expense_id: number;
user_id: number;
owed_amount: string; // Decimal is string
share_percentage?: string;
share_units?: number;
user?: UserPublic;
created_at: string;
updated_at: string;
status: 'unpaid' | 'paid' | 'partially_paid';
paid_at?: string;
}
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
interface Expense {
id: number;
description: string;
total_amount: string; // Decimal is string
currency: string;
expense_date?: string;
split_type: SplitType;
list_id?: number;
group_id?: number;
item_id?: number;
paid_by_user_id: number;
is_recurring: boolean;
recurrence_pattern?: any;
created_at: string;
updated_at: string;
version: number;
created_by_user_id: number;
splits: ExpenseSplit[];
paid_by_user?: UserPublic;
overall_settlement_status: 'unpaid' | 'paid' | 'partially_paid';
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}
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 formError = ref<string | null>(null)
const initialFormState: CreateExpenseData = {
description: '',
total_amount: '',
currency: 'USD',
split_type: 'EQUAL',
isRecurring: false,
paid_by_user_id: 0, // Should be current user id by default
list_id: undefined,
group_id: undefined,
splits_in: [],
}
const formState = reactive<any>({ ...initialFormState })
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 (!expenses.value) return [];
const expensesByDate = expenses.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 openCreateExpenseModal = () => {
editingExpense.value = null
Object.assign(formState, initialFormState)
// 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
// Map snake_case from Expense to camelCase for CreateExpenseData/UpdateExpenseData
formState.description = expense.description
formState.total_amount = expense.total_amount
formState.currency = expense.currency
formState.split_type = expense.split_type
formState.isRecurring = expense.is_recurring
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 = () => {
showModal.value = false
editingExpense.value = null
formError.value = null
}
const handleFormSubmit = async () => {
formError.value = null
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
}
} else {
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
expenses.value.unshift(newExpense)
}
closeModal()
// re-fetch all expenses to ensure data consistency after create/update
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) {
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
console.error(err)
}
}
const handleDeleteExpense = async (expenseId: number) => {
if (!confirm('Are you sure you want to delete this expense? This action cannot be undone.')) return
try {
// 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)
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete expense.'
console.error(err)
}
}
</script>
<style scoped lang="scss">
.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;
}
.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;
}
</style>

View File

@ -219,10 +219,10 @@
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ <VButton variant="neutral" @click="closeSettleShareModal">{{
t('groupDetailPage.settleShareModal.cancelButton') t('groupDetailPage.settleShareModal.cancelButton')
}}</VButton> }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{ <VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
t('groupDetailPage.settleShareModal.confirmButton') t('groupDetailPage.settleShareModal.confirmButton')
}}</VButton> }}</VButton>
</template> </template>
</VModal> </VModal>
@ -242,7 +242,7 @@
<div class="meta-item"> <div class="meta-item">
<span class="label">Created by:</span> <span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown' <span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span> }}</span>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<span class="label">Created:</span> <span class="label">Created:</span>
@ -383,7 +383,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, reactive } from 'vue'; import { ref, onMounted, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useClipboard, useStorage } from '@vueuse/core'; import { useClipboard, useStorage } from '@vueuse/core';
import ListsPage from './ListsPage.vue'; import ListsPage from './ListsPage.vue';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';

View File

@ -122,7 +122,7 @@
import { ref, onMounted, nextTick, watch } from 'vue'; import { ref, onMounted, nextTick, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useStorage } from '@vueuse/core'; 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';

View File

@ -48,112 +48,127 @@
</div> </div>
</div> </div>
<p v-if="list.description" class="neo-description-internal">{{ list.description }}</p> <p v-if="list.description" class="neo-description-internal">{{ list.description }}</p>
<div class="supermarkt-mode-toggle">
<label>
Supermarkt Mode
<VToggleSwitch v-model="supermarktMode" />
</label>
</div>
<VProgressBar v-if="supermarktMode" :value="itemCompletionProgress" class="mt-4" />
</div> </div>
<!-- End Integrated Header --> <!-- End Integrated Header -->
<draggable v-model="list.items" item-key="id" handle=".drag-handle" @end="handleDragEnd" :disabled="!isOnline" <div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
class="neo-item-list"> :class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
<template #item="{ element: item }"> <h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
<li class="neo-list-item" <draggable v-model="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }"> :disabled="!isOnline" class="neo-item-list">
<div class="neo-item-content"> <template #item="{ element: item }">
<!-- Drag Handle --> <li class="neo-list-item"
<div class="drag-handle" v-if="isOnline"> :class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" <div class="neo-item-content">
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <!-- Drag Handle -->
<circle cx="9" cy="12" r="1"></circle> <div class="drag-handle" v-if="isOnline">
<circle cx="9" cy="5" r="1"></circle> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<circle cx="9" cy="19" r="1"></circle> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="15" cy="12" r="1"></circle> <circle cx="9" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle> <circle cx="9" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle> <circle cx="9" cy="19" r="1"></circle>
</svg> <circle cx="15" cy="12" r="1"></circle>
</div> <circle cx="15" cy="5" r="1"></circle>
<!-- Content when NOT editing --> <circle cx="15" cy="19" r="1"></circle>
<template v-if="!item.isEditing"> </svg>
<label class="neo-checkbox-label" @click.stop> </div>
<input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" /> <!-- Content when NOT editing -->
<div class="checkbox-content"> <template v-if="!item.isEditing">
<span class="checkbox-text-span" <label class="neo-checkbox-label" @click.stop>
:class="{ 'neo-completed-static': item.is_complete && !item.updating }"> <input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" />
{{ item.name }} <div class="checkbox-content">
</span> <span class="checkbox-text-span"
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span> :class="{ 'neo-completed-static': item.is_complete && !item.updating }">
<div v-if="item.is_complete" class="neo-price-input"> {{ item.name }}
<VInput type="number" :model-value="item.priceInput || ''" </span>
@update:modelValue="item.priceInput = $event" <span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24" step="0.01" <div v-if="item.is_complete" class="neo-price-input">
@blur="updateItemPrice(item)" <VInput type="number" :model-value="item.priceInput || ''"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" /> @update:modelValue="item.priceInput = $event"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div> </div>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="startItemEdit(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="deleteItem(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>
</label> </template>
<div class="neo-item-actions"> <!-- Content WHEN editing -->
<button class="neo-icon-button neo-edit-button" @click.stop="startItemEdit(item)" <template v-else>
:aria-label="$t('listDetailPage.items.editItemAriaLabel')"> <div class="inline-edit-form flex-grow flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" <VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> @keydown.esc.prevent="cancelItemEdit(item)" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> <VInput type="number" :model-value="item.editQuantity || ''"
</svg> @update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
</button> @keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)" <VSelect :model-value="item.editCategoryId" @update:modelValue="item.editCategoryId = $event"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')"> :options="categoryOptions" placeholder="Category" class="w-40" size="sm" />
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" </div>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <div class="neo-item-actions">
<path d="M3 6h18"></path> <button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)"
<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> :aria-label="$t('listDetailPage.buttons.saveChanges')">
<line x1="10" y1="11" x2="10" y2="17"></line> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<line x1="14" y1="11" x2="14" y2="17"></line> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</svg> <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
</button> <polyline points="17 21 17 13 7 13 7 21"></polyline>
</div> <polyline points="7 3 7 8 15 8"></polyline>
</template> </svg>
<!-- Content WHEN editing --> </button>
<template v-else> <button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)"
<div class="inline-edit-form flex-grow flex items-center gap-2"> :aria-label="$t('listDetailPage.buttons.cancel')">
<VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@keydown.esc.prevent="cancelItemEdit(item)" /> <circle cx="12" cy="12" r="10"></circle>
<VInput type="number" :model-value="item.editQuantity || ''" <line x1="15" y1="9" x2="9" y2="15"></line>
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm" <line x1="9" y1="9" x2="15" y2="15"></line>
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" /> </svg>
</div> </button>
<div class="neo-item-actions"> <button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)" :disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
:aria-label="$t('listDetailPage.buttons.saveChanges')"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
<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"> <path d="M3 6h18"></path>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></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">
<polyline points="17 21 17 13 7 13 7 21"></polyline> </path>
<polyline points="7 3 7 8 15 8"></polyline> <line x1="10" y1="11" x2="10" y2="17"></line>
</svg> <line x1="14" y1="11" x2="14" y2="17"></line>
</button> </svg>
<button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)" </button>
:aria-label="$t('listDetailPage.buttons.cancel')"> </div>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" </template>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> </div>
<circle cx="12" cy="12" r="10"></circle> </li>
<line x1="15" y1="9" x2="9" y2="15"></line> </template>
<line x1="9" y1="9" x2="15" y2="15"></line> </draggable>
</svg> </div>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(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>
</li>
</template>
</draggable>
<!-- New Add Item LI, integrated into the list --> <!-- New Add Item LI, integrated into the list -->
<li class="neo-list-item new-item-input-container"> <li class="neo-list-item new-item-input-container">
@ -163,6 +178,8 @@
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef" :placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
:data-list-id="list?.id" @keyup.enter="onAddItem" @blur="handleNewItemBlur" v-model="newItem.name" :data-list-id="list?.id" @keyup.enter="onAddItem" @blur="handleNewItemBlur" v-model="newItem.name"
@click.stop /> @click.stop />
<VSelect v-model="newItem.category_id" :options="categoryOptions" placeholder="Category" class="w-40"
size="sm" />
</label> </label>
</li> </li>
@ -379,10 +396,10 @@
<template #footer> <template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ <VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.modals.settleShare.cancelButton') $t('listDetailPage.modals.settleShare.cancelButton')
}}</VButton> }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ <VButton variant="primary" @click="handleConfirmSettle">{{
$t('listDetailPage.modals.settleShare.confirmButton') $t('listDetailPage.modals.settleShare.confirmButton')
}}</VButton> }}</VButton>
</template> </template>
</VModal> </VModal>
@ -393,9 +410,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'; import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS } from '@/services/api';
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline'; import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
@ -421,7 +438,12 @@ import VInput from '@/components/valerie/VInput.vue';
import VList from '@/components/valerie/VList.vue'; import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue'; import VListItem from '@/components/valerie/VListItem.vue';
import VCheckbox from '@/components/valerie/VCheckbox.vue'; import VCheckbox from '@/components/valerie/VCheckbox.vue';
import VProgressBar from '@/components/valerie/VProgressBar.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import { useCategoryStore } from '@/stores/categoryStore';
import { storeToRefs } from 'pinia';
import ExpenseCard from '@/components/ExpenseCard.vue';
const { t } = useI18n(); const { t } = useI18n();
@ -469,6 +491,7 @@ interface ItemWithUI extends Item {
isEditing?: boolean; // For inline editing state isEditing?: boolean; // For inline editing state
editName?: string; // Temporary name for inline editing editName?: string; // Temporary name for inline editing
editQuantity?: number | string | null; // Temporary quantity for inline editing editQuantity?: number | string | null; // Temporary quantity for inline editing
editCategoryId?: number | null; // Temporary category for inline editing
showFirework?: boolean; // For firework animation showFirework?: boolean; // For firework animation
} }
@ -523,9 +546,21 @@ const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null); const lastListUpdate = ref<string | null>(null);
const lastItemCount = ref<number | null>(null); const lastItemCount = ref<number | null>(null);
const newItem = ref<{ name: string; quantity?: number | string }>({ name: '' }); const supermarktMode = ref(false);
const categoryStore = useCategoryStore();
const { categories } = storeToRefs(categoryStore);
const newItem = ref<{ name: string; quantity?: number | string; category_id?: number | null }>({ name: '', category_id: null });
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null); const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
const categoryOptions = computed(() => {
return [
{ label: 'No Category', value: null },
...categories.value.map(c => ({ label: c.name, value: c.id })),
];
});
// OCR // OCR
const showOcrDialogState = ref(false); const showOcrDialogState = ref(false);
// const ocrModalRef = ref<HTMLElement | null>(null); // Removed // const ocrModalRef = ref<HTMLElement | null>(null); // Removed
@ -547,6 +582,12 @@ const listCostSummary = ref<ListCostSummaryData | null>(null);
const costSummaryLoading = ref(false); const costSummaryLoading = ref(false);
const costSummaryError = ref<string | null>(null); const costSummaryError = ref<string | null>(null);
const itemCompletionProgress = computed(() => {
if (!list.value?.items.length) return 0;
const completedCount = list.value.items.filter(i => i.is_complete).length;
return (completedCount / list.value.items.length) * 100;
});
// Settle Share // Settle Share
const authStore = useAuthStore(); const authStore = useAuthStore();
const showSettleModal = ref(false); const showSettleModal = ref(false);
@ -703,6 +744,7 @@ const onAddItem = async () => {
is_complete: false, is_complete: false,
price: null, price: null,
version: 1, version: 1,
category_id: newItem.value.category_id,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
list_id: list.value.id, list_id: list.value.id,
@ -715,6 +757,7 @@ const onAddItem = async () => {
list.value.items.push(optimisticItem); list.value.items.push(optimisticItem);
newItem.value.name = ''; newItem.value.name = '';
newItem.value.category_id = null;
if (itemNameInputRef.value?.$el) { if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus(); (itemNameInputRef.value.$el as HTMLElement).focus();
} }
@ -733,6 +776,9 @@ const onAddItem = async () => {
offlinePayload.quantity = String(rawQuantity); offlinePayload.quantity = String(rawQuantity);
} }
} }
if (newItem.value.category_id) {
offlinePayload.category_id = newItem.value.category_id;
}
offlineStore.addAction({ offlineStore.addAction({
type: 'create_list_item', type: 'create_list_item',
@ -752,7 +798,8 @@ const onAddItem = async () => {
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)), API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ {
name: itemName, name: itemName,
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null quantity: newItem.value.quantity ? String(newItem.value.quantity) : null,
category_id: newItem.value.category_id,
} }
); );
@ -1105,6 +1152,9 @@ onMounted(() => {
} }
} }
// Fetch categories relevant to the list (either personal or group)
categoryStore.fetchCategories(list.value?.group_id);
fetchListDetails().then(() => { fetchListDetails().then(() => {
startPolling(); startPolling();
}); });
@ -1121,6 +1171,7 @@ const startItemEdit = (item: ItemWithUI) => {
item.isEditing = true; item.isEditing = true;
item.editName = item.name; item.editName = item.name;
item.editQuantity = item.quantity ?? ''; item.editQuantity = item.quantity ?? '';
item.editCategoryId = item.category_id;
}; };
const cancelItemEdit = (item: ItemWithUI) => { const cancelItemEdit = (item: ItemWithUI) => {
@ -1140,6 +1191,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
name: String(item.editName).trim(), name: String(item.editName).trim(),
quantity: item.editQuantity ? String(item.editQuantity) : null, quantity: item.editQuantity ? String(item.editQuantity) : null,
version: item.version, version: item.version,
category_id: item.editCategoryId,
}; };
item.updating = true; item.updating = true;
@ -1157,6 +1209,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
item.is_complete = updatedItemFromApi.is_complete; item.is_complete = updatedItemFromApi.is_complete;
item.price = updatedItemFromApi.price; item.price = updatedItemFromApi.price;
item.updated_at = updatedItemFromApi.updated_at; item.updated_at = updatedItemFromApi.updated_at;
item.category_id = updatedItemFromApi.category_id;
item.isEditing = false; item.isEditing = false;
notificationStore.addNotification({ notificationStore.addNotification({
@ -1310,6 +1363,24 @@ const isExpenseExpanded = (expenseId: number) => {
return expandedExpenses.value.has(expenseId); return expandedExpenses.value.has(expenseId);
}; };
const groupedItems = computed(() => {
if (!list.value?.items) return [];
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
list.value.items.forEach(item => {
const categoryId = item.category_id;
const category = categories.value.find(c => c.id === categoryId);
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
if (!groups[categoryName]) {
groups[categoryName] = { categoryName, items: [] };
}
groups[categoryName].items.push(item);
});
return Object.values(groups);
});
</script> </script>
<style scoped> <style scoped>
@ -1999,4 +2070,18 @@ const isExpenseExpanded = (expenseId: number) => {
background: white; background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 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;
}
.category-group.highlight .neo-list-item:not(.is-complete) {
background-color: #e6f7ff;
}
</style> </style>

View File

@ -1,12 +1,17 @@
<template> <template>
<main class="container page-padding"> <main class="container page-padding">
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-bold">{{ pageTitle }}</h1>
<VToggleSwitch v-model="showArchived" :label="t('listsPage.showArchived')" />
</div>
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false"> <VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions> <template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton> <VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
</template> </template>
</VAlert> </VAlert>
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard" <VCard v-else-if="filteredLists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="t(noListsMessageKey)"> :empty-title="t(noListsMessageKey)">
<template #default> <template #default>
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p> <p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
@ -19,17 +24,24 @@
</template> </template>
</VCard> </VCard>
<div v-else-if="loading && lists.length === 0" class="loading-placeholder"> <div v-else-if="loading && filteredLists.length === 0" class="loading-placeholder">
{{ t('listsPage.loadingLists') }} {{ t('listsPage.loadingLists') }}
</div> </div>
<div v-else> <div v-else>
<div class="neo-lists-grid"> <div class="neo-lists-grid">
<div v-for="list in lists" :key="list.id" class="neo-list-card" <div v-for="list in filteredLists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)" :class="{ 'touch-active': touchActiveListId === list.id, 'archived': list.archived_at }"
@touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd" @click="navigateToList(list.id)" @touchstart.passive="handleTouchStart(list.id)"
@touchcancel.passive="handleTouchEnd" :data-list-id="list.id"> @touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div> <div class="neo-list-header">
<span>{{ list.name }}</span>
<div class="actions">
<VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm"
icon="archive" />
<VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" />
</div>
</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div> <div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list"> <ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id" <li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
@ -44,7 +56,7 @@
</div> </div>
</label> </label>
</li> </li>
<li class="neo-list-item new-item-input-container"> <li v-if="!list.archived_at" class="neo-list-item new-item-input-container">
<label class="neo-checkbox-label"> <label class="neo-checkbox-label">
<input type="checkbox" disabled /> <input type="checkbox" disabled />
<input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')" <input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')"
@ -68,12 +80,13 @@
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue'; import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS } from '@/services/api';
import CreateListModal from '@/components/CreateListModal.vue'; import CreateListModal from '@/components/CreateListModal.vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue'; import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue'; import VCard from '@/components/valerie/VCard.vue';
import VButton from '@/components/valerie/VButton.vue'; import VButton from '@/components/valerie/VButton.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
const { t } = useI18n(); const { t } = useI18n();
@ -95,6 +108,7 @@ interface List {
created_at: string; created_at: string;
version: number; version: number;
items: Item[]; items: Item[];
archived_at?: string | null;
} }
interface Group { interface Group {
@ -125,6 +139,8 @@ const router = useRouter();
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const lists = ref<(List & { items: Item[] })[]>([]); const lists = ref<(List & { items: Item[] })[]>([]);
const archivedLists = ref<List[]>([]);
const haveFetchedArchived = ref(false);
const allFetchedGroups = ref<Group[]>([]); const allFetchedGroups = ref<Group[]>([]);
const currentViewedGroup = ref<Group | null>(null); const currentViewedGroup = ref<Group | null>(null);
const showCreateModal = ref(false); const showCreateModal = ref(false);
@ -220,6 +236,18 @@ const fetchLists = async () => {
} }
}; };
const fetchArchivedLists = async () => {
if (haveFetchedArchived.value) return;
try {
const endpoint = API_ENDPOINTS.LISTS.ARCHIVED;
const response = await apiClient.get(endpoint);
archivedLists.value = response.data as List[];
haveFetchedArchived.value = true;
} catch (err) {
console.error('Failed to fetch archived lists:', err);
}
};
const fetchListsAndGroups = async () => { const fetchListsAndGroups = async () => {
loading.value = true; loading.value = true;
try { try {
@ -494,6 +522,54 @@ const stopPolling = () => {
} }
}; };
const showArchived = ref(false);
watch(showArchived, (isShowing) => {
if (isShowing) {
fetchArchivedLists();
}
});
const filteredLists = computed(() => {
if (showArchived.value) {
const combined = [...lists.value, ...archivedLists.value];
const uniqueLists = Array.from(new Map(combined.map(l => [l.id, l])).values());
return uniqueLists.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}
return lists.value.filter(list => !list.archived_at);
});
const archiveList = async (list: List) => {
try {
await apiClient.delete(API_ENDPOINTS.LISTS.BY_ID(list.id.toString()));
list.archived_at = new Date().toISOString();
const listIndex = lists.value.findIndex(l => l.id === list.id);
if (listIndex > -1) {
const [archivedItem] = lists.value.splice(listIndex, 1);
archivedLists.value.push(archivedItem);
}
} catch (error) {
console.error('Failed to archive list', error);
}
};
const unarchiveList = async (list: List) => {
try {
const response = await apiClient.post(API_ENDPOINTS.LISTS.UNARCHIVE(list.id.toString()));
const unarchivedList = response.data as List;
const listIndex = archivedLists.value.findIndex(l => l.id === list.id);
if (listIndex > -1) {
archivedLists.value.splice(listIndex, 1);
}
lists.value.push({ ...unarchivedList, items: unarchivedList.items || [] });
} catch (error) {
console.error('Failed to unarchive list', error);
}
};
onMounted(() => { onMounted(() => {
loadCachedData(); loadCachedData();
fetchListsAndGroups().then(() => { fetchListsAndGroups().then(() => {
@ -506,6 +582,8 @@ onMounted(() => {
watch(currentGroupId, () => { watch(currentGroupId, () => {
loadCachedData(); loadCachedData();
haveFetchedArchived.value = false;
archivedLists.value = [];
fetchListsAndGroups().then(() => { fetchListsAndGroups().then(() => {
if (lists.value.length > 0) { if (lists.value.length > 0) {
setupIntersectionObserver(); setupIntersectionObserver();
@ -918,4 +996,14 @@ onUnmounted(() => {
.item-appear { .item-appear {
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards; animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
.archived {
opacity: 0.6;
background-color: #f0f0f0;
}
.actions {
display: flex;
gap: 0.5rem;
}
</style> </style>

View File

@ -34,6 +34,12 @@
{{ t('loginPage.loginButton') }} {{ t('loginPage.loginButton') }}
</button> </button>
<div class="divider my-3">or</div>
<button type="button" class="btn btn-secondary w-full" @click="handleGuestLogin" :disabled="loading">
Continue as Guest
</button>
<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>
@ -103,6 +109,24 @@ const onSubmit = async () => {
loading.value = false; loading.value = false;
} }
}; };
const handleGuestLogin = async () => {
loading.value = true;
formErrors.value.general = undefined;
try {
await authStore.loginAsGuest();
notificationStore.addNotification({ message: 'Welcome, Guest!', type: 'success' });
const redirectPath = (route.query.redirect as string) || '/';
router.push(redirectPath);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to login as guest.';
formErrors.value.general = message;
console.error(message, error);
notificationStore.addNotification({ message, type: 'error' });
} finally {
loading.value = false;
}
};
</script> </script>
<style scoped> <style scoped>
@ -117,6 +141,30 @@ const onSubmit = async () => {
max-width: 400px; max-width: 400px;
} }
.divider {
display: flex;
align-items: center;
text-align: center;
color: #ccc;
font-size: 0.8em;
text-transform: uppercase;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid #eee;
}
.divider:not(:empty)::before {
margin-right: .5em;
}
.divider:not(:empty)::after {
margin-left: .5em;
}
.link-styled { .link-styled {
color: var(--primary); color: var(--primary);
text-decoration: none; text-decoration: none;

View File

@ -1,11 +1,13 @@
import { mount, flushPromises } from '@vue/test-utils'; import { mount, flushPromises } from '@vue/test-utils';
import AccountPage from '../AccountPage.vue'; // Adjust path import AccountPage from '../AccountPage.vue'; // Adjust path
import { apiClient, API_ENDPOINTS as MOCK_API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS as MOCK_API_ENDPOINTS } from '@/services/api';
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { useAuthStore } from '@/stores/auth';
import { createTestingPinia } from '@pinia/testing';
// --- Mocks --- // --- Mocks ---
vi.mock('@/config/api', () => ({ vi.mock('@/services/api', () => ({
apiClient: { apiClient: {
get: vi.fn(), get: vi.fn(),
put: vi.fn(), put: vi.fn(),
@ -69,7 +71,7 @@ describe('AccountPage.vue', () => {
describe('Rendering and Initial Data Fetching', () => { describe('Rendering and Initial Data Fetching', () => {
it('renders loading state initially', async () => { it('renders loading state initially', async () => {
mockApiClient.get.mockImplementationOnce(() => new Promise(() => {})); // Keep it pending mockApiClient.get.mockImplementationOnce(() => new Promise(() => { })); // Keep it pending
const wrapper = createWrapper(); const wrapper = createWrapper();
expect(wrapper.text()).toContain('Loading profile...'); expect(wrapper.text()).toContain('Loading profile...');
expect(wrapper.find('.spinner-dots').exists()).toBe(true); expect(wrapper.find('.spinner-dots').exists()).toBe(true);
@ -128,23 +130,23 @@ describe('AccountPage.vue', () => {
}); });
it('handles profile update failure', async () => { it('handles profile update failure', async () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
await flushPromises(); await flushPromises();
mockApiClient.put.mockRejectedValueOnce(new Error('Update failed')); mockApiClient.put.mockRejectedValueOnce(new Error('Update failed'));
await wrapper.find('form').trigger('submit.prevent'); await wrapper.find('form').trigger('submit.prevent');
await flushPromises(); await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' }); expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' });
}); });
}); });
describe('Change Password Form', () => { describe('Change Password Form', () => {
let wrapper: ReturnType<typeof createWrapper>; let wrapper: ReturnType<typeof createWrapper>;
beforeEach(async () => { beforeEach(async () => {
wrapper = createWrapper(); wrapper = createWrapper();
await flushPromises(); // Initial load await flushPromises(); // Initial load
}); });
it('changes password successfully', async () => { it('changes password successfully', async () => {
await wrapper.find('#currentPassword').setValue('currentPass123'); await wrapper.find('#currentPassword').setValue('currentPass123');
@ -166,79 +168,79 @@ describe('AccountPage.vue', () => {
}); });
it('shows validation error if new password is too short', async () => { it('shows validation error if new password is too short', async () => {
await wrapper.find('#currentPassword').setValue('currentPass123'); await wrapper.find('#currentPassword').setValue('currentPass123');
await wrapper.find('#newPassword').setValue('short'); await wrapper.find('#newPassword').setValue('short');
const forms = wrapper.findAll('form'); const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent'); await forms[1].trigger('submit.prevent');
await flushPromises(); await flushPromises();
expect(mockApiClient.put).not.toHaveBeenCalled(); expect(mockApiClient.put).not.toHaveBeenCalled();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
message: 'New password must be at least 8 characters long.', type: 'warning' message: 'New password must be at least 8 characters long.', type: 'warning'
});
}); });
});
it('shows validation error if fields are empty', async () => { it('shows validation error if fields are empty', async () => {
const forms = wrapper.findAll('form'); const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent'); // Submit with empty fields await forms[1].trigger('submit.prevent'); // Submit with empty fields
await flushPromises(); await flushPromises();
expect(mockApiClient.put).not.toHaveBeenCalled(); expect(mockApiClient.put).not.toHaveBeenCalled();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
message: 'Please fill in both current and new password fields.', type: 'warning' message: 'Please fill in both current and new password fields.', type: 'warning'
});
}); });
});
it('handles password change failure', async () => { it('handles password change failure', async () => {
await wrapper.find('#currentPassword').setValue('currentPass123'); await wrapper.find('#currentPassword').setValue('currentPass123');
await wrapper.find('#newPassword').setValue('newPasswordSecure'); await wrapper.find('#newPassword').setValue('newPasswordSecure');
mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed')); mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed'));
const forms = wrapper.findAll('form'); const forms = wrapper.findAll('form');
await forms[1].trigger('submit.prevent'); await forms[1].trigger('submit.prevent');
await flushPromises(); await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' }); expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' });
}); });
}); });
describe('Notification Preferences', () => { describe('Notification Preferences', () => {
it('updates preferences successfully when a toggle is changed', async () => { it('updates preferences successfully when a toggle is changed', async () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
await flushPromises(); // Initial load await flushPromises(); // Initial load
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0]; const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
const initialEmailPref = mockPreferencesData.emailNotifications; const initialEmailPref = mockPreferencesData.emailNotifications;
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref); expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
await flushPromises(); await flushPromises();
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref }; const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
expect(mockApiClient.put).toHaveBeenCalledWith( expect(mockApiClient.put).toHaveBeenCalledWith(
MOCK_API_ENDPOINTS.USERS.PREFERENCES, MOCK_API_ENDPOINTS.USERS.PREFERENCES,
expectedPreferences expectedPreferences
); );
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' }); expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
}); });
it('handles preference update failure', async () => { it('handles preference update failure', async () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
await flushPromises(); await flushPromises();
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1]; const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
const initialListPref = mockPreferencesData.listUpdates; const initialListPref = mockPreferencesData.listUpdates;
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed')); mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
await listUpdatesToggle.setValue(!initialListPref); await listUpdatesToggle.setValue(!initialListPref);
await flushPromises(); await flushPromises();
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' }); expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' });
// Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error. // Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error.
}); });
}); });
}); });

View File

@ -58,6 +58,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/ChoresPage.vue'), component: () => import('@/pages/ChoresPage.vue'),
meta: { requiresAuth: true, keepAlive: false }, meta: { requiresAuth: true, keepAlive: false },
}, },
{
path: '/expenses',
name: 'Expenses',
component: () => import('@/pages/ExpensePage.vue'),
meta: { requiresAuth: true, keepAlive: false },
},
], ],
}, },
{ {

View File

@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { API_BASE_URL, API_ENDPOINTS } from '@/config/api-config' // api-config.ts can be moved to src/config/ import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from '@/config/api-config' // api-config.ts can be moved to src/config/
import router from '@/router' // Import the router instance import router from '@/router' // Import the router instance
import { useAuthStore } from '@/stores/auth' // Import the auth store import { useAuthStore } from '@/stores/auth' // Import the auth store
import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
@ -7,7 +7,7 @@ import { stringify } from 'qs';
// Create axios instance // Create axios instance
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, // API_BASE_URL should come from env or config baseURL: `${API_BASE_URL}/api/${API_VERSION}`,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },

View File

@ -1,13 +1,13 @@
import { api } from './api' import { api } from './api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore' import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
import { groupService } from './groupService' import { groupService } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/config/api' import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { Group } from '@/types/group' import type { Group } from '@/types/group'
export const choreService = { export const choreService = {
async getAllChores(): Promise<Chore[]> { async getAllChores(): Promise<Chore[]> {
try { try {
const response = await api.get('/api/v1/chores/all') const response = await api.get('/chores/all')
return response.data return response.data
} catch (error) { } catch (error) {
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error) console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)

View File

@ -1,5 +1,5 @@
import { apiClient, API_ENDPOINTS } from '@/config/api'; import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { Group } from '@/types/group'; import type { Group, GroupCreate, GroupUpdate } from '@/types/group';
import type { ChoreHistory } from '@/types/chore'; import type { ChoreHistory } from '@/types/chore';
export const groupService = { export const groupService = {

View File

@ -11,6 +11,7 @@ export interface AuthState {
email: string email: string
name: string name: string
id?: string | number id?: string | number
is_guest?: boolean
} | null } | null
} }
@ -23,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
// Getters // Getters
const isAuthenticated = computed(() => !!accessToken.value) const isAuthenticated = computed(() => !!accessToken.value)
const getUser = computed(() => user.value) const getUser = computed(() => user.value)
const isGuest = computed(() => user.value?.is_guest ?? false)
// Actions // Actions
const setTokens = (tokens: { access_token: string; refresh_token?: string }) => { const setTokens = (tokens: { access_token: string; refresh_token?: string }) => {
@ -109,6 +111,14 @@ export const useAuthStore = defineStore('auth', () => {
return response.data return response.data
} }
const loginAsGuest = async () => {
const response = await api.post(API_ENDPOINTS.AUTH.GUEST)
const { access_token, refresh_token } = response.data
setTokens({ access_token, refresh_token })
await fetchCurrentUser()
return response.data
}
const signup = async (userData: { name: string; email: string; password: string }) => { const signup = async (userData: { name: string; email: string; password: string }) => {
const response = await api.post(API_ENDPOINTS.AUTH.SIGNUP, userData) const response = await api.post(API_ENDPOINTS.AUTH.SIGNUP, userData)
return response.data return response.data
@ -125,11 +135,13 @@ export const useAuthStore = defineStore('auth', () => {
refreshToken, refreshToken,
isAuthenticated, isAuthenticated,
getUser, getUser,
isGuest,
setTokens, setTokens,
clearTokens, clearTokens,
setUser, setUser,
fetchCurrentUser, fetchCurrentUser,
login, login,
loginAsGuest,
signup, signup,
logout, logout,
} }

View File

@ -0,0 +1,86 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { Category } from '@/types/category';
export interface CategoryCreate {
name: string;
group_id?: number;
}
export const useCategoryStore = defineStore('category', () => {
const categories = ref<Category[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchCategories(groupId?: number) {
loading.value = true;
error.value = null;
try {
const endpoint = API_ENDPOINTS.CATEGORIES.BASE;
const params = groupId ? { group_id: groupId } : {};
const response = await apiClient.get(endpoint, { params });
categories.value = response.data;
} catch (e) {
error.value = 'Failed to fetch categories.';
console.error(e);
} finally {
loading.value = false;
}
}
async function createCategory(categoryData: CategoryCreate) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.post(API_ENDPOINTS.CATEGORIES.BASE, categoryData);
categories.value.push(response.data);
} catch (e) {
error.value = 'Failed to create category.';
console.error(e);
} finally {
loading.value = false;
}
}
async function updateCategory(id: number, categoryData: Partial<CategoryCreate>) {
loading.value = true;
error.value = null;
try {
const response = await apiClient.put(API_ENDPOINTS.CATEGORIES.BY_ID(id.toString()), categoryData);
const index = categories.value.findIndex(c => c.id === id);
if (index !== -1) {
categories.value[index] = response.data;
}
} catch (e) {
error.value = 'Failed to update category.';
console.error(e);
} finally {
loading.value = false;
}
}
async function deleteCategory(id: number) {
loading.value = true;
error.value = null;
try {
await apiClient.delete(API_ENDPOINTS.CATEGORIES.BY_ID(id.toString()));
categories.value = categories.value.filter(c => c.id !== id);
} catch (e) {
error.value = 'Failed to delete category.';
console.error(e);
} finally {
loading.value = false;
}
}
return {
categories,
loading,
error,
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
};
});

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { apiClient, API_ENDPOINTS } from '@/config/api' import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { import type {
Expense, Expense,
ExpenseSplit, ExpenseSplit,

View File

@ -20,7 +20,8 @@ export type CreateListItemPayload = {
name: string name: string
quantity?: number | string quantity?: number | string
completed?: boolean completed?: boolean
price?: number | null /* other item properties */ price?: number | null
category_id?: number | null
} }
} }
export type UpdateListItemPayload = { export type UpdateListItemPayload = {

View File

@ -0,0 +1,79 @@
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,
};
});

View File

@ -1,4 +1,4 @@
import type { User } from './user' import type { UserPublic as User } from './user'
export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom' export type ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
export type ChoreType = 'personal' | 'group' export type ChoreType = 'personal' | 'group'
@ -69,3 +69,15 @@ export interface ChoreAssignmentHistory {
changed_by_user?: User changed_by_user?: User
timestamp: string timestamp: string
} }
export interface ChoreWithCompletion extends Chore {
current_assignment_id: number | null;
is_completed: boolean;
completed_at: string | null;
updating: boolean;
assigned_user_name?: string;
completed_by_name?: string;
parent_chore_id?: number | null;
child_chores?: ChoreWithCompletion[];
subtext?: string;
}

View File

@ -50,16 +50,15 @@ export interface ExpenseSplit {
id: number id: number
expense_id: number expense_id: number
user_id: number user_id: number
user?: UserPublic | null
owed_amount: string // String representation of Decimal owed_amount: string // String representation of Decimal
share_percentage?: string | null share_percentage?: string | null
share_units?: number | null share_units?: number | null
created_at: string created_at: string
updated_at: string updated_at: string
status: ExpenseSplitStatusEnum status: ExpenseSplitStatusEnum
paid_at?: string | null paid_at?: string | null
settlement_activities: SettlementActivity[] settlement_activities: SettlementActivity[]
user?: UserPublic | null
} }
export interface RecurrencePatternCreate { export interface RecurrencePatternCreate {
@ -124,3 +123,32 @@ export interface Expense {
parentExpenseId?: number parentExpenseId?: number
generatedExpenses?: Expense[] generatedExpenses?: Expense[]
} }
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
export type SettlementStatus = 'unpaid' | 'paid' | 'partially_paid';
export interface Expense {
id: number;
description: string;
total_amount: string; // Decimal is string
currency: string;
expense_date?: string;
split_type: SplitType;
list_id?: number;
group_id?: number;
item_id?: number;
paid_by_user_id: number;
is_recurring: boolean;
recurrence_pattern?: any;
created_at: string;
updated_at: string;
version: number;
created_by_user_id: number;
splits: ExpenseSplit[];
paid_by_user?: UserPublic;
overall_settlement_status: SettlementStatus;
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}

View File

@ -5,6 +5,7 @@ export interface Item {
is_complete: boolean is_complete: boolean
price?: string | null // String representation of Decimal price?: string | null // String representation of Decimal
list_id: number list_id: number
category_id?: number | null
created_at: string created_at: string
updated_at: string updated_at: string
version: number version: number

View File

@ -0,0 +1,22 @@
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns';
export const formatDateHeader = (date: Date) => {
const today = startOfDay(new Date());
const itemDate = startOfDay(date);
if (isEqual(itemDate, today)) {
return `Today, ${format(itemDate, 'eee, d MMM')}`;
}
return format(itemDate, 'eee, d MMM');
};
export const formatDuration = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.round(seconds % 60);
return [
h > 0 ? `${h}h` : '',
m > 0 ? `${m}m` : '',
s > 0 ? `${s}s` : ''
].filter(Boolean).join(' ');
};