feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
This commit is contained in:
parent
bbe3b3a493
commit
f49e15c05c
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