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:
commit
453ce9e45f
32
.cursor/rules/fastapi.mdc
Normal file
32
.cursor/rules/fastapi.mdc
Normal 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 FastAPI’s 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
37
.cursor/rules/vue.mdc
Normal 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.
|
||||
|
||||
|
||||
|
||||
|
91
be/alembic/versions/bdf7427ccfa3_feature_updates_phase1.py
Normal file
91
be/alembic/versions/bdf7427ccfa3_feature_updates_phase1.py
Normal 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 ###
|
@ -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
55
be/app/api/auth/guest.py
Normal 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
26
be/app/api/auth/jwt.py
Normal 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"],
|
||||
)
|
@ -9,7 +9,10 @@ from app.api.v1.endpoints import ocr
|
||||
from app.api.v1.endpoints import costs
|
||||
from app.api.v1.endpoints import financials
|
||||
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()
|
||||
|
||||
@ -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(financials.router, prefix="/financials", tags=["Financials"])
|
||||
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(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"])
|
||||
|
75
be/app/api/v1/endpoints/categories.py
Normal file
75
be/app/api/v1/endpoints/categories.py
Normal 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)
|
@ -1,18 +1,21 @@
|
||||
# app/api/v1/endpoints/chores.py
|
||||
import logging
|
||||
from typing import List as PyList, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_transactional_session, get_session
|
||||
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 (
|
||||
ChoreCreate, ChoreUpdate, ChorePublic,
|
||||
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
|
||||
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
||||
)
|
||||
from app.schemas.time_entry import TimeEntryPublic
|
||||
from app.crud import chore as crud_chore
|
||||
from app.crud import history as crud_history
|
||||
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
||||
@ -506,4 +509,123 @@ async def get_chore_assignment_history(
|
||||
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
|
||||
|
||||
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
|
46
be/app/api/v1/endpoints/history.py
Normal file
46
be/app/api/v1/endpoints/history.py
Normal 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
|
@ -94,6 +94,24 @@ async def read_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(
|
||||
"/statuses",
|
||||
response_model=PyList[ListStatusWithId],
|
||||
@ -185,29 +203,29 @@ async def update_list(
|
||||
@router.delete(
|
||||
"/{list_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete List",
|
||||
summary="Archive List",
|
||||
tags=["Lists"],
|
||||
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,
|
||||
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),
|
||||
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,
|
||||
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)
|
||||
|
||||
if expected_version is not None and list_db.version != expected_version:
|
||||
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}."
|
||||
)
|
||||
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."
|
||||
)
|
||||
|
||||
await crud_list.delete_list(db=db, list_db=list_db)
|
||||
logger.info(f"List {list_id} (version: {list_db.version}) deleted successfully by user {current_user.email}.")
|
||||
await crud_list.archive_list(db=db, list_db=list_db)
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
"/{list_id}/status",
|
||||
response_model=ListStatus,
|
||||
|
11
be/app/api/v1/endpoints/users.py
Normal file
11
be/app/api/v1/endpoints/users.py
Normal 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"],
|
||||
)
|
@ -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)):
|
||||
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:
|
||||
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
|
||||
|
78
be/app/core/cache.py
Normal file
78
be/app/core/cache.py
Normal 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
7
be/app/core/redis.py
Normal 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
|
@ -1,4 +1,7 @@
|
||||
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")
|
||||
|
||||
@ -32,4 +35,40 @@ def hash_password(password: str) -> str:
|
||||
Returns:
|
||||
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
77
be/app/crud/audit.py
Normal 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
38
be/app/crud/category.py
Normal 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
|
@ -39,7 +39,8 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history)
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.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.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history)
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.order_by(Chore.next_due_date, Chore.name)
|
||||
)
|
||||
@ -85,8 +87,14 @@ async def create_chore(
|
||||
if group_id:
|
||||
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(
|
||||
**chore_in.model_dump(exclude_unset=True, exclude={'group_id'}),
|
||||
**chore_data,
|
||||
group_id=group_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
@ -115,7 +123,8 @@ async def create_chore(
|
||||
selectinload(Chore.group),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history)
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
)
|
||||
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.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history)
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@ -168,7 +178,8 @@ async def get_personal_chores(
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history)
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.order_by(Chore.next_due_date, Chore.name)
|
||||
)
|
||||
@ -193,7 +204,8 @@ async def get_chores_by_group_id(
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history)
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.order_by(Chore.next_due_date, Chore.name)
|
||||
)
|
||||
@ -228,6 +240,14 @@ async def update_chore(
|
||||
raise PermissionDeniedError(detail="Only the creator can update personal chores")
|
||||
|
||||
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:
|
||||
new_type = update_data['type']
|
||||
@ -289,7 +309,8 @@ async def update_chore(
|
||||
selectinload(Chore.group),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history)
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@ -379,6 +400,7 @@ async def create_chore_assignment(
|
||||
.where(ChoreAssignment.id == db_assignment.id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
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)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
@ -414,6 +437,7 @@ async def get_user_assignments(
|
||||
|
||||
query = query.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||
@ -443,6 +467,7 @@ async def get_chore_assignments(
|
||||
.where(ChoreAssignment.chore_id == chore_id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
@ -456,75 +481,72 @@ async def update_chore_assignment(
|
||||
assignment_in: ChoreAssignmentUpdate,
|
||||
user_id: int
|
||||
) -> 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():
|
||||
db_assignment = await get_chore_assignment_by_id(db, assignment_id)
|
||||
if not db_assignment:
|
||||
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||
|
||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
||||
if not chore:
|
||||
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
||||
return None
|
||||
|
||||
can_manage = False
|
||||
if chore.type == ChoreTypeEnum.personal:
|
||||
can_manage = chore.created_by_id == user_id
|
||||
else:
|
||||
can_manage = await is_user_member(db, chore.group_id, user_id)
|
||||
# Permission Check: only assigned user or group owner can update
|
||||
is_allowed = db_assignment.assigned_to_user_id == user_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_complete = db_assignment.assigned_to_user_id == user_id
|
||||
if not is_allowed:
|
||||
raise PermissionDeniedError("You cannot update this chore assignment.")
|
||||
|
||||
original_status = db_assignment.is_complete
|
||||
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():
|
||||
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:
|
||||
await db.flush()
|
||||
result = await db.execute(
|
||||
select(ChoreAssignment)
|
||||
.where(ChoreAssignment.id == db_assignment.id)
|
||||
.where(ChoreAssignment.id == assignment_id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating chore assignment {assignment_id}: {e}", exc_info=True)
|
||||
raise DatabaseIntegrityError(f"Could not update chore assignment {assignment_id}. Error: {str(e)}")
|
||||
logger.error(f"Error updating assignment: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
raise DatabaseIntegrityError(f"Could not update assignment. Error: {str(e)}")
|
||||
|
||||
async def delete_chore_assignment(
|
||||
db: AsyncSession,
|
||||
|
@ -7,6 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # A
|
||||
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
|
||||
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
|
||||
from datetime import datetime, timezone # Added timezone
|
||||
import json
|
||||
|
||||
from app.models import (
|
||||
Expense as ExpenseModel,
|
||||
@ -34,6 +35,7 @@ from app.core.exceptions import (
|
||||
ExpenseOperationError # Added specific exception
|
||||
)
|
||||
from app.models import RecurrencePattern
|
||||
from app.crud.audit import create_financial_audit_log
|
||||
|
||||
# Placeholder for InvalidOperationError if not defined in app.core.exceptions
|
||||
# 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
|
||||
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.
|
||||
return loaded_expense
|
||||
|
||||
@ -611,22 +620,28 @@ async def get_user_accessible_expenses(db: AsyncSession, user_id: int, skip: int
|
||||
)
|
||||
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.
|
||||
Only allows updates to description, currency, and expense_date to avoid split complexities.
|
||||
Requires version matching for optimistic locking.
|
||||
Updates an expense. For now, only allows simple field updates.
|
||||
More complex updates (like changing split logic) would require a more sophisticated approach.
|
||||
"""
|
||||
if expense_in.version is None:
|
||||
raise InvalidOperationError("Version is required for updating an expense.")
|
||||
|
||||
if expense_db.version != expense_in.version:
|
||||
raise InvalidOperationError(
|
||||
f"Expense '{expense_db.description}' (ID: {expense_db.id}) has been modified. "
|
||||
f"Your version is {expense_in.version}, current version is {expense_db.version}. Please refresh.",
|
||||
# status_code=status.HTTP_409_CONFLICT # This would be for the API layer to set
|
||||
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be updated. "
|
||||
f"Your expected version {expense_in.version} does not match current version {expense_db.version}. Please refresh.",
|
||||
)
|
||||
|
||||
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"}
|
||||
|
||||
updated_something = False
|
||||
@ -635,38 +650,41 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
|
||||
setattr(expense_db, field, value)
|
||||
updated_something = True
|
||||
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.")
|
||||
|
||||
if not updated_something and not expense_in.model_fields_set.intersection(allowed_to_update):
|
||||
# No actual updatable fields were provided in the payload, even if others (like version) were.
|
||||
# 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.")
|
||||
if not updated_something:
|
||||
pass
|
||||
|
||||
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.updated_at = datetime.now(timezone.utc) # Manually update timestamp
|
||||
# db.add(expense_db) # Not strictly necessary as expense_db is already tracked by the session
|
||||
expense_db.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush() # Persist changes to the DB and run constraints
|
||||
await db.refresh(expense_db) # Refresh the object from the DB
|
||||
return expense_db
|
||||
except InvalidOperationError: # Re-raise validation errors to be handled by the caller
|
||||
raise
|
||||
await db.flush()
|
||||
|
||||
after_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__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="EXPENSE_UPDATED",
|
||||
entity=expense_db,
|
||||
details={"before": before_state, "after": after_state}
|
||||
)
|
||||
|
||||
await db.refresh(expense_db)
|
||||
return expense_db
|
||||
except IntegrityError as e:
|
||||
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
|
||||
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)
|
||||
# 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
|
||||
# No generic Exception catch here, let other unexpected errors propagate if not SQLAlchemy related.
|
||||
|
||||
|
||||
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_version: Optional[int] = None) -> None:
|
||||
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
|
||||
"""
|
||||
Deletes an expense. Requires version matching if expected_version is provided.
|
||||
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(
|
||||
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.",
|
||||
# status_code=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
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.flush() # Ensure the delete operation is sent to the database
|
||||
except InvalidOperationError: # Re-raise validation errors
|
||||
raise
|
||||
await db.flush()
|
||||
except IntegrityError as e:
|
||||
logger.error(f"Database integrity error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
|
||||
# The transaction context manager (begin_nested/begin) handles rollback.
|
||||
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_db.id} due to database integrity issue.") from e
|
||||
except SQLAlchemyError as e: # Catch other SQLAlchemy errors
|
||||
logger.error(f"Database transaction error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
|
||||
# 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
|
||||
logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
|
||||
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {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
|
||||
return None
|
||||
|
||||
# Note: The InvalidOperationError is a simple ValueError placeholder.
|
||||
|
@ -1,13 +1,14 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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 typing import Optional, List
|
||||
from sqlalchemy import delete, func
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy import delete, func, and_, or_, update, desc
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
|
||||
from app.schemas.group import GroupCreate
|
||||
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, GroupPublic
|
||||
from app.models import UserRoleEnum
|
||||
from app.core.exceptions import (
|
||||
GroupOperationError,
|
||||
@ -17,8 +18,10 @@ from app.core.exceptions import (
|
||||
DatabaseQueryError,
|
||||
DatabaseTransactionError,
|
||||
GroupMembershipError,
|
||||
GroupPermissionError # Import GroupPermissionError
|
||||
GroupPermissionError,
|
||||
PermissionDeniedError
|
||||
)
|
||||
from app.core.cache import cache
|
||||
|
||||
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:
|
||||
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]:
|
||||
"""Gets a single group by its ID, optionally loading members."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(GroupModel)
|
||||
.where(GroupModel.id == group_id)
|
||||
.options(
|
||||
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
|
||||
selectinload(GroupModel.chore_history) # Eager load chore history
|
||||
)
|
||||
"""Get a group by its ID with caching, including member associations and chore history."""
|
||||
result = await db.execute(
|
||||
select(GroupModel)
|
||||
.where(GroupModel.id == group_id)
|
||||
.options(
|
||||
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
|
||||
selectinload(GroupModel.chore_history)
|
||||
)
|
||||
return result.scalars().first()
|
||||
except OperationalError as e:
|
||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseQueryError(f"Failed to query group: {str(e)}")
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
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."""
|
||||
|
@ -33,6 +33,7 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
|
||||
db_item = ItemModel(
|
||||
name=item_in.name,
|
||||
quantity=item_in.quantity,
|
||||
category_id=item_in.category_id,
|
||||
list_id=list_id,
|
||||
added_by_id=user_id,
|
||||
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'})
|
||||
|
||||
if 'category_id' in update_data:
|
||||
item_db.category_id = update_data.pop('category_id')
|
||||
|
||||
if 'position' in update_data:
|
||||
new_position = update_data.pop('position')
|
||||
|
||||
|
@ -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 typing import Optional, List as PyList
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.schemas.list import ListStatus
|
||||
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)
|
||||
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."""
|
||||
try:
|
||||
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:
|
||||
conditions.append(ListModel.group_id.in_(user_group_ids))
|
||||
|
||||
query = (
|
||||
select(ListModel)
|
||||
.where(or_(*conditions))
|
||||
.options(
|
||||
selectinload(ListModel.creator),
|
||||
selectinload(ListModel.group),
|
||||
selectinload(ListModel.items).options(
|
||||
joinedload(ItemModel.added_by_user),
|
||||
joinedload(ItemModel.completed_by_user)
|
||||
)
|
||||
query = select(ListModel).where(or_(*conditions))
|
||||
|
||||
if not include_archived:
|
||||
query = query.where(ListModel.archived_at.is_(None))
|
||||
|
||||
query = query.options(
|
||||
selectinload(ListModel.creator),
|
||||
selectinload(ListModel.group),
|
||||
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)
|
||||
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)
|
||||
raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
|
||||
|
||||
async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
|
||||
"""Deletes a list record. Version check should be done by the caller (API endpoint)."""
|
||||
async def archive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
|
||||
"""Archives a list record by setting the archived_at timestamp."""
|
||||
try:
|
||||
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:
|
||||
logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True)
|
||||
raise DatabaseConnectionError(f"Database connection error while deleting list: {str(e)}")
|
||||
logger.error(f"Database connection error while archiving list: {str(e)}", exc_info=True)
|
||||
raise DatabaseConnectionError(f"Database connection error while archiving list: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Unexpected SQLAlchemy error while deleting list: {str(e)}", exc_info=True)
|
||||
raise DatabaseTransactionError(f"Failed to delete list: {str(e)}")
|
||||
logger.error(f"Unexpected SQLAlchemy error while archiving list: {str(e)}", exc_info=True)
|
||||
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:
|
||||
"""Fetches a list and verifies user permission."""
|
||||
|
@ -26,6 +26,7 @@ from app.core.exceptions import (
|
||||
SettlementOperationError,
|
||||
ConflictError
|
||||
)
|
||||
from app.crud.audit import create_financial_audit_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -75,6 +76,13 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
|
||||
|
||||
if loaded_settlement is None:
|
||||
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
|
||||
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
|
||||
@ -160,7 +168,7 @@ async def get_settlements_involving_user(
|
||||
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.
|
||||
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."
|
||||
)
|
||||
|
||||
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"})
|
||||
allowed_to_update = {"description", "settlement_date"}
|
||||
updated_something = False
|
||||
@ -210,6 +223,19 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
|
||||
|
||||
if updated_settlement is None:
|
||||
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
|
||||
except ConflictError as e:
|
||||
@ -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)}")
|
||||
|
||||
|
||||
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.
|
||||
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."
|
||||
)
|
||||
|
||||
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)
|
||||
except ConflictError as e:
|
||||
raise
|
||||
|
@ -15,6 +15,7 @@ from app.models import (
|
||||
ExpenseOverallStatusEnum,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from app.crud.audit import create_financial_audit_log
|
||||
|
||||
|
||||
class SettlementActivityCreatePlaceholder(BaseModel):
|
||||
@ -140,6 +141,13 @@ async def create_settlement_activity(
|
||||
db.add(db_settlement_activity)
|
||||
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
|
||||
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:
|
||||
|
@ -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)
|
||||
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."""
|
||||
try:
|
||||
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(
|
||||
email=user_in.email,
|
||||
hashed_password=_hashed_password,
|
||||
name=user_in.name
|
||||
name=user_in.name,
|
||||
is_guest=is_guest
|
||||
)
|
||||
db.add(db_user)
|
||||
await db.flush()
|
||||
|
@ -57,32 +57,6 @@ app.add_middleware(
|
||||
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.get("/health", tags=["Health"])
|
||||
|
@ -93,6 +93,7 @@ class User(Base):
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_superuser = 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)
|
||||
|
||||
# --- Relationships ---
|
||||
@ -112,6 +113,9 @@ class User(Base):
|
||||
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")
|
||||
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):
|
||||
__tablename__ = "groups"
|
||||
@ -120,6 +124,8 @@ class Group(Base):
|
||||
name = Column(String, index=True, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), 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")
|
||||
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)
|
||||
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')
|
||||
archived_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
creator = relationship("User", back_populates="created_lists")
|
||||
group = relationship("Group", back_populates="lists")
|
||||
@ -199,6 +206,7 @@ class Item(Base):
|
||||
is_complete = Column(Boolean, default=False, nullable=False)
|
||||
price = Column(Numeric(10, 2), nullable=True)
|
||||
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)
|
||||
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
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")
|
||||
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
|
||||
expenses = relationship("Expense", back_populates="item")
|
||||
category = relationship("Category", back_populates="items")
|
||||
|
||||
class Expense(Base):
|
||||
__tablename__ = "expenses"
|
||||
@ -248,7 +257,7 @@ class Expense(Base):
|
||||
last_occurrence = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__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):
|
||||
@ -335,6 +344,7 @@ class Chore(Base):
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(Text, nullable=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)
|
||||
custom_interval_days = Column(Integer, nullable=True)
|
||||
@ -349,6 +359,8 @@ class Chore(Base):
|
||||
creator = relationship("User", back_populates="created_chores")
|
||||
assignments = relationship("ChoreAssignment", 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 ---
|
||||
@ -369,6 +381,7 @@ class ChoreAssignment(Base):
|
||||
chore = relationship("Chore", back_populates="assignments")
|
||||
assigned_user = relationship("User", back_populates="assigned_chores")
|
||||
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
|
||||
time_entries = relationship("TimeEntry", back_populates="assignment", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
# === NEW: RecurrencePattern Model ===
|
||||
@ -419,3 +432,41 @@ class ChoreAssignmentHistory(Base):
|
||||
|
||||
assignment = relationship("ChoreAssignment", back_populates="history")
|
||||
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
20
be/app/schemas/audit.py
Normal 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
|
19
be/app/schemas/category.py
Normal file
19
be/app/schemas/category.py
Normal 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
|
@ -1,3 +1,4 @@
|
||||
from __future__ import annotations
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
@ -55,6 +56,7 @@ class ChoreBase(BaseModel):
|
||||
|
||||
class ChoreCreate(ChoreBase):
|
||||
group_id: Optional[int] = None
|
||||
parent_chore_id: Optional[int] = None
|
||||
|
||||
@field_validator('group_id')
|
||||
@classmethod
|
||||
@ -89,11 +91,13 @@ class ChorePublic(ChoreBase):
|
||||
group_id: Optional[int] = None
|
||||
created_by_id: int
|
||||
last_completed_at: Optional[datetime] = None
|
||||
parent_chore_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
|
||||
assignments: List[ChoreAssignmentPublic] = []
|
||||
history: List[ChoreHistoryPublic] = []
|
||||
child_chores: List[ChorePublic] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
@ -20,6 +20,7 @@ class ItemPublic(BaseModel):
|
||||
class ItemCreate(BaseModel):
|
||||
name: str
|
||||
quantity: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
|
||||
class ItemUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
@ -27,4 +28,5 @@ class ItemUpdate(BaseModel):
|
||||
is_complete: Optional[bool] = None
|
||||
price: Optional[Decimal] = None
|
||||
position: Optional[int] = None
|
||||
category_id: Optional[int] = None
|
||||
version: int
|
22
be/app/schemas/time_entry.py
Normal file
22
be/app/schemas/time_entry.py
Normal 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
8
be/app/schemas/token.py
Normal 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
|
@ -25,6 +25,10 @@ class UserUpdate(UserBase):
|
||||
is_superuser: Optional[bool] = None
|
||||
is_verified: Optional[bool] = None
|
||||
|
||||
class UserClaim(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserInDBBase(UserBase):
|
||||
id: int
|
||||
password_hash: str
|
||||
|
@ -24,4 +24,5 @@ httpx>=0.24.0 # For async HTTP testing
|
||||
aiosqlite>=0.19.0 # For async SQLite support in tests
|
||||
|
||||
# Scheduler
|
||||
APScheduler==3.10.4
|
||||
APScheduler==3.10.4
|
||||
redis>=5.0.0
|
47
fe/src/components/CategoryForm.vue
Normal file
47
fe/src/components/CategoryForm.vue
Normal 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>
|
128
fe/src/components/ChoreItem.vue
Normal file
128
fe/src/components/ChoreItem.vue
Normal 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>
|
@ -189,7 +189,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { useAuthStore } from '@/stores/auth';
|
||||
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';
|
||||
|
@ -20,7 +20,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
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 VModal from '@/components/valerie/VModal.vue';
|
||||
import VFormField from '@/components/valerie/VFormField.vue';
|
||||
|
@ -29,7 +29,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from 'vue';
|
||||
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 VModal from '@/components/valerie/VModal.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 VButton from '@/components/valerie/VButton.vue';
|
||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||||
import type { Group } from '@/types/group';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
|
@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="buttonClasses"
|
||||
:disabled="disabled"
|
||||
@click="handleClick"
|
||||
>
|
||||
<button :type="type" :class="buttonClasses" :disabled="disabled" @click="handleClick">
|
||||
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
|
||||
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
|
||||
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
|
||||
@ -15,10 +10,10 @@
|
||||
</template>
|
||||
|
||||
<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
|
||||
|
||||
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 ButtonType = 'button' | 'submit' | 'reset';
|
||||
|
||||
@ -35,7 +30,7 @@ export default defineComponent({
|
||||
variant: {
|
||||
type: String as PropType<ButtonVariant>,
|
||||
default: 'primary',
|
||||
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger'].includes(value),
|
||||
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger', 'success'].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<ButtonSize>,
|
||||
@ -162,6 +157,12 @@ export default defineComponent({
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745; // Example success color
|
||||
color: white;
|
||||
border-color: #28a745;
|
||||
}
|
||||
|
||||
// Sizes
|
||||
.btn-sm {
|
||||
padding: 0.25em 0.5em;
|
||||
@ -180,9 +181,18 @@ export default defineComponent({
|
||||
// Icon only
|
||||
.btn-icon-only {
|
||||
padding: 0.5em; // Adjust padding for icon-only buttons
|
||||
|
||||
// Ensure VIcon fills the space or adjust VIcon size if needed
|
||||
& .mr-1 { margin-right: 0 !important; } // Remove margin if accidentally applied
|
||||
& .ml-1 { margin-left: 0 !important; } // Remove margin if accidentally applied
|
||||
& .mr-1 {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
// Remove margin if accidentally applied
|
||||
& .ml-1 {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
// Remove margin if accidentally applied
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
@ -201,6 +211,7 @@ export default defineComponent({
|
||||
.mr-1 {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
|
||||
// Auth
|
||||
AUTH: {
|
||||
LOGIN: '/auth/jwt/login',
|
||||
GUEST: '/auth/guest',
|
||||
SIGNUP: '/auth/register',
|
||||
LOGOUT: '/auth/jwt/logout',
|
||||
REFRESH: '/auth/jwt/refresh',
|
||||
@ -21,11 +22,11 @@ export const API_ENDPOINTS = {
|
||||
USERS: {
|
||||
PROFILE: '/users/me',
|
||||
UPDATE_PROFILE: '/users/me',
|
||||
PASSWORD: '/api/v1/users/password',
|
||||
AVATAR: '/api/v1/users/avatar',
|
||||
SETTINGS: '/api/v1/users/settings',
|
||||
NOTIFICATIONS: '/api/v1/users/notifications',
|
||||
PREFERENCES: '/api/v1/users/preferences',
|
||||
PASSWORD: '/users/password',
|
||||
AVATAR: '/users/avatar',
|
||||
SETTINGS: '/users/settings',
|
||||
NOTIFICATIONS: '/users/notifications',
|
||||
PREFERENCES: '/users/preferences',
|
||||
},
|
||||
|
||||
// Lists
|
||||
@ -42,10 +43,16 @@ export const API_ENDPOINTS = {
|
||||
COMPLETE: (listId: string) => `/lists/${listId}/complete`,
|
||||
REOPEN: (listId: string) => `/lists/${listId}/reopen`,
|
||||
ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
|
||||
RESTORE: (listId: string) => `/lists/${listId}/restore`,
|
||||
UNARCHIVE: (listId: string) => `/lists/${listId}/unarchive`,
|
||||
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
|
||||
EXPORT: (listId: string) => `/lists/${listId}/export`,
|
||||
IMPORT: '/lists/import',
|
||||
ARCHIVED: '/lists/archived',
|
||||
},
|
||||
|
||||
CATEGORIES: {
|
||||
BASE: '/categories',
|
||||
BY_ID: (id: string) => `/categories/${id}`,
|
||||
},
|
||||
|
||||
// Groups
|
||||
@ -129,5 +136,7 @@ export const API_ENDPOINTS = {
|
||||
HISTORY: (id: number) => `/chores/${id}/history`,
|
||||
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
|
||||
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}`,
|
||||
},
|
||||
}
|
||||
|
@ -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),
|
||||
};
|
@ -89,6 +89,10 @@
|
||||
<span class="material-icons">task_alt</span>
|
||||
<span class="tab-text">Chores</span>
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
|
@ -15,7 +15,9 @@
|
||||
<form v-else @submit.prevent="onSubmitProfile">
|
||||
<!-- Profile Section -->
|
||||
<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;">
|
||||
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
|
||||
<VInput id="profileName" v-model="profile.name" required />
|
||||
@ -35,7 +37,9 @@
|
||||
<!-- Password Section -->
|
||||
<form @submit.prevent="onChangePassword">
|
||||
<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;">
|
||||
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
|
||||
<VInput type="password" id="currentPassword" v-model="password.current" required />
|
||||
@ -54,28 +58,33 @@
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<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">
|
||||
<VListItem class="preference-item">
|
||||
<div class="preference-label">
|
||||
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
|
||||
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
|
||||
</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 class="preference-item">
|
||||
<div class="preference-label">
|
||||
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
|
||||
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
|
||||
</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 class="preference-item">
|
||||
<div class="preference-label">
|
||||
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
|
||||
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
|
||||
</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>
|
||||
</VList>
|
||||
</VCard>
|
||||
@ -83,9 +92,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
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 VHeading from '@/components/valerie/VHeading.vue';
|
||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||||
@ -130,6 +140,8 @@ const preferences = ref<Preferences>({
|
||||
groupActivities: true,
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const fetchProfile = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
65
fe/src/pages/CategoriesPage.vue
Normal file
65
fe/src/pages/CategoriesPage.vue
Normal 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>
|
@ -4,23 +4,20 @@ import { useI18n } from 'vue-i18n'
|
||||
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
|
||||
import { choreService } from '../services/choreService'
|
||||
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 { 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 props = defineProps<{ groupId?: number | string }>();
|
||||
|
||||
// Types
|
||||
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;
|
||||
}
|
||||
// ChoreWithCompletion is now imported from ../types/chore
|
||||
|
||||
interface ChoreFormData {
|
||||
name: string;
|
||||
@ -30,6 +27,7 @@ interface ChoreFormData {
|
||||
next_due_date: string;
|
||||
type: 'personal' | 'group';
|
||||
group_id: number | undefined;
|
||||
parent_chore_id?: number | null;
|
||||
}
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
@ -60,11 +58,26 @@ const initialChoreFormState: ChoreFormData = {
|
||||
next_due_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
type: 'personal',
|
||||
group_id: undefined,
|
||||
parent_chore_id: null,
|
||||
}
|
||||
|
||||
const choreForm = ref({ ...initialChoreFormState })
|
||||
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 now = Date.now();
|
||||
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(() => {
|
||||
loadChores()
|
||||
loadChores().then(loadTimeEntries);
|
||||
loadGroups()
|
||||
})
|
||||
|
||||
@ -173,17 +194,50 @@ const filteredChores = computed(() => {
|
||||
return chores.value;
|
||||
});
|
||||
|
||||
const groupedChores = computed(() => {
|
||||
if (!filteredChores.value) return []
|
||||
|
||||
const choresByDate = filteredChores.value.reduce((acc, chore) => {
|
||||
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
|
||||
if (!acc[dueDate]) {
|
||||
acc[dueDate] = []
|
||||
const availableParentChores = computed(() => {
|
||||
return chores.value.filter(c => {
|
||||
// A chore cannot be its own parent
|
||||
if (isEditing.value && selectedChore.value && c.id === selectedChore.value.id) {
|
||||
return false;
|
||||
}
|
||||
acc[dueDate].push(chore)
|
||||
return acc
|
||||
}, {} as Record<string, ChoreWithCompletion[]>)
|
||||
// A chore that is already a subtask cannot be a parent
|
||||
if (c.parent_chore_id) {
|
||||
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)
|
||||
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
||||
@ -198,7 +252,7 @@ const groupedChores = computed(() => {
|
||||
...chore,
|
||||
subtext: getChoreSubtext(chore)
|
||||
}))
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@ -238,6 +292,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
|
||||
next_due_date: chore.next_due_date,
|
||||
type: chore.type,
|
||||
group_id: chore.group_id ?? undefined,
|
||||
parent_chore_id: chore.parent_chore_id,
|
||||
}
|
||||
showChoreModal.value = true
|
||||
}
|
||||
@ -412,10 +467,29 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
if (isEqual(dueDate, today)) return 'due-today';
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
|
||||
<button class="btn btn-primary" @click="openCreateChoreModal">
|
||||
@ -444,44 +518,11 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
|
||||
<div class="neo-item-list-container">
|
||||
<ul class="neo-item-list">
|
||||
<li v-for="chore in group.chores" :key="chore.id" 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="toggleCompletion(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>
|
||||
</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>
|
||||
<ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
|
||||
:time-entries="chore.current_assignment_id ? timeEntries[chore.current_assignment_id] || [] : []"
|
||||
:active-timer="activeTimer" @toggle-completion="toggleCompletion" @edit="openEditChoreModal"
|
||||
@delete="confirmDelete" @open-details="openChoreDetailModal" @open-history="openHistoryModal"
|
||||
@start-timer="startTimer" @stop-timer="stopTimer" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -523,7 +564,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
</div>
|
||||
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
||||
<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"
|
||||
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||
</div>
|
||||
@ -544,16 +585,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
</div>
|
||||
<div v-if="choreForm.type === 'group'" class="form-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">
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</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 class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
||||
t('choresPage.form.cancel', 'Cancel')
|
||||
}}</button>
|
||||
}}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
||||
t('choresPage.form.create', 'Create') }}</button>
|
||||
</div>
|
||||
@ -578,7 +629,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
||||
t('choresPage.deleteConfirm.delete', 'Delete')
|
||||
}}</button>
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -603,7 +654,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
<div class="detail-item">
|
||||
<span class="label">Created by:</span>
|
||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<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 class="assignment-main">
|
||||
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||
}}</span>
|
||||
}}</span>
|
||||
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
||||
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
||||
</span>
|
||||
@ -693,6 +744,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
|
738
fe/src/pages/ExpensePage.vue
Normal file
738
fe/src/pages/ExpensePage.vue
Normal 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">×</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>
|
@ -219,10 +219,10 @@
|
||||
<template #footer>
|
||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||
t('groupDetailPage.settleShareModal.cancelButton')
|
||||
}}</VButton>
|
||||
}}</VButton>
|
||||
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
|
||||
t('groupDetailPage.settleShareModal.confirmButton')
|
||||
}}</VButton>
|
||||
}}</VButton>
|
||||
</template>
|
||||
</VModal>
|
||||
|
||||
@ -242,7 +242,7 @@
|
||||
<div class="meta-item">
|
||||
<span class="label">Created by:</span>
|
||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||
}}</span>
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="label">Created:</span>
|
||||
@ -383,7 +383,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, reactive } from 'vue';
|
||||
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 ListsPage from './ListsPage.vue';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
@ -122,7 +122,7 @@
|
||||
import { ref, onMounted, nextTick, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 { onClickOutside } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
@ -48,112 +48,127 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<!-- End Integrated Header -->
|
||||
|
||||
<draggable v-model="list.items" item-key="id" handle=".drag-handle" @end="handleDragEnd" :disabled="!isOnline"
|
||||
class="neo-item-list">
|
||||
<template #item="{ element: item }">
|
||||
<li class="neo-list-item"
|
||||
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
|
||||
<div class="neo-item-content">
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle" v-if="isOnline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="5" r="1"></circle>
|
||||
<circle cx="9" cy="19" r="1"></circle>
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="15" cy="5" r="1"></circle>
|
||||
<circle cx="15" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Content when NOT editing -->
|
||||
<template v-if="!item.isEditing">
|
||||
<label class="neo-checkbox-label" @click.stop>
|
||||
<input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" />
|
||||
<div class="checkbox-content">
|
||||
<span class="checkbox-text-span"
|
||||
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
|
||||
<div v-if="item.is_complete" class="neo-price-input">
|
||||
<VInput type="number" :model-value="item.priceInput || ''"
|
||||
@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 v-for="group in groupedItems" :key="group.categoryName" class="category-group"
|
||||
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
|
||||
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
|
||||
<draggable v-model="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
|
||||
:disabled="!isOnline" class="neo-item-list">
|
||||
<template #item="{ element: item }">
|
||||
<li class="neo-list-item"
|
||||
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
|
||||
<div class="neo-item-content">
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle" v-if="isOnline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="12" r="1"></circle>
|
||||
<circle cx="9" cy="5" r="1"></circle>
|
||||
<circle cx="9" cy="19" r="1"></circle>
|
||||
<circle cx="15" cy="12" r="1"></circle>
|
||||
<circle cx="15" cy="5" r="1"></circle>
|
||||
<circle cx="15" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Content when NOT editing -->
|
||||
<template v-if="!item.isEditing">
|
||||
<label class="neo-checkbox-label" @click.stop>
|
||||
<input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" />
|
||||
<div class="checkbox-content">
|
||||
<span class="checkbox-text-span"
|
||||
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
|
||||
<div v-if="item.is_complete" class="neo-price-input">
|
||||
<VInput type="number" :model-value="item.priceInput || ''"
|
||||
@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>
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
<!-- Content WHEN editing -->
|
||||
<template v-else>
|
||||
<div class="inline-edit-form flex-grow flex items-center gap-2">
|
||||
<VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event"
|
||||
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
|
||||
@keydown.esc.prevent="cancelItemEdit(item)" />
|
||||
<VInput type="number" :model-value="item.editQuantity || ''"
|
||||
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
|
||||
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
|
||||
</div>
|
||||
<div class="neo-item-actions">
|
||||
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)"
|
||||
: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"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)"
|
||||
:aria-label="$t('listDetailPage.buttons.cancel')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="neo-icon-button neo-delete-button" @click.stop="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>
|
||||
</template>
|
||||
<!-- Content WHEN editing -->
|
||||
<template v-else>
|
||||
<div class="inline-edit-form flex-grow flex items-center gap-2">
|
||||
<VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event"
|
||||
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
|
||||
@keydown.esc.prevent="cancelItemEdit(item)" />
|
||||
<VInput type="number" :model-value="item.editQuantity || ''"
|
||||
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
|
||||
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
|
||||
<VSelect :model-value="item.editCategoryId" @update:modelValue="item.editCategoryId = $event"
|
||||
:options="categoryOptions" placeholder="Category" class="w-40" size="sm" />
|
||||
</div>
|
||||
<div class="neo-item-actions">
|
||||
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)"
|
||||
: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"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)"
|
||||
:aria-label="$t('listDetailPage.buttons.cancel')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="neo-icon-button neo-delete-button" @click.stop="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>
|
||||
</div>
|
||||
|
||||
<!-- New Add Item LI, integrated into the list -->
|
||||
<li class="neo-list-item new-item-input-container">
|
||||
@ -163,6 +178,8 @@
|
||||
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
|
||||
:data-list-id="list?.id" @keyup.enter="onAddItem" @blur="handleNewItemBlur" v-model="newItem.name"
|
||||
@click.stop />
|
||||
<VSelect v-model="newItem.category_id" :options="categoryOptions" placeholder="Category" class="w-40"
|
||||
size="sm" />
|
||||
</label>
|
||||
</li>
|
||||
|
||||
@ -379,10 +396,10 @@
|
||||
<template #footer>
|
||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||
$t('listDetailPage.modals.settleShare.cancelButton')
|
||||
}}</VButton>
|
||||
}}</VButton>
|
||||
<VButton variant="primary" @click="handleConfirmSettle">{{
|
||||
$t('listDetailPage.modals.settleShare.confirmButton')
|
||||
}}</VButton>
|
||||
}}</VButton>
|
||||
</template>
|
||||
</VModal>
|
||||
|
||||
@ -393,9 +410,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
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 VListItem from '@/components/valerie/VListItem.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 { useCategoryStore } from '@/stores/categoryStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import ExpenseCard from '@/components/ExpenseCard.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -469,6 +491,7 @@ interface ItemWithUI extends Item {
|
||||
isEditing?: boolean; // For inline editing state
|
||||
editName?: string; // Temporary name 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
|
||||
}
|
||||
|
||||
@ -523,9 +546,21 @@ const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastListUpdate = ref<string | 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 categoryOptions = computed(() => {
|
||||
return [
|
||||
{ label: 'No Category', value: null },
|
||||
...categories.value.map(c => ({ label: c.name, value: c.id })),
|
||||
];
|
||||
});
|
||||
|
||||
// OCR
|
||||
const showOcrDialogState = ref(false);
|
||||
// const ocrModalRef = ref<HTMLElement | null>(null); // Removed
|
||||
@ -547,6 +582,12 @@ const listCostSummary = ref<ListCostSummaryData | null>(null);
|
||||
const costSummaryLoading = ref(false);
|
||||
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
|
||||
const authStore = useAuthStore();
|
||||
const showSettleModal = ref(false);
|
||||
@ -703,6 +744,7 @@ const onAddItem = async () => {
|
||||
is_complete: false,
|
||||
price: null,
|
||||
version: 1,
|
||||
category_id: newItem.value.category_id,
|
||||
updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
list_id: list.value.id,
|
||||
@ -715,6 +757,7 @@ const onAddItem = async () => {
|
||||
list.value.items.push(optimisticItem);
|
||||
|
||||
newItem.value.name = '';
|
||||
newItem.value.category_id = null;
|
||||
if (itemNameInputRef.value?.$el) {
|
||||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||||
}
|
||||
@ -733,6 +776,9 @@ const onAddItem = async () => {
|
||||
offlinePayload.quantity = String(rawQuantity);
|
||||
}
|
||||
}
|
||||
if (newItem.value.category_id) {
|
||||
offlinePayload.category_id = newItem.value.category_id;
|
||||
}
|
||||
|
||||
offlineStore.addAction({
|
||||
type: 'create_list_item',
|
||||
@ -752,7 +798,8 @@ const onAddItem = async () => {
|
||||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||||
{
|
||||
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(() => {
|
||||
startPolling();
|
||||
});
|
||||
@ -1121,6 +1171,7 @@ const startItemEdit = (item: ItemWithUI) => {
|
||||
item.isEditing = true;
|
||||
item.editName = item.name;
|
||||
item.editQuantity = item.quantity ?? '';
|
||||
item.editCategoryId = item.category_id;
|
||||
};
|
||||
|
||||
const cancelItemEdit = (item: ItemWithUI) => {
|
||||
@ -1140,6 +1191,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
|
||||
name: String(item.editName).trim(),
|
||||
quantity: item.editQuantity ? String(item.editQuantity) : null,
|
||||
version: item.version,
|
||||
category_id: item.editCategoryId,
|
||||
};
|
||||
|
||||
item.updating = true;
|
||||
@ -1157,6 +1209,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
|
||||
item.is_complete = updatedItemFromApi.is_complete;
|
||||
item.price = updatedItemFromApi.price;
|
||||
item.updated_at = updatedItemFromApi.updated_at;
|
||||
item.category_id = updatedItemFromApi.category_id;
|
||||
|
||||
item.isEditing = false;
|
||||
notificationStore.addNotification({
|
||||
@ -1310,6 +1363,24 @@ const isExpenseExpanded = (expenseId: number) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@ -1999,4 +2070,18 @@ const isExpenseExpanded = (expenseId: number) => {
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.category-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category-group.highlight .neo-list-item:not(.is-complete) {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,12 +1,17 @@
|
||||
<template>
|
||||
<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">
|
||||
<template #actions>
|
||||
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
|
||||
</template>
|
||||
</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)">
|
||||
<template #default>
|
||||
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
|
||||
@ -19,17 +24,24 @@
|
||||
</template>
|
||||
</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') }}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="neo-lists-grid">
|
||||
<div v-for="list in lists" :key="list.id" class="neo-list-card"
|
||||
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
|
||||
@touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd"
|
||||
@touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
|
||||
<div class="neo-list-header">{{ list.name }}</div>
|
||||
<div v-for="list in filteredLists" :key="list.id" class="neo-list-card"
|
||||
:class="{ 'touch-active': touchActiveListId === list.id, 'archived': list.archived_at }"
|
||||
@click="navigateToList(list.id)" @touchstart.passive="handleTouchStart(list.id)"
|
||||
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
|
||||
<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>
|
||||
<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"
|
||||
@ -44,7 +56,7 @@
|
||||
</div>
|
||||
</label>
|
||||
</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">
|
||||
<input type="checkbox" disabled />
|
||||
<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 { useI18n } from 'vue-i18n';
|
||||
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 { useStorage } from '@vueuse/core';
|
||||
import VAlert from '@/components/valerie/VAlert.vue';
|
||||
import VCard from '@/components/valerie/VCard.vue';
|
||||
import VButton from '@/components/valerie/VButton.vue';
|
||||
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -95,6 +108,7 @@ interface List {
|
||||
created_at: string;
|
||||
version: number;
|
||||
items: Item[];
|
||||
archived_at?: string | null;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
@ -125,6 +139,8 @@ const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const lists = ref<(List & { items: Item[] })[]>([]);
|
||||
const archivedLists = ref<List[]>([]);
|
||||
const haveFetchedArchived = ref(false);
|
||||
const allFetchedGroups = ref<Group[]>([]);
|
||||
const currentViewedGroup = ref<Group | null>(null);
|
||||
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 () => {
|
||||
loading.value = true;
|
||||
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(() => {
|
||||
loadCachedData();
|
||||
fetchListsAndGroups().then(() => {
|
||||
@ -506,6 +582,8 @@ onMounted(() => {
|
||||
|
||||
watch(currentGroupId, () => {
|
||||
loadCachedData();
|
||||
haveFetchedArchived.value = false;
|
||||
archivedLists.value = [];
|
||||
fetchListsAndGroups().then(() => {
|
||||
if (lists.value.length > 0) {
|
||||
setupIntersectionObserver();
|
||||
@ -918,4 +996,14 @@ onUnmounted(() => {
|
||||
.item-appear {
|
||||
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>
|
||||
|
@ -34,6 +34,12 @@
|
||||
{{ t('loginPage.loginButton') }}
|
||||
</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">
|
||||
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
|
||||
</div>
|
||||
@ -103,6 +109,24 @@ const onSubmit = async () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@ -117,6 +141,30 @@ const onSubmit = async () => {
|
||||
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 {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
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 { vi } from 'vitest';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
// --- Mocks ---
|
||||
vi.mock('@/config/api', () => ({
|
||||
vi.mock('@/services/api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
@ -69,7 +71,7 @@ describe('AccountPage.vue', () => {
|
||||
|
||||
describe('Rendering and Initial Data Fetching', () => {
|
||||
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();
|
||||
expect(wrapper.text()).toContain('Loading profile...');
|
||||
expect(wrapper.find('.spinner-dots').exists()).toBe(true);
|
||||
@ -128,23 +130,23 @@ describe('AccountPage.vue', () => {
|
||||
});
|
||||
|
||||
it('handles profile update failure', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
|
||||
mockApiClient.put.mockRejectedValueOnce(new Error('Update failed'));
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' });
|
||||
});
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
|
||||
mockApiClient.put.mockRejectedValueOnce(new Error('Update failed'));
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change Password Form', () => {
|
||||
let wrapper: ReturnType<typeof createWrapper>;
|
||||
beforeEach(async () => {
|
||||
wrapper = createWrapper();
|
||||
await flushPromises(); // Initial load
|
||||
});
|
||||
wrapper = createWrapper();
|
||||
await flushPromises(); // Initial load
|
||||
});
|
||||
|
||||
it('changes password successfully', async () => {
|
||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||
@ -164,81 +166,81 @@ describe('AccountPage.vue', () => {
|
||||
expect(wrapper.find<HTMLInputElement>('#currentPassword').element.value).toBe('');
|
||||
expect(wrapper.find<HTMLInputElement>('#newPassword').element.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows validation error if new password is too short', async () => {
|
||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||
await wrapper.find('#newPassword').setValue('short');
|
||||
|
||||
const forms = wrapper.findAll('form');
|
||||
await forms[1].trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||
message: 'New password must be at least 8 characters long.', type: 'warning'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error if fields are empty', async () => {
|
||||
const forms = wrapper.findAll('form');
|
||||
await forms[1].trigger('submit.prevent'); // Submit with empty fields
|
||||
await flushPromises();
|
||||
|
||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||
message: 'Please fill in both current and new password fields.', type: 'warning'
|
||||
});
|
||||
it('shows validation error if new password is too short', async () => {
|
||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||
await wrapper.find('#newPassword').setValue('short');
|
||||
|
||||
const forms = wrapper.findAll('form');
|
||||
await forms[1].trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||
message: 'New password must be at least 8 characters long.', type: 'warning'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error if fields are empty', async () => {
|
||||
const forms = wrapper.findAll('form');
|
||||
await forms[1].trigger('submit.prevent'); // Submit with empty fields
|
||||
await flushPromises();
|
||||
|
||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||
message: 'Please fill in both current and new password fields.', type: 'warning'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles password change failure', async () => {
|
||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||
await wrapper.find('#newPassword').setValue('newPasswordSecure');
|
||||
mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed'));
|
||||
|
||||
const forms = wrapper.findAll('form');
|
||||
await forms[1].trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' });
|
||||
});
|
||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||
await wrapper.find('#newPassword').setValue('newPasswordSecure');
|
||||
mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed'));
|
||||
|
||||
const forms = wrapper.findAll('form');
|
||||
await forms[1].trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification Preferences', () => {
|
||||
it('updates preferences successfully when a toggle is changed', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises(); // Initial load
|
||||
|
||||
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
|
||||
const initialEmailPref = mockPreferencesData.emailNotifications;
|
||||
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
|
||||
|
||||
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
|
||||
|
||||
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
|
||||
await flushPromises();
|
||||
|
||||
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
|
||||
expect(mockApiClient.put).toHaveBeenCalledWith(
|
||||
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
|
||||
expectedPreferences
|
||||
);
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
|
||||
});
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises(); // Initial load
|
||||
|
||||
it('handles preference update failure', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
|
||||
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
|
||||
const initialListPref = mockPreferencesData.listUpdates;
|
||||
|
||||
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
|
||||
|
||||
await listUpdatesToggle.setValue(!initialListPref);
|
||||
await flushPromises();
|
||||
|
||||
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.
|
||||
});
|
||||
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
|
||||
const initialEmailPref = mockPreferencesData.emailNotifications;
|
||||
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
|
||||
|
||||
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
|
||||
|
||||
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
|
||||
await flushPromises();
|
||||
|
||||
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
|
||||
expect(mockApiClient.put).toHaveBeenCalledWith(
|
||||
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
|
||||
expectedPreferences
|
||||
);
|
||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
|
||||
});
|
||||
|
||||
it('handles preference update failure', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
|
||||
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
|
||||
const initialListPref = mockPreferencesData.listUpdates;
|
||||
|
||||
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
|
||||
|
||||
await listUpdatesToggle.setValue(!initialListPref);
|
||||
await flushPromises();
|
||||
|
||||
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.
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -58,6 +58,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/ChoresPage.vue'),
|
||||
meta: { requiresAuth: true, keepAlive: false },
|
||||
},
|
||||
{
|
||||
path: '/expenses',
|
||||
name: 'Expenses',
|
||||
component: () => import('@/pages/ExpensePage.vue'),
|
||||
meta: { requiresAuth: true, keepAlive: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 { useAuthStore } from '@/stores/auth' // Import the auth store
|
||||
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
|
||||
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: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { api } from './api'
|
||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
|
||||
import { groupService } from './groupService'
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
import type { Group } from '@/types/group'
|
||||
|
||||
export const choreService = {
|
||||
async getAllChores(): Promise<Chore[]> {
|
||||
try {
|
||||
const response = await api.get('/api/v1/chores/all')
|
||||
const response = await api.get('/chores/all')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import type { Group } from '@/types/group';
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||
import type { Group, GroupCreate, GroupUpdate } from '@/types/group';
|
||||
import type { ChoreHistory } from '@/types/chore';
|
||||
|
||||
export const groupService = {
|
||||
|
@ -11,6 +11,7 @@ export interface AuthState {
|
||||
email: string
|
||||
name: string
|
||||
id?: string | number
|
||||
is_guest?: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
@ -23,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const getUser = computed(() => user.value)
|
||||
const isGuest = computed(() => user.value?.is_guest ?? false)
|
||||
|
||||
// Actions
|
||||
const setTokens = (tokens: { access_token: string; refresh_token?: string }) => {
|
||||
@ -109,6 +111,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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 response = await api.post(API_ENDPOINTS.AUTH.SIGNUP, userData)
|
||||
return response.data
|
||||
@ -125,11 +135,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
refreshToken,
|
||||
isAuthenticated,
|
||||
getUser,
|
||||
isGuest,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
setUser,
|
||||
fetchCurrentUser,
|
||||
login,
|
||||
loginAsGuest,
|
||||
signup,
|
||||
logout,
|
||||
}
|
||||
|
86
fe/src/stores/categoryStore.ts
Normal file
86
fe/src/stores/categoryStore.ts
Normal 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,
|
||||
};
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
import type {
|
||||
Expense,
|
||||
ExpenseSplit,
|
||||
|
@ -20,7 +20,8 @@ export type CreateListItemPayload = {
|
||||
name: string
|
||||
quantity?: number | string
|
||||
completed?: boolean
|
||||
price?: number | null /* other item properties */
|
||||
price?: number | null
|
||||
category_id?: number | null
|
||||
}
|
||||
}
|
||||
export type UpdateListItemPayload = {
|
||||
|
79
fe/src/stores/timeEntryStore.ts
Normal file
79
fe/src/stores/timeEntryStore.ts
Normal 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,
|
||||
};
|
||||
});
|
@ -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 ChoreType = 'personal' | 'group'
|
||||
@ -69,3 +69,15 @@ export interface ChoreAssignmentHistory {
|
||||
changed_by_user?: User
|
||||
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;
|
||||
}
|
||||
|
@ -50,16 +50,15 @@ export interface ExpenseSplit {
|
||||
id: number
|
||||
expense_id: number
|
||||
user_id: number
|
||||
user?: UserPublic | null
|
||||
owed_amount: string // String representation of Decimal
|
||||
share_percentage?: string | null
|
||||
share_units?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
status: ExpenseSplitStatusEnum
|
||||
paid_at?: string | null
|
||||
settlement_activities: SettlementActivity[]
|
||||
user?: UserPublic | null
|
||||
}
|
||||
|
||||
export interface RecurrencePatternCreate {
|
||||
@ -124,3 +123,32 @@ export interface Expense {
|
||||
parentExpenseId?: number
|
||||
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[];
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ export interface Item {
|
||||
is_complete: boolean
|
||||
price?: string | null // String representation of Decimal
|
||||
list_id: number
|
||||
category_id?: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
version: number
|
||||
|
22
fe/src/utils/formatters.ts
Normal file
22
fe/src/utils/formatters.ts
Normal 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(' ');
|
||||
};
|
Loading…
Reference in New Issue
Block a user