feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support #66
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 costs
|
||||||
from app.api.v1.endpoints import financials
|
from app.api.v1.endpoints import financials
|
||||||
from app.api.v1.endpoints import chores
|
from app.api.v1.endpoints import chores
|
||||||
from app.api.auth import oauth
|
from app.api.v1.endpoints import history
|
||||||
|
from app.api.v1.endpoints import categories
|
||||||
|
from app.api.v1.endpoints import users
|
||||||
|
from app.api.auth import oauth, guest, jwt
|
||||||
|
|
||||||
api_router_v1 = APIRouter()
|
api_router_v1 = APIRouter()
|
||||||
|
|
||||||
@ -22,4 +25,9 @@ api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
|
|||||||
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
|
api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
|
||||||
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
|
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
|
||||||
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
|
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
|
||||||
|
api_router_v1.include_router(history.router, prefix="/history", tags=["History"])
|
||||||
|
api_router_v1.include_router(categories.router, prefix="/categories", tags=["Categories"])
|
||||||
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
|
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
|
||||||
|
api_router_v1.include_router(guest.router, prefix="/auth", tags=["Auth"])
|
||||||
|
api_router_v1.include_router(jwt.router, prefix="/auth", tags=["Auth"])
|
||||||
|
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
||||||
|
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
|
# app/api/v1/endpoints/chores.py
|
||||||
import logging
|
import logging
|
||||||
from typing import List as PyList, Optional
|
from typing import List as PyList, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.database import get_transactional_session, get_session
|
from app.database import get_transactional_session, get_session
|
||||||
from app.auth import current_active_user
|
from app.auth import current_active_user
|
||||||
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
|
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum, TimeEntry
|
||||||
from app.schemas.chore import (
|
from app.schemas.chore import (
|
||||||
ChoreCreate, ChoreUpdate, ChorePublic,
|
ChoreCreate, ChoreUpdate, ChorePublic,
|
||||||
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
|
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
|
||||||
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
||||||
)
|
)
|
||||||
|
from app.schemas.time_entry import TimeEntryPublic
|
||||||
from app.crud import chore as crud_chore
|
from app.crud import chore as crud_chore
|
||||||
from app.crud import history as crud_history
|
from app.crud import history as crud_history
|
||||||
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
||||||
@ -507,3 +510,122 @@ async def get_chore_assignment_history(
|
|||||||
|
|
||||||
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
|
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
|
||||||
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
|
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
|
||||||
|
|
||||||
|
# === TIME ENTRY ENDPOINTS ===
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assignments/{assignment_id}/time-entries",
|
||||||
|
response_model=PyList[TimeEntryPublic],
|
||||||
|
summary="Get Time Entries",
|
||||||
|
tags=["Time Tracking"]
|
||||||
|
)
|
||||||
|
async def get_time_entries_for_assignment(
|
||||||
|
assignment_id: int,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Retrieves all time entries for a specific chore assignment."""
|
||||||
|
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
|
||||||
|
|
||||||
|
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
|
||||||
|
if not chore:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
|
||||||
|
|
||||||
|
# Permission check
|
||||||
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
||||||
|
|
||||||
|
if chore.type == ChoreTypeEnum.group:
|
||||||
|
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
|
||||||
|
if not is_member:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
||||||
|
|
||||||
|
# For now, return time entries for the current user only
|
||||||
|
time_entries = await db.execute(
|
||||||
|
select(TimeEntry)
|
||||||
|
.where(TimeEntry.chore_assignment_id == assignment_id)
|
||||||
|
.where(TimeEntry.user_id == current_user.id)
|
||||||
|
.order_by(TimeEntry.start_time.desc())
|
||||||
|
)
|
||||||
|
return time_entries.scalars().all()
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/assignments/{assignment_id}/time-entries",
|
||||||
|
response_model=TimeEntryPublic,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Start Time Entry",
|
||||||
|
tags=["Time Tracking"]
|
||||||
|
)
|
||||||
|
async def start_time_entry(
|
||||||
|
assignment_id: int,
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Starts a new time entry for a chore assignment."""
|
||||||
|
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
|
||||||
|
|
||||||
|
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
|
||||||
|
if not chore:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
|
||||||
|
|
||||||
|
# Permission check - only assigned user can track time
|
||||||
|
if assignment.assigned_to_user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only assigned user can track time")
|
||||||
|
|
||||||
|
# Check if there's already an active time entry
|
||||||
|
existing_active = await db.execute(
|
||||||
|
select(TimeEntry)
|
||||||
|
.where(TimeEntry.chore_assignment_id == assignment_id)
|
||||||
|
.where(TimeEntry.user_id == current_user.id)
|
||||||
|
.where(TimeEntry.end_time.is_(None))
|
||||||
|
)
|
||||||
|
if existing_active.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already active")
|
||||||
|
|
||||||
|
# Create new time entry
|
||||||
|
time_entry = TimeEntry(
|
||||||
|
chore_assignment_id=assignment_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
start_time=datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
db.add(time_entry)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(time_entry)
|
||||||
|
|
||||||
|
return time_entry
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/time-entries/{time_entry_id}",
|
||||||
|
response_model=TimeEntryPublic,
|
||||||
|
summary="Stop Time Entry",
|
||||||
|
tags=["Time Tracking"]
|
||||||
|
)
|
||||||
|
async def stop_time_entry(
|
||||||
|
time_entry_id: int,
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Stops an active time entry."""
|
||||||
|
time_entry = await db.get(TimeEntry, time_entry_id)
|
||||||
|
if not time_entry:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Time entry not found")
|
||||||
|
|
||||||
|
if time_entry.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
||||||
|
|
||||||
|
if time_entry.end_time:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already stopped")
|
||||||
|
|
||||||
|
# Stop the time entry
|
||||||
|
end_time = datetime.now(timezone.utc)
|
||||||
|
time_entry.end_time = end_time
|
||||||
|
time_entry.duration_seconds = int((end_time - time_entry.start_time).total_seconds())
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(time_entry)
|
||||||
|
|
||||||
|
return time_entry
|
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
|
return lists
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/archived",
|
||||||
|
response_model=PyList[ListDetail],
|
||||||
|
summary="List Archived Lists",
|
||||||
|
tags=["Lists"]
|
||||||
|
)
|
||||||
|
async def read_archived_lists(
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieves archived lists for the current user.
|
||||||
|
"""
|
||||||
|
logger.info(f"Fetching archived lists for user: {current_user.email}")
|
||||||
|
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id, include_archived=True)
|
||||||
|
return [l for l in lists if l.archived_at]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/statuses",
|
"/statuses",
|
||||||
response_model=PyList[ListStatusWithId],
|
response_model=PyList[ListStatusWithId],
|
||||||
@ -185,29 +203,29 @@ async def update_list(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{list_id}",
|
"/{list_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Delete List",
|
summary="Archive List",
|
||||||
tags=["Lists"],
|
tags=["Lists"],
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"}
|
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot archive specified version"}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def delete_list(
|
async def archive_list_endpoint(
|
||||||
list_id: int,
|
list_id: int,
|
||||||
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
|
expected_version: Optional[int] = Query(None, description="The expected version of the list to archive for optimistic locking."),
|
||||||
db: AsyncSession = Depends(get_transactional_session),
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
current_user: UserModel = Depends(current_active_user),
|
current_user: UserModel = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Deletes a list. Requires user to be the creator of the list.
|
Archives a list. Requires user to be the creator of the list.
|
||||||
If `expected_version` is provided and does not match the list's current version,
|
If `expected_version` is provided and does not match the list's current version,
|
||||||
a 409 Conflict is returned.
|
a 409 Conflict is returned.
|
||||||
"""
|
"""
|
||||||
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}")
|
logger.info(f"User {current_user.email} attempting to archive list ID: {list_id}, expected version: {expected_version}")
|
||||||
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
|
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
|
||||||
|
|
||||||
if expected_version is not None and list_db.version != expected_version:
|
if expected_version is not None and list_db.version != expected_version:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Conflict deleting list {list_id} for user {current_user.email}. "
|
f"Conflict archiving list {list_id} for user {current_user.email}. "
|
||||||
f"Expected version {expected_version}, actual version {list_db.version}."
|
f"Expected version {expected_version}, actual version {list_db.version}."
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -215,11 +233,37 @@ async def delete_list(
|
|||||||
detail=f"List has been modified. Expected version {expected_version}, but current version is {list_db.version}. Please refresh."
|
detail=f"List has been modified. Expected version {expected_version}, but current version is {list_db.version}. Please refresh."
|
||||||
)
|
)
|
||||||
|
|
||||||
await crud_list.delete_list(db=db, list_db=list_db)
|
await crud_list.archive_list(db=db, list_db=list_db)
|
||||||
logger.info(f"List {list_id} (version: {list_db.version}) deleted successfully by user {current_user.email}.")
|
logger.info(f"List {list_id} (version: {list_db.version}) archived successfully by user {current_user.email}.")
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{list_id}/unarchive",
|
||||||
|
response_model=ListPublic,
|
||||||
|
summary="Unarchive List",
|
||||||
|
tags=["Lists"]
|
||||||
|
)
|
||||||
|
async def unarchive_list_endpoint(
|
||||||
|
list_id: int,
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Restores an archived list.
|
||||||
|
"""
|
||||||
|
logger.info(f"User {current_user.email} attempting to unarchive list ID: {list_id}")
|
||||||
|
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
|
||||||
|
|
||||||
|
if not list_db.archived_at:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="List is not archived.")
|
||||||
|
|
||||||
|
updated_list = await crud_list.unarchive_list(db=db, list_db=list_db)
|
||||||
|
|
||||||
|
logger.info(f"List {list_id} unarchived successfully by user {current_user.email}.")
|
||||||
|
return updated_list
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{list_id}/status",
|
"/{list_id}/status",
|
||||||
response_model=ListStatus,
|
response_model=ListStatus,
|
||||||
|
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)):
|
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
|
||||||
yield UserManager(user_db)
|
yield UserManager(user_db)
|
||||||
|
|
||||||
bearer_transport = BearerTransportWithRefresh(tokenUrl="auth/jwt/login")
|
bearer_transport = BearerTransportWithRefresh(tokenUrl="/api/v1/auth/jwt/login")
|
||||||
|
|
||||||
def get_jwt_strategy() -> JWTStrategy:
|
def get_jwt_strategy() -> JWTStrategy:
|
||||||
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
|
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
|
||||||
|
78
be/app/core/cache.py
Normal file
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 passlib.context import CryptContext
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from jose import jwt
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
@ -33,3 +36,39 @@ def hash_password(password: str) -> str:
|
|||||||
The resulting hash string.
|
The resulting hash string.
|
||||||
"""
|
"""
|
||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
# Alias for compatibility with guest.py
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""
|
||||||
|
Alias for hash_password function for backward compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: The plain text password to hash.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resulting hash string.
|
||||||
|
"""
|
||||||
|
return hash_password(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""
|
||||||
|
Create a JWT access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: The data to encode in the token (typically {"sub": email}).
|
||||||
|
expires_delta: Optional custom expiration time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded JWT token.
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
return encoded_jwt
|
77
be/app/crud/audit.py
Normal file
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.creator),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
selectinload(Chore.history)
|
selectinload(Chore.history),
|
||||||
|
selectinload(Chore.child_chores)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -57,7 +58,8 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
|||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
selectinload(Chore.history)
|
selectinload(Chore.history),
|
||||||
|
selectinload(Chore.child_chores)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -85,8 +87,14 @@ async def create_chore(
|
|||||||
if group_id:
|
if group_id:
|
||||||
raise ValueError("group_id must be None for personal chores")
|
raise ValueError("group_id must be None for personal chores")
|
||||||
|
|
||||||
|
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'})
|
||||||
|
if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']:
|
||||||
|
parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
|
||||||
|
if not parent_chore:
|
||||||
|
raise ChoreNotFoundError(chore_data['parent_chore_id'])
|
||||||
|
|
||||||
db_chore = Chore(
|
db_chore = Chore(
|
||||||
**chore_in.model_dump(exclude_unset=True, exclude={'group_id'}),
|
**chore_data,
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
created_by_id=user_id,
|
created_by_id=user_id,
|
||||||
)
|
)
|
||||||
@ -115,7 +123,8 @@ async def create_chore(
|
|||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
selectinload(Chore.history)
|
selectinload(Chore.history),
|
||||||
|
selectinload(Chore.child_chores)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -133,7 +142,8 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
|
|||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
selectinload(Chore.history)
|
selectinload(Chore.history),
|
||||||
|
selectinload(Chore.child_chores)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@ -168,7 +178,8 @@ async def get_personal_chores(
|
|||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
selectinload(Chore.history)
|
selectinload(Chore.history),
|
||||||
|
selectinload(Chore.child_chores)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -193,7 +204,8 @@ async def get_chores_by_group_id(
|
|||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
selectinload(Chore.history)
|
selectinload(Chore.history),
|
||||||
|
selectinload(Chore.child_chores)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -229,6 +241,14 @@ async def update_chore(
|
|||||||
|
|
||||||
update_data = chore_in.model_dump(exclude_unset=True)
|
update_data = chore_in.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if 'parent_chore_id' in update_data:
|
||||||
|
if update_data['parent_chore_id']:
|
||||||
|
parent_chore = await get_chore_by_id(db, update_data['parent_chore_id'])
|
||||||
|
if not parent_chore:
|
||||||
|
raise ChoreNotFoundError(update_data['parent_chore_id'])
|
||||||
|
# Setting parent_chore_id to None is allowed
|
||||||
|
setattr(db_chore, 'parent_chore_id', update_data['parent_chore_id'])
|
||||||
|
|
||||||
if 'type' in update_data:
|
if 'type' in update_data:
|
||||||
new_type = update_data['type']
|
new_type = update_data['type']
|
||||||
if new_type == ChoreTypeEnum.group and not group_id:
|
if new_type == ChoreTypeEnum.group and not group_id:
|
||||||
@ -289,7 +309,8 @@ async def update_chore(
|
|||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
selectinload(Chore.history)
|
selectinload(Chore.history),
|
||||||
|
selectinload(Chore.child_chores)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -379,6 +400,7 @@ async def create_chore_assignment(
|
|||||||
.where(ChoreAssignment.id == db_assignment.id)
|
.where(ChoreAssignment.id == db_assignment.id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
|
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||||
selectinload(ChoreAssignment.assigned_user),
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(ChoreAssignment.history)
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
@ -395,6 +417,7 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
|
|||||||
.where(ChoreAssignment.id == assignment_id)
|
.where(ChoreAssignment.id == assignment_id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
|
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||||
selectinload(ChoreAssignment.assigned_user),
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(ChoreAssignment.history)
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
@ -414,6 +437,7 @@ async def get_user_assignments(
|
|||||||
|
|
||||||
query = query.options(
|
query = query.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
|
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||||
selectinload(ChoreAssignment.assigned_user),
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(ChoreAssignment.history)
|
selectinload(ChoreAssignment.history)
|
||||||
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||||
@ -443,6 +467,7 @@ async def get_chore_assignments(
|
|||||||
.where(ChoreAssignment.chore_id == chore_id)
|
.where(ChoreAssignment.chore_id == chore_id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||||
|
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||||
selectinload(ChoreAssignment.assigned_user),
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
selectinload(ChoreAssignment.history)
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
@ -456,75 +481,72 @@ async def update_chore_assignment(
|
|||||||
assignment_in: ChoreAssignmentUpdate,
|
assignment_in: ChoreAssignmentUpdate,
|
||||||
user_id: int
|
user_id: int
|
||||||
) -> Optional[ChoreAssignment]:
|
) -> Optional[ChoreAssignment]:
|
||||||
"""Updates a chore assignment. Only the assignee can mark it complete."""
|
"""Updates a chore assignment, e.g., to mark it as complete."""
|
||||||
async with db.begin_nested() if db.in_transaction() else db.begin():
|
async with db.begin_nested() if db.in_transaction() else db.begin():
|
||||||
db_assignment = await get_chore_assignment_by_id(db, assignment_id)
|
db_assignment = await get_chore_assignment_by_id(db, assignment_id)
|
||||||
if not db_assignment:
|
if not db_assignment:
|
||||||
raise ChoreNotFoundError(assignment_id=assignment_id)
|
return None
|
||||||
|
|
||||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
# Permission Check: only assigned user or group owner can update
|
||||||
if not chore:
|
is_allowed = db_assignment.assigned_to_user_id == user_id
|
||||||
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
if not is_allowed and db_assignment.chore.group_id:
|
||||||
|
user_role = await get_user_role_in_group(db, db_assignment.chore.group_id, user_id)
|
||||||
|
is_allowed = user_role == UserRoleEnum.owner
|
||||||
|
|
||||||
can_manage = False
|
if not is_allowed:
|
||||||
if chore.type == ChoreTypeEnum.personal:
|
raise PermissionDeniedError("You cannot update this chore assignment.")
|
||||||
can_manage = chore.created_by_id == user_id
|
|
||||||
else:
|
|
||||||
can_manage = await is_user_member(db, chore.group_id, user_id)
|
|
||||||
|
|
||||||
can_complete = db_assignment.assigned_to_user_id == user_id
|
|
||||||
|
|
||||||
|
original_status = db_assignment.is_complete
|
||||||
update_data = assignment_in.model_dump(exclude_unset=True)
|
update_data = assignment_in.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
original_assignee = db_assignment.assigned_to_user_id
|
|
||||||
original_due_date = db_assignment.due_date
|
|
||||||
|
|
||||||
if 'is_complete' in update_data and not can_complete:
|
|
||||||
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
|
|
||||||
|
|
||||||
if 'due_date' in update_data and update_data['due_date'] != original_due_date:
|
|
||||||
if not can_manage:
|
|
||||||
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
|
|
||||||
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.DUE_DATE_CHANGED, event_data={"old": original_due_date.isoformat(), "new": update_data['due_date'].isoformat()})
|
|
||||||
|
|
||||||
if 'assigned_to_user_id' in update_data and update_data['assigned_to_user_id'] != original_assignee:
|
|
||||||
if not can_manage:
|
|
||||||
raise PermissionDeniedError(detail="Only chore managers can reassign assignments")
|
|
||||||
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']})
|
|
||||||
|
|
||||||
if 'is_complete' in update_data:
|
|
||||||
if update_data['is_complete'] and not db_assignment.is_complete:
|
|
||||||
update_data['completed_at'] = datetime.utcnow()
|
|
||||||
chore.last_completed_at = update_data['completed_at']
|
|
||||||
chore.next_due_date = calculate_next_due_date(
|
|
||||||
current_due_date=chore.next_due_date,
|
|
||||||
frequency=chore.frequency,
|
|
||||||
custom_interval_days=chore.custom_interval_days,
|
|
||||||
last_completed_date=chore.last_completed_at
|
|
||||||
)
|
|
||||||
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
|
|
||||||
elif not update_data['is_complete'] and db_assignment.is_complete:
|
|
||||||
update_data['completed_at'] = None
|
|
||||||
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED)
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(db_assignment, field, value)
|
setattr(db_assignment, field, value)
|
||||||
|
|
||||||
|
if 'is_complete' in update_data:
|
||||||
|
new_status = update_data['is_complete']
|
||||||
|
history_event = None
|
||||||
|
if new_status and not original_status:
|
||||||
|
db_assignment.completed_at = datetime.utcnow()
|
||||||
|
history_event = ChoreHistoryEventTypeEnum.COMPLETED
|
||||||
|
|
||||||
|
# Advance the next_due_date of the parent chore
|
||||||
|
if db_assignment.chore:
|
||||||
|
db_assignment.chore.last_completed_at = db_assignment.completed_at
|
||||||
|
db_assignment.chore.next_due_date = calculate_next_due_date(
|
||||||
|
db_assignment.chore.frequency,
|
||||||
|
db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else date.today(),
|
||||||
|
db_assignment.chore.custom_interval_days
|
||||||
|
)
|
||||||
|
elif not new_status and original_status:
|
||||||
|
db_assignment.completed_at = None
|
||||||
|
history_event = ChoreHistoryEventTypeEnum.REOPENED
|
||||||
|
# Policy: Do not automatically roll back parent chore's due date.
|
||||||
|
|
||||||
|
if history_event:
|
||||||
|
await create_assignment_history_entry(
|
||||||
|
db=db,
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=history_event,
|
||||||
|
event_data={"new_status": new_status}
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await db.flush()
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ChoreAssignment)
|
select(ChoreAssignment)
|
||||||
.where(ChoreAssignment.id == db_assignment.id)
|
.where(ChoreAssignment.id == assignment_id)
|
||||||
.options(
|
.options(
|
||||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||||
selectinload(ChoreAssignment.assigned_user),
|
selectinload(ChoreAssignment.assigned_user)
|
||||||
selectinload(ChoreAssignment.history)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating chore assignment {assignment_id}: {e}", exc_info=True)
|
logger.error(f"Error updating assignment: {e}", exc_info=True)
|
||||||
raise DatabaseIntegrityError(f"Could not update chore assignment {assignment_id}. Error: {str(e)}")
|
await db.rollback()
|
||||||
|
raise DatabaseIntegrityError(f"Could not update assignment. Error: {str(e)}")
|
||||||
|
|
||||||
async def delete_chore_assignment(
|
async def delete_chore_assignment(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
@ -7,6 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # A
|
|||||||
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
|
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
|
||||||
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
|
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
|
||||||
from datetime import datetime, timezone # Added timezone
|
from datetime import datetime, timezone # Added timezone
|
||||||
|
import json
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Expense as ExpenseModel,
|
Expense as ExpenseModel,
|
||||||
@ -34,6 +35,7 @@ from app.core.exceptions import (
|
|||||||
ExpenseOperationError # Added specific exception
|
ExpenseOperationError # Added specific exception
|
||||||
)
|
)
|
||||||
from app.models import RecurrencePattern
|
from app.models import RecurrencePattern
|
||||||
|
from app.crud.audit import create_financial_audit_log
|
||||||
|
|
||||||
# Placeholder for InvalidOperationError if not defined in app.core.exceptions
|
# Placeholder for InvalidOperationError if not defined in app.core.exceptions
|
||||||
# This should be a proper HTTPException subclass if used in API layer
|
# This should be a proper HTTPException subclass if used in API layer
|
||||||
@ -215,6 +217,13 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us
|
|||||||
# await transaction.rollback() # Should be handled by context manager
|
# await transaction.rollback() # Should be handled by context manager
|
||||||
raise ExpenseOperationError("Failed to load expense after creation.")
|
raise ExpenseOperationError("Failed to load expense after creation.")
|
||||||
|
|
||||||
|
await create_financial_audit_log(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user_id,
|
||||||
|
action_type="EXPENSE_CREATED",
|
||||||
|
entity=loaded_expense,
|
||||||
|
)
|
||||||
|
|
||||||
# await transaction.commit() # Explicit commit removed, context manager handles it.
|
# await transaction.commit() # Explicit commit removed, context manager handles it.
|
||||||
return loaded_expense
|
return loaded_expense
|
||||||
|
|
||||||
@ -611,22 +620,28 @@ async def get_user_accessible_expenses(db: AsyncSession, user_id: int, skip: int
|
|||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate) -> ExpenseModel:
|
async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in: ExpenseUpdate, current_user_id: int) -> ExpenseModel:
|
||||||
"""
|
"""
|
||||||
Updates an existing expense.
|
Updates an expense. For now, only allows simple field updates.
|
||||||
Only allows updates to description, currency, and expense_date to avoid split complexities.
|
More complex updates (like changing split logic) would require a more sophisticated approach.
|
||||||
Requires version matching for optimistic locking.
|
|
||||||
"""
|
"""
|
||||||
|
if expense_in.version is None:
|
||||||
|
raise InvalidOperationError("Version is required for updating an expense.")
|
||||||
|
|
||||||
if expense_db.version != expense_in.version:
|
if expense_db.version != expense_in.version:
|
||||||
raise InvalidOperationError(
|
raise InvalidOperationError(
|
||||||
f"Expense '{expense_db.description}' (ID: {expense_db.id}) has been modified. "
|
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be updated. "
|
||||||
f"Your version is {expense_in.version}, current version is {expense_db.version}. Please refresh.",
|
f"Your expected version {expense_in.version} does not match current version {expense_db.version}. Please refresh.",
|
||||||
# status_code=status.HTTP_409_CONFLICT # This would be for the API layer to set
|
|
||||||
)
|
)
|
||||||
|
|
||||||
update_data = expense_in.model_dump(exclude_unset=True, exclude={"version"}) # Exclude version itself from data
|
before_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in expense_in.dict(exclude_unset=True)}
|
||||||
|
# A simple way to handle non-serializable types for JSON
|
||||||
|
for k, v in before_state.items():
|
||||||
|
if isinstance(v, (datetime, Decimal)):
|
||||||
|
before_state[k] = str(v)
|
||||||
|
|
||||||
|
update_data = expense_in.dict(exclude_unset=True, exclude={"version"})
|
||||||
|
|
||||||
# Fields that are safe to update without affecting splits or core logic
|
|
||||||
allowed_to_update = {"description", "currency", "expense_date"}
|
allowed_to_update = {"description", "currency", "expense_date"}
|
||||||
|
|
||||||
updated_something = False
|
updated_something = False
|
||||||
@ -635,38 +650,41 @@ async def update_expense(db: AsyncSession, expense_db: ExpenseModel, expense_in:
|
|||||||
setattr(expense_db, field, value)
|
setattr(expense_db, field, value)
|
||||||
updated_something = True
|
updated_something = True
|
||||||
else:
|
else:
|
||||||
# If any other field is present in the update payload, it's an invalid operation for this simple update
|
|
||||||
raise InvalidOperationError(f"Field '{field}' cannot be updated. Only {', '.join(allowed_to_update)} are allowed.")
|
raise InvalidOperationError(f"Field '{field}' cannot be updated. Only {', '.join(allowed_to_update)} are allowed.")
|
||||||
|
|
||||||
if not updated_something and not expense_in.model_fields_set.intersection(allowed_to_update):
|
if not updated_something:
|
||||||
# No actual updatable fields were provided in the payload, even if others (like version) were.
|
pass
|
||||||
# This could be a non-issue, or an indication of a misuse of the endpoint.
|
|
||||||
# For now, if only version was sent, we still increment if it matched.
|
|
||||||
pass # Or raise InvalidOperationError("No updatable fields provided.")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
async with db.begin_nested() if db.in_transaction() else db.begin():
|
||||||
expense_db.version += 1
|
expense_db.version += 1
|
||||||
expense_db.updated_at = datetime.now(timezone.utc) # Manually update timestamp
|
expense_db.updated_at = datetime.now(timezone.utc)
|
||||||
# db.add(expense_db) # Not strictly necessary as expense_db is already tracked by the session
|
|
||||||
|
|
||||||
await db.flush() # Persist changes to the DB and run constraints
|
await db.flush()
|
||||||
await db.refresh(expense_db) # Refresh the object from the DB
|
|
||||||
return expense_db
|
after_state = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns if c.name in update_data}
|
||||||
except InvalidOperationError: # Re-raise validation errors to be handled by the caller
|
for k, v in after_state.items():
|
||||||
raise
|
if isinstance(v, (datetime, Decimal)):
|
||||||
|
after_state[k] = str(v)
|
||||||
|
|
||||||
|
await create_financial_audit_log(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user_id,
|
||||||
|
action_type="EXPENSE_UPDATED",
|
||||||
|
entity=expense_db,
|
||||||
|
details={"before": before_state, "after": after_state}
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.refresh(expense_db)
|
||||||
|
return expense_db
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
logger.error(f"Database integrity error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
|
logger.error(f"Database integrity error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
|
||||||
# The transaction context manager (begin_nested/begin) handles rollback.
|
|
||||||
raise DatabaseIntegrityError(f"Failed to update expense ID {expense_db.id} due to database integrity issue.") from e
|
raise DatabaseIntegrityError(f"Failed to update expense ID {expense_db.id} due to database integrity issue.") from e
|
||||||
except SQLAlchemyError as e: # Catch other SQLAlchemy errors
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Database transaction error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
|
logger.error(f"Database transaction error during expense update for ID {expense_db.id}: {str(e)}", exc_info=True)
|
||||||
# The transaction context manager (begin_nested/begin) handles rollback.
|
|
||||||
raise DatabaseTransactionError(f"Failed to update expense ID {expense_db.id} due to a database transaction error.") from e
|
raise DatabaseTransactionError(f"Failed to update expense ID {expense_db.id} due to a database transaction error.") from e
|
||||||
# No generic Exception catch here, let other unexpected errors propagate if not SQLAlchemy related.
|
|
||||||
|
|
||||||
|
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
|
||||||
async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_version: Optional[int] = None) -> None:
|
|
||||||
"""
|
"""
|
||||||
Deletes an expense. Requires version matching if expected_version is provided.
|
Deletes an expense. Requires version matching if expected_version is provided.
|
||||||
Associated ExpenseSplits are cascade deleted by the database foreign key constraint.
|
Associated ExpenseSplits are cascade deleted by the database foreign key constraint.
|
||||||
@ -675,23 +693,33 @@ async def delete_expense(db: AsyncSession, expense_db: ExpenseModel, expected_ve
|
|||||||
raise InvalidOperationError(
|
raise InvalidOperationError(
|
||||||
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be deleted. "
|
f"Expense '{expense_db.description}' (ID: {expense_db.id}) cannot be deleted. "
|
||||||
f"Your expected version {expected_version} does not match current version {expense_db.version}. Please refresh.",
|
f"Your expected version {expected_version} does not match current version {expense_db.version}. Please refresh.",
|
||||||
# status_code=status.HTTP_409_CONFLICT
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
async with db.begin_nested() if db.in_transaction() else db.begin():
|
||||||
|
details = {c.name: getattr(expense_db, c.name) for c in expense_db.__table__.columns}
|
||||||
|
for k, v in details.items():
|
||||||
|
if isinstance(v, (datetime, Decimal)):
|
||||||
|
details[k] = str(v)
|
||||||
|
|
||||||
|
expense_id_for_log = expense_db.id
|
||||||
|
|
||||||
|
await create_financial_audit_log(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user_id,
|
||||||
|
action_type="EXPENSE_DELETED",
|
||||||
|
entity=expense_db,
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
await db.delete(expense_db)
|
await db.delete(expense_db)
|
||||||
await db.flush() # Ensure the delete operation is sent to the database
|
await db.flush()
|
||||||
except InvalidOperationError: # Re-raise validation errors
|
|
||||||
raise
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
logger.error(f"Database integrity error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
|
logger.error(f"Database integrity error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
|
||||||
# The transaction context manager (begin_nested/begin) handles rollback.
|
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_id_for_log} due to database integrity issue.") from e
|
||||||
raise DatabaseIntegrityError(f"Failed to delete expense ID {expense_db.id} due to database integrity issue.") from e
|
except SQLAlchemyError as e:
|
||||||
except SQLAlchemyError as e: # Catch other SQLAlchemy errors
|
logger.error(f"Database transaction error during expense deletion for ID {expense_id_for_log}: {str(e)}", exc_info=True)
|
||||||
logger.error(f"Database transaction error during expense deletion for ID {expense_db.id}: {str(e)}", exc_info=True)
|
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_id_for_log} due to a database transaction error.") from e
|
||||||
# The transaction context manager (begin_nested/begin) handles rollback.
|
|
||||||
raise DatabaseTransactionError(f"Failed to delete expense ID {expense_db.id} due to a database transaction error.") from e
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Note: The InvalidOperationError is a simple ValueError placeholder.
|
# Note: The InvalidOperationError is a simple ValueError placeholder.
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload, joinedload, contains_eager
|
||||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
from sqlalchemy import delete, func
|
from sqlalchemy import delete, func, and_, or_, update, desc
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
|
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel, List as ListModel, Chore as ChoreModel, ChoreAssignment as ChoreAssignmentModel
|
||||||
from app.schemas.group import GroupCreate
|
from app.schemas.group import GroupCreate, GroupPublic
|
||||||
from app.models import UserRoleEnum
|
from app.models import UserRoleEnum
|
||||||
from app.core.exceptions import (
|
from app.core.exceptions import (
|
||||||
GroupOperationError,
|
GroupOperationError,
|
||||||
@ -17,8 +18,10 @@ from app.core.exceptions import (
|
|||||||
DatabaseQueryError,
|
DatabaseQueryError,
|
||||||
DatabaseTransactionError,
|
DatabaseTransactionError,
|
||||||
GroupMembershipError,
|
GroupMembershipError,
|
||||||
GroupPermissionError # Import GroupPermissionError
|
GroupPermissionError,
|
||||||
|
PermissionDeniedError
|
||||||
)
|
)
|
||||||
|
from app.core.cache import cache
|
||||||
|
|
||||||
logger = logging.getLogger(__name__) # Initialize logger
|
logger = logging.getLogger(__name__) # Initialize logger
|
||||||
|
|
||||||
@ -88,22 +91,18 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
|
|||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
raise DatabaseQueryError(f"Failed to query user groups: {str(e)}")
|
raise DatabaseQueryError(f"Failed to query user groups: {str(e)}")
|
||||||
|
|
||||||
|
@cache(expire_time=1800, key_prefix="group") # Cache for 30 minutes
|
||||||
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
|
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
|
||||||
"""Gets a single group by its ID, optionally loading members."""
|
"""Get a group by its ID with caching, including member associations and chore history."""
|
||||||
try:
|
result = await db.execute(
|
||||||
result = await db.execute(
|
select(GroupModel)
|
||||||
select(GroupModel)
|
.where(GroupModel.id == group_id)
|
||||||
.where(GroupModel.id == group_id)
|
.options(
|
||||||
.options(
|
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
|
||||||
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user),
|
selectinload(GroupModel.chore_history)
|
||||||
selectinload(GroupModel.chore_history) # Eager load chore history
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
)
|
||||||
except OperationalError as e:
|
return result.scalar_one_or_none()
|
||||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
raise DatabaseQueryError(f"Failed to query group: {str(e)}")
|
|
||||||
|
|
||||||
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
|
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
|
||||||
"""Checks if a user is a member of a specific group."""
|
"""Checks if a user is a member of a specific group."""
|
||||||
|
@ -33,6 +33,7 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
|
|||||||
db_item = ItemModel(
|
db_item = ItemModel(
|
||||||
name=item_in.name,
|
name=item_in.name,
|
||||||
quantity=item_in.quantity,
|
quantity=item_in.quantity,
|
||||||
|
category_id=item_in.category_id,
|
||||||
list_id=list_id,
|
list_id=list_id,
|
||||||
added_by_id=user_id,
|
added_by_id=user_id,
|
||||||
is_complete=False,
|
is_complete=False,
|
||||||
@ -116,6 +117,9 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
|
|||||||
|
|
||||||
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
|
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
|
||||||
|
|
||||||
|
if 'category_id' in update_data:
|
||||||
|
item_db.category_id = update_data.pop('category_id')
|
||||||
|
|
||||||
if 'position' in update_data:
|
if 'position' in update_data:
|
||||||
new_position = update_data.pop('position')
|
new_position = update_data.pop('position')
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
|
|||||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
|
||||||
from typing import Optional, List as PyList
|
from typing import Optional, List as PyList
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.schemas.list import ListStatus
|
from app.schemas.list import ListStatus
|
||||||
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
|
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
|
||||||
@ -62,7 +63,7 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
|
|||||||
logger.error(f"Unexpected SQLAlchemy error during list creation: {str(e)}", exc_info=True)
|
logger.error(f"Unexpected SQLAlchemy error during list creation: {str(e)}", exc_info=True)
|
||||||
raise DatabaseTransactionError(f"Failed to create list: {str(e)}")
|
raise DatabaseTransactionError(f"Failed to create list: {str(e)}")
|
||||||
|
|
||||||
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]:
|
async def get_lists_for_user(db: AsyncSession, user_id: int, include_archived: bool = False) -> PyList[ListModel]:
|
||||||
"""Gets all lists accessible by a user."""
|
"""Gets all lists accessible by a user."""
|
||||||
try:
|
try:
|
||||||
group_ids_result = await db.execute(
|
group_ids_result = await db.execute(
|
||||||
@ -76,19 +77,19 @@ async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel
|
|||||||
if user_group_ids:
|
if user_group_ids:
|
||||||
conditions.append(ListModel.group_id.in_(user_group_ids))
|
conditions.append(ListModel.group_id.in_(user_group_ids))
|
||||||
|
|
||||||
query = (
|
query = select(ListModel).where(or_(*conditions))
|
||||||
select(ListModel)
|
|
||||||
.where(or_(*conditions))
|
if not include_archived:
|
||||||
.options(
|
query = query.where(ListModel.archived_at.is_(None))
|
||||||
selectinload(ListModel.creator),
|
|
||||||
selectinload(ListModel.group),
|
query = query.options(
|
||||||
selectinload(ListModel.items).options(
|
selectinload(ListModel.creator),
|
||||||
joinedload(ItemModel.added_by_user),
|
selectinload(ListModel.group),
|
||||||
joinedload(ItemModel.completed_by_user)
|
selectinload(ListModel.items).options(
|
||||||
)
|
joinedload(ItemModel.added_by_user),
|
||||||
|
joinedload(ItemModel.completed_by_user)
|
||||||
)
|
)
|
||||||
.order_by(ListModel.updated_at.desc())
|
).order_by(ListModel.updated_at.desc())
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
@ -169,17 +170,35 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
|
|||||||
logger.error(f"Unexpected SQLAlchemy error during list update: {str(e)}", exc_info=True)
|
logger.error(f"Unexpected SQLAlchemy error during list update: {str(e)}", exc_info=True)
|
||||||
raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
|
raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
|
||||||
|
|
||||||
async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
|
async def archive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
|
||||||
"""Deletes a list record. Version check should be done by the caller (API endpoint)."""
|
"""Archives a list record by setting the archived_at timestamp."""
|
||||||
try:
|
try:
|
||||||
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
||||||
await db.delete(list_db)
|
list_db.archived_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(list_db)
|
||||||
|
return list_db
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True)
|
logger.error(f"Database connection error while archiving list: {str(e)}", exc_info=True)
|
||||||
raise DatabaseConnectionError(f"Database connection error while deleting list: {str(e)}")
|
raise DatabaseConnectionError(f"Database connection error while archiving list: {str(e)}")
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"Unexpected SQLAlchemy error while deleting list: {str(e)}", exc_info=True)
|
logger.error(f"Unexpected SQLAlchemy error while archiving list: {str(e)}", exc_info=True)
|
||||||
raise DatabaseTransactionError(f"Failed to delete list: {str(e)}")
|
raise DatabaseTransactionError(f"Failed to archive list: {str(e)}")
|
||||||
|
|
||||||
|
async def unarchive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
|
||||||
|
"""Unarchives a list record by setting the archived_at timestamp to None."""
|
||||||
|
try:
|
||||||
|
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
||||||
|
list_db.archived_at = None
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(list_db)
|
||||||
|
return list_db
|
||||||
|
except OperationalError as e:
|
||||||
|
logger.error(f"Database connection error while unarchiving list: {str(e)}", exc_info=True)
|
||||||
|
raise DatabaseConnectionError(f"Database connection error while unarchiving list: {str(e)}")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Unexpected SQLAlchemy error while unarchiving list: {str(e)}", exc_info=True)
|
||||||
|
raise DatabaseTransactionError(f"Failed to unarchive list: {str(e)}")
|
||||||
|
|
||||||
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
|
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
|
||||||
"""Fetches a list and verifies user permission."""
|
"""Fetches a list and verifies user permission."""
|
||||||
|
@ -26,6 +26,7 @@ from app.core.exceptions import (
|
|||||||
SettlementOperationError,
|
SettlementOperationError,
|
||||||
ConflictError
|
ConflictError
|
||||||
)
|
)
|
||||||
|
from app.crud.audit import create_financial_audit_log
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -76,6 +77,13 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
|
|||||||
if loaded_settlement is None:
|
if loaded_settlement is None:
|
||||||
raise SettlementOperationError("Failed to load settlement after creation.")
|
raise SettlementOperationError("Failed to load settlement after creation.")
|
||||||
|
|
||||||
|
await create_financial_audit_log(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user_id,
|
||||||
|
action_type="SETTLEMENT_CREATED",
|
||||||
|
entity=loaded_settlement,
|
||||||
|
)
|
||||||
|
|
||||||
return loaded_settlement
|
return loaded_settlement
|
||||||
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
|
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
|
||||||
raise
|
raise
|
||||||
@ -160,7 +168,7 @@ async def get_settlements_involving_user(
|
|||||||
raise DatabaseQueryError(f"DB query error fetching user settlements: {str(e)}")
|
raise DatabaseQueryError(f"DB query error fetching user settlements: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate) -> SettlementModel:
|
async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, settlement_in: SettlementUpdate, current_user_id: int) -> SettlementModel:
|
||||||
"""
|
"""
|
||||||
Updates an existing settlement.
|
Updates an existing settlement.
|
||||||
Only allows updates to description and settlement_date.
|
Only allows updates to description and settlement_date.
|
||||||
@ -179,6 +187,11 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
|
|||||||
f"Your version {settlement_in.version} does not match current version {settlement_db.version}. Please refresh."
|
f"Your version {settlement_in.version} does not match current version {settlement_db.version}. Please refresh."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
before_state = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns if c.name in settlement_in.dict(exclude_unset=True)}
|
||||||
|
for k, v in before_state.items():
|
||||||
|
if isinstance(v, (datetime, Decimal)):
|
||||||
|
before_state[k] = str(v)
|
||||||
|
|
||||||
update_data = settlement_in.model_dump(exclude_unset=True, exclude={"version"})
|
update_data = settlement_in.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
allowed_to_update = {"description", "settlement_date"}
|
allowed_to_update = {"description", "settlement_date"}
|
||||||
updated_something = False
|
updated_something = False
|
||||||
@ -211,6 +224,19 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
|
|||||||
if updated_settlement is None:
|
if updated_settlement is None:
|
||||||
raise SettlementOperationError("Failed to load settlement after update.")
|
raise SettlementOperationError("Failed to load settlement after update.")
|
||||||
|
|
||||||
|
after_state = {c.name: getattr(updated_settlement, c.name) for c in updated_settlement.__table__.columns if c.name in update_data}
|
||||||
|
for k, v in after_state.items():
|
||||||
|
if isinstance(v, (datetime, Decimal)):
|
||||||
|
after_state[k] = str(v)
|
||||||
|
|
||||||
|
await create_financial_audit_log(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user_id,
|
||||||
|
action_type="SETTLEMENT_UPDATED",
|
||||||
|
entity=updated_settlement,
|
||||||
|
details={"before": before_state, "after": after_state}
|
||||||
|
)
|
||||||
|
|
||||||
return updated_settlement
|
return updated_settlement
|
||||||
except ConflictError as e:
|
except ConflictError as e:
|
||||||
raise
|
raise
|
||||||
@ -227,7 +253,7 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
|
|||||||
raise DatabaseTransactionError(f"DB transaction error during settlement update: {str(e)}")
|
raise DatabaseTransactionError(f"DB transaction error during settlement update: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, expected_version: Optional[int] = None) -> None:
|
async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, current_user_id: int, expected_version: Optional[int] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Deletes a settlement. Requires version matching if expected_version is provided.
|
Deletes a settlement. Requires version matching if expected_version is provided.
|
||||||
Assumes SettlementModel has a version field.
|
Assumes SettlementModel has a version field.
|
||||||
@ -241,6 +267,19 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
|
|||||||
f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh."
|
f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
details = {c.name: getattr(settlement_db, c.name) for c in settlement_db.__table__.columns}
|
||||||
|
for k, v in details.items():
|
||||||
|
if isinstance(v, (datetime, Decimal)):
|
||||||
|
details[k] = str(v)
|
||||||
|
|
||||||
|
await create_financial_audit_log(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user_id,
|
||||||
|
action_type="SETTLEMENT_DELETED",
|
||||||
|
entity=settlement_db,
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
await db.delete(settlement_db)
|
await db.delete(settlement_db)
|
||||||
except ConflictError as e:
|
except ConflictError as e:
|
||||||
raise
|
raise
|
||||||
|
@ -15,6 +15,7 @@ from app.models import (
|
|||||||
ExpenseOverallStatusEnum,
|
ExpenseOverallStatusEnum,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from app.crud.audit import create_financial_audit_log
|
||||||
|
|
||||||
|
|
||||||
class SettlementActivityCreatePlaceholder(BaseModel):
|
class SettlementActivityCreatePlaceholder(BaseModel):
|
||||||
@ -140,6 +141,13 @@ async def create_settlement_activity(
|
|||||||
db.add(db_settlement_activity)
|
db.add(db_settlement_activity)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
await create_financial_audit_log(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user_id,
|
||||||
|
action_type="SETTLEMENT_ACTIVITY_CREATED",
|
||||||
|
entity=db_settlement_activity,
|
||||||
|
)
|
||||||
|
|
||||||
# Update statuses
|
# Update statuses
|
||||||
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
|
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
|
||||||
if updated_split and updated_split.expense_id:
|
if updated_split and updated_split.expense_id:
|
||||||
|
@ -39,7 +39,7 @@ async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]
|
|||||||
logger.error(f"Unexpected SQLAlchemy error while fetching user by email '{email}': {str(e)}", exc_info=True)
|
logger.error(f"Unexpected SQLAlchemy error while fetching user by email '{email}': {str(e)}", exc_info=True)
|
||||||
raise DatabaseQueryError(f"Failed to query user: {str(e)}")
|
raise DatabaseQueryError(f"Failed to query user: {str(e)}")
|
||||||
|
|
||||||
async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
|
async def create_user(db: AsyncSession, user_in: UserCreate, is_guest: bool = False) -> UserModel:
|
||||||
"""Creates a new user record in the database with common relationships loaded."""
|
"""Creates a new user record in the database with common relationships loaded."""
|
||||||
try:
|
try:
|
||||||
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
||||||
@ -47,7 +47,8 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
|
|||||||
db_user = UserModel(
|
db_user = UserModel(
|
||||||
email=user_in.email,
|
email=user_in.email,
|
||||||
hashed_password=_hashed_password,
|
hashed_password=_hashed_password,
|
||||||
name=user_in.name
|
name=user_in.name,
|
||||||
|
is_guest=is_guest
|
||||||
)
|
)
|
||||||
db.add(db_user)
|
db.add(db_user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
@ -57,32 +57,6 @@ app.add_middleware(
|
|||||||
expose_headers=["*"]
|
expose_headers=["*"]
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
fastapi_users.get_auth_router(auth_backend),
|
|
||||||
prefix="/auth/jwt",
|
|
||||||
tags=["auth"],
|
|
||||||
)
|
|
||||||
app.include_router(
|
|
||||||
fastapi_users.get_register_router(UserPublic, UserCreate),
|
|
||||||
prefix="/auth",
|
|
||||||
tags=["auth"],
|
|
||||||
)
|
|
||||||
app.include_router(
|
|
||||||
fastapi_users.get_reset_password_router(),
|
|
||||||
prefix="/auth",
|
|
||||||
tags=["auth"],
|
|
||||||
)
|
|
||||||
app.include_router(
|
|
||||||
fastapi_users.get_verify_router(UserPublic),
|
|
||||||
prefix="/auth",
|
|
||||||
tags=["auth"],
|
|
||||||
)
|
|
||||||
app.include_router(
|
|
||||||
fastapi_users.get_users_router(UserPublic, UserUpdate),
|
|
||||||
prefix="/users",
|
|
||||||
tags=["users"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.API_PREFIX)
|
app.include_router(api_router, prefix=settings.API_PREFIX)
|
||||||
|
|
||||||
@app.get("/health", tags=["Health"])
|
@app.get("/health", tags=["Health"])
|
||||||
|
@ -93,6 +93,7 @@ class User(Base):
|
|||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
is_superuser = Column(Boolean, default=False, nullable=False)
|
is_superuser = Column(Boolean, default=False, nullable=False)
|
||||||
is_verified = Column(Boolean, default=False, nullable=False)
|
is_verified = Column(Boolean, default=False, nullable=False)
|
||||||
|
is_guest = Column(Boolean, default=False, nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
# --- Relationships ---
|
# --- Relationships ---
|
||||||
@ -112,6 +113,9 @@ class User(Base):
|
|||||||
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
|
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
|
||||||
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
|
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
|
||||||
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
|
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
|
||||||
|
financial_audit_logs = relationship("FinancialAuditLog", back_populates="user")
|
||||||
|
time_entries = relationship("TimeEntry", back_populates="user")
|
||||||
|
categories = relationship("Category", back_populates="user")
|
||||||
|
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
@ -120,6 +124,8 @@ class Group(Base):
|
|||||||
name = Column(String, index=True, nullable=False)
|
name = Column(String, index=True, nullable=False)
|
||||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
version = Column(Integer, nullable=False, default=1, server_default='1')
|
||||||
|
|
||||||
creator = relationship("User", back_populates="created_groups")
|
creator = relationship("User", back_populates="created_groups")
|
||||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
|
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
|
||||||
@ -174,6 +180,7 @@ class List(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
version = Column(Integer, nullable=False, default=1, server_default='1')
|
version = Column(Integer, nullable=False, default=1, server_default='1')
|
||||||
|
archived_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
creator = relationship("User", back_populates="created_lists")
|
creator = relationship("User", back_populates="created_lists")
|
||||||
group = relationship("Group", back_populates="lists")
|
group = relationship("Group", back_populates="lists")
|
||||||
@ -199,6 +206,7 @@ class Item(Base):
|
|||||||
is_complete = Column(Boolean, default=False, nullable=False)
|
is_complete = Column(Boolean, default=False, nullable=False)
|
||||||
price = Column(Numeric(10, 2), nullable=True)
|
price = Column(Numeric(10, 2), nullable=True)
|
||||||
position = Column(Integer, nullable=False, server_default='0')
|
position = Column(Integer, nullable=False, server_default='0')
|
||||||
|
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
|
||||||
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
@ -210,6 +218,7 @@ class Item(Base):
|
|||||||
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
|
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
|
||||||
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
|
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
|
||||||
expenses = relationship("Expense", back_populates="item")
|
expenses = relationship("Expense", back_populates="item")
|
||||||
|
category = relationship("Category", back_populates="items")
|
||||||
|
|
||||||
class Expense(Base):
|
class Expense(Base):
|
||||||
__tablename__ = "expenses"
|
__tablename__ = "expenses"
|
||||||
@ -248,7 +257,7 @@ class Expense(Base):
|
|||||||
last_occurrence = Column(DateTime(timezone=True), nullable=True)
|
last_occurrence = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
|
CheckConstraint('group_id IS NOT NULL OR list_id IS NOT NULL', name='ck_expense_group_or_list'),
|
||||||
)
|
)
|
||||||
|
|
||||||
class ExpenseSplit(Base):
|
class ExpenseSplit(Base):
|
||||||
@ -335,6 +344,7 @@ class Chore(Base):
|
|||||||
name = Column(String, nullable=False, index=True)
|
name = Column(String, nullable=False, index=True)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
parent_chore_id = Column(Integer, ForeignKey('chores.id'), nullable=True, index=True)
|
||||||
|
|
||||||
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
|
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
|
||||||
custom_interval_days = Column(Integer, nullable=True)
|
custom_interval_days = Column(Integer, nullable=True)
|
||||||
@ -349,6 +359,8 @@ class Chore(Base):
|
|||||||
creator = relationship("User", back_populates="created_chores")
|
creator = relationship("User", back_populates="created_chores")
|
||||||
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
|
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
|
||||||
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
|
history = relationship("ChoreHistory", back_populates="chore", cascade="all, delete-orphan")
|
||||||
|
parent_chore = relationship("Chore", remote_side=[id], back_populates="child_chores")
|
||||||
|
child_chores = relationship("Chore", back_populates="parent_chore", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
# --- ChoreAssignment Model ---
|
# --- ChoreAssignment Model ---
|
||||||
@ -369,6 +381,7 @@ class ChoreAssignment(Base):
|
|||||||
chore = relationship("Chore", back_populates="assignments")
|
chore = relationship("Chore", back_populates="assignments")
|
||||||
assigned_user = relationship("User", back_populates="assigned_chores")
|
assigned_user = relationship("User", back_populates="assigned_chores")
|
||||||
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
|
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
|
||||||
|
time_entries = relationship("TimeEntry", back_populates="assignment", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
# === NEW: RecurrencePattern Model ===
|
# === NEW: RecurrencePattern Model ===
|
||||||
@ -419,3 +432,41 @@ class ChoreAssignmentHistory(Base):
|
|||||||
|
|
||||||
assignment = relationship("ChoreAssignment", back_populates="history")
|
assignment = relationship("ChoreAssignment", back_populates="history")
|
||||||
changed_by_user = relationship("User", back_populates="assignment_history_entries")
|
changed_by_user = relationship("User", back_populates="assignment_history_entries")
|
||||||
|
|
||||||
|
# --- New Models from Roadmap ---
|
||||||
|
|
||||||
|
class FinancialAuditLog(Base):
|
||||||
|
__tablename__ = 'financial_audit_log'
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||||
|
action_type = Column(String, nullable=False, index=True)
|
||||||
|
entity_type = Column(String, nullable=False)
|
||||||
|
entity_id = Column(Integer, nullable=False)
|
||||||
|
details = Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="financial_audit_logs")
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = 'categories'
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||||
|
group_id = Column(Integer, ForeignKey('groups.id'), nullable=True)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="categories")
|
||||||
|
items = relationship("Item", back_populates="category")
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope'),)
|
||||||
|
|
||||||
|
class TimeEntry(Base):
|
||||||
|
__tablename__ = 'time_entries'
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
chore_assignment_id = Column(Integer, ForeignKey('chore_assignments.id', ondelete="CASCADE"), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||||
|
start_time = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
duration_seconds = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
assignment = relationship("ChoreAssignment", back_populates="time_entries")
|
||||||
|
user = relationship("User", back_populates="time_entries")
|
||||||
|
20
be/app/schemas/audit.py
Normal file
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 datetime import date, datetime
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
@ -55,6 +56,7 @@ class ChoreBase(BaseModel):
|
|||||||
|
|
||||||
class ChoreCreate(ChoreBase):
|
class ChoreCreate(ChoreBase):
|
||||||
group_id: Optional[int] = None
|
group_id: Optional[int] = None
|
||||||
|
parent_chore_id: Optional[int] = None
|
||||||
|
|
||||||
@field_validator('group_id')
|
@field_validator('group_id')
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -89,11 +91,13 @@ class ChorePublic(ChoreBase):
|
|||||||
group_id: Optional[int] = None
|
group_id: Optional[int] = None
|
||||||
created_by_id: int
|
created_by_id: int
|
||||||
last_completed_at: Optional[datetime] = None
|
last_completed_at: Optional[datetime] = None
|
||||||
|
parent_chore_id: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
|
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
|
||||||
assignments: List[ChoreAssignmentPublic] = []
|
assignments: List[ChoreAssignmentPublic] = []
|
||||||
history: List[ChoreHistoryPublic] = []
|
history: List[ChoreHistoryPublic] = []
|
||||||
|
child_chores: List[ChorePublic] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ class ItemPublic(BaseModel):
|
|||||||
class ItemCreate(BaseModel):
|
class ItemCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
quantity: Optional[str] = None
|
quantity: Optional[str] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
|
||||||
class ItemUpdate(BaseModel):
|
class ItemUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@ -27,4 +28,5 @@ class ItemUpdate(BaseModel):
|
|||||||
is_complete: Optional[bool] = None
|
is_complete: Optional[bool] = None
|
||||||
price: Optional[Decimal] = None
|
price: Optional[Decimal] = None
|
||||||
position: Optional[int] = None
|
position: Optional[int] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
version: int
|
version: int
|
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_superuser: Optional[bool] = None
|
||||||
is_verified: Optional[bool] = None
|
is_verified: Optional[bool] = None
|
||||||
|
|
||||||
|
class UserClaim(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
class UserInDBBase(UserBase):
|
class UserInDBBase(UserBase):
|
||||||
id: int
|
id: int
|
||||||
password_hash: str
|
password_hash: str
|
||||||
|
@ -25,3 +25,4 @@ aiosqlite>=0.19.0 # For async SQLite support in tests
|
|||||||
|
|
||||||
# Scheduler
|
# Scheduler
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
redis>=5.0.0
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';
|
import type { ExpenseCreate, ExpenseSplitCreate } from '@/types/expense';
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue';
|
import { ref, watch, nextTick } from 'vue';
|
||||||
import { useVModel } from '@vueuse/core';
|
import { useVModel } from '@vueuse/core';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import VModal from '@/components/valerie/VModal.vue';
|
import VModal from '@/components/valerie/VModal.vue';
|
||||||
import VFormField from '@/components/valerie/VFormField.vue';
|
import VFormField from '@/components/valerie/VFormField.vue';
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, computed } from 'vue';
|
import { ref, watch, nextTick, computed } from 'vue';
|
||||||
import { useVModel } from '@vueuse/core'; // onClickOutside removed
|
import { useVModel } from '@vueuse/core'; // onClickOutside removed
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming this path is correct
|
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming this path is correct
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import VModal from '@/components/valerie/VModal.vue';
|
import VModal from '@/components/valerie/VModal.vue';
|
||||||
import VFormField from '@/components/valerie/VFormField.vue';
|
import VFormField from '@/components/valerie/VFormField.vue';
|
||||||
@ -38,6 +38,7 @@ import VTextarea from '@/components/valerie/VTextarea.vue';
|
|||||||
import VSelect from '@/components/valerie/VSelect.vue';
|
import VSelect from '@/components/valerie/VSelect.vue';
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
import VButton from '@/components/valerie/VButton.vue';
|
||||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||||||
|
import type { Group } from '@/types/group';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
|
@ -1,10 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button :type="type" :class="buttonClasses" :disabled="disabled" @click="handleClick">
|
||||||
:type="type"
|
|
||||||
:class="buttonClasses"
|
|
||||||
:disabled="disabled"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
|
<VIcon v-if="iconLeft && !iconOnly" :name="iconLeft" :size="iconSize" class="mr-1" />
|
||||||
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
|
<VIcon v-if="iconOnly && (iconLeft || iconRight)" :name="iconNameForIconOnly" :size="iconSize" />
|
||||||
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
|
<span v-if="!iconOnly" :class="{ 'sr-only': iconOnly && (iconLeft || iconRight) }">
|
||||||
@ -15,10 +10,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, PropType } from 'vue';
|
import { defineComponent, computed, type PropType } from 'vue';
|
||||||
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
|
import VIcon from './VIcon.vue'; // Assuming VIcon.vue is in the same directory
|
||||||
|
|
||||||
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger';
|
type ButtonVariant = 'primary' | 'secondary' | 'neutral' | 'danger' | 'success';
|
||||||
type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially
|
type ButtonSize = 'sm' | 'md' | 'lg'; // Added 'lg' for consistency, though not in SCSS class list initially
|
||||||
type ButtonType = 'button' | 'submit' | 'reset';
|
type ButtonType = 'button' | 'submit' | 'reset';
|
||||||
|
|
||||||
@ -35,7 +30,7 @@ export default defineComponent({
|
|||||||
variant: {
|
variant: {
|
||||||
type: String as PropType<ButtonVariant>,
|
type: String as PropType<ButtonVariant>,
|
||||||
default: 'primary',
|
default: 'primary',
|
||||||
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger'].includes(value),
|
validator: (value: string) => ['primary', 'secondary', 'neutral', 'danger', 'success'].includes(value),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
type: String as PropType<ButtonSize>,
|
type: String as PropType<ButtonSize>,
|
||||||
@ -162,6 +157,12 @@ export default defineComponent({
|
|||||||
border-color: #dc3545;
|
border-color: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: #28a745; // Example success color
|
||||||
|
color: white;
|
||||||
|
border-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
// Sizes
|
// Sizes
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
padding: 0.25em 0.5em;
|
padding: 0.25em 0.5em;
|
||||||
@ -180,9 +181,18 @@ export default defineComponent({
|
|||||||
// Icon only
|
// Icon only
|
||||||
.btn-icon-only {
|
.btn-icon-only {
|
||||||
padding: 0.5em; // Adjust padding for icon-only buttons
|
padding: 0.5em; // Adjust padding for icon-only buttons
|
||||||
|
|
||||||
// Ensure VIcon fills the space or adjust VIcon size if needed
|
// Ensure VIcon fills the space or adjust VIcon size if needed
|
||||||
& .mr-1 { margin-right: 0 !important; } // Remove margin if accidentally applied
|
& .mr-1 {
|
||||||
& .ml-1 { margin-left: 0 !important; } // Remove margin if accidentally applied
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove margin if accidentally applied
|
||||||
|
& .ml-1 {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove margin if accidentally applied
|
||||||
}
|
}
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
@ -201,6 +211,7 @@ export default defineComponent({
|
|||||||
.mr-1 {
|
.mr-1 {
|
||||||
margin-right: 0.25em;
|
margin-right: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ml-1 {
|
.ml-1 {
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
|
|||||||
// Auth
|
// Auth
|
||||||
AUTH: {
|
AUTH: {
|
||||||
LOGIN: '/auth/jwt/login',
|
LOGIN: '/auth/jwt/login',
|
||||||
|
GUEST: '/auth/guest',
|
||||||
SIGNUP: '/auth/register',
|
SIGNUP: '/auth/register',
|
||||||
LOGOUT: '/auth/jwt/logout',
|
LOGOUT: '/auth/jwt/logout',
|
||||||
REFRESH: '/auth/jwt/refresh',
|
REFRESH: '/auth/jwt/refresh',
|
||||||
@ -21,11 +22,11 @@ export const API_ENDPOINTS = {
|
|||||||
USERS: {
|
USERS: {
|
||||||
PROFILE: '/users/me',
|
PROFILE: '/users/me',
|
||||||
UPDATE_PROFILE: '/users/me',
|
UPDATE_PROFILE: '/users/me',
|
||||||
PASSWORD: '/api/v1/users/password',
|
PASSWORD: '/users/password',
|
||||||
AVATAR: '/api/v1/users/avatar',
|
AVATAR: '/users/avatar',
|
||||||
SETTINGS: '/api/v1/users/settings',
|
SETTINGS: '/users/settings',
|
||||||
NOTIFICATIONS: '/api/v1/users/notifications',
|
NOTIFICATIONS: '/users/notifications',
|
||||||
PREFERENCES: '/api/v1/users/preferences',
|
PREFERENCES: '/users/preferences',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
@ -42,10 +43,16 @@ export const API_ENDPOINTS = {
|
|||||||
COMPLETE: (listId: string) => `/lists/${listId}/complete`,
|
COMPLETE: (listId: string) => `/lists/${listId}/complete`,
|
||||||
REOPEN: (listId: string) => `/lists/${listId}/reopen`,
|
REOPEN: (listId: string) => `/lists/${listId}/reopen`,
|
||||||
ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
|
ARCHIVE: (listId: string) => `/lists/${listId}/archive`,
|
||||||
RESTORE: (listId: string) => `/lists/${listId}/restore`,
|
UNARCHIVE: (listId: string) => `/lists/${listId}/unarchive`,
|
||||||
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
|
DUPLICATE: (listId: string) => `/lists/${listId}/duplicate`,
|
||||||
EXPORT: (listId: string) => `/lists/${listId}/export`,
|
EXPORT: (listId: string) => `/lists/${listId}/export`,
|
||||||
IMPORT: '/lists/import',
|
IMPORT: '/lists/import',
|
||||||
|
ARCHIVED: '/lists/archived',
|
||||||
|
},
|
||||||
|
|
||||||
|
CATEGORIES: {
|
||||||
|
BASE: '/categories',
|
||||||
|
BY_ID: (id: string) => `/categories/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
@ -129,5 +136,7 @@ export const API_ENDPOINTS = {
|
|||||||
HISTORY: (id: number) => `/chores/${id}/history`,
|
HISTORY: (id: number) => `/chores/${id}/history`,
|
||||||
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
|
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
|
||||||
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
|
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
|
||||||
|
TIME_ENTRIES: (assignmentId: number) => `/chores/assignments/${assignmentId}/time-entries`,
|
||||||
|
TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -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="material-icons">task_alt</span>
|
||||||
<span class="tab-text">Chores</span>
|
<span class="tab-text">Chores</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link to="/expenses" class="tab-item" active-class="active">
|
||||||
|
<span class="material-icons">payments</span>
|
||||||
|
<span class="tab-text">Expenses</span>
|
||||||
|
</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
@ -15,7 +15,9 @@
|
|||||||
<form v-else @submit.prevent="onSubmitProfile">
|
<form v-else @submit.prevent="onSubmitProfile">
|
||||||
<!-- Profile Section -->
|
<!-- Profile Section -->
|
||||||
<VCard class="mb-3">
|
<VCard class="mb-3">
|
||||||
<template #header><VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading></template>
|
<template #header>
|
||||||
|
<VHeading level="3">{{ $t('accountPage.profileSection.header') }}</VHeading>
|
||||||
|
</template>
|
||||||
<div class="flex flex-wrap" style="gap: 1rem;">
|
<div class="flex flex-wrap" style="gap: 1rem;">
|
||||||
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
|
<VFormField :label="$t('accountPage.profileSection.nameLabel')" class="flex-grow">
|
||||||
<VInput id="profileName" v-model="profile.name" required />
|
<VInput id="profileName" v-model="profile.name" required />
|
||||||
@ -35,7 +37,9 @@
|
|||||||
<!-- Password Section -->
|
<!-- Password Section -->
|
||||||
<form @submit.prevent="onChangePassword">
|
<form @submit.prevent="onChangePassword">
|
||||||
<VCard class="mb-3">
|
<VCard class="mb-3">
|
||||||
<template #header><VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading></template>
|
<template #header>
|
||||||
|
<VHeading level="3">{{ $t('accountPage.passwordSection.header') }}</VHeading>
|
||||||
|
</template>
|
||||||
<div class="flex flex-wrap" style="gap: 1rem;">
|
<div class="flex flex-wrap" style="gap: 1rem;">
|
||||||
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
|
<VFormField :label="$t('accountPage.passwordSection.currentPasswordLabel')" class="flex-grow">
|
||||||
<VInput type="password" id="currentPassword" v-model="password.current" required />
|
<VInput type="password" id="currentPassword" v-model="password.current" required />
|
||||||
@ -54,28 +58,33 @@
|
|||||||
|
|
||||||
<!-- Notifications Section -->
|
<!-- Notifications Section -->
|
||||||
<VCard>
|
<VCard>
|
||||||
<template #header><VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading></template>
|
<template #header>
|
||||||
|
<VHeading level="3">{{ $t('accountPage.notificationsSection.header') }}</VHeading>
|
||||||
|
</template>
|
||||||
<VList class="preference-list">
|
<VList class="preference-list">
|
||||||
<VListItem class="preference-item">
|
<VListItem class="preference-item">
|
||||||
<div class="preference-label">
|
<div class="preference-label">
|
||||||
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
|
<span>{{ $t('accountPage.notificationsSection.emailNotificationsLabel') }}</span>
|
||||||
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
|
<small>{{ $t('accountPage.notificationsSection.emailNotificationsDescription') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
|
<VToggleSwitch v-model="preferences.emailNotifications" @change="onPreferenceChange"
|
||||||
|
:label="$t('accountPage.notificationsSection.emailNotificationsLabel')" id="emailNotificationsToggle" />
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem class="preference-item">
|
<VListItem class="preference-item">
|
||||||
<div class="preference-label">
|
<div class="preference-label">
|
||||||
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
|
<span>{{ $t('accountPage.notificationsSection.listUpdatesLabel') }}</span>
|
||||||
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
|
<small>{{ $t('accountPage.notificationsSection.listUpdatesDescription') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle"/>
|
<VToggleSwitch v-model="preferences.listUpdates" @change="onPreferenceChange"
|
||||||
|
:label="$t('accountPage.notificationsSection.listUpdatesLabel')" id="listUpdatesToggle" />
|
||||||
</VListItem>
|
</VListItem>
|
||||||
<VListItem class="preference-item">
|
<VListItem class="preference-item">
|
||||||
<div class="preference-label">
|
<div class="preference-label">
|
||||||
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
|
<span>{{ $t('accountPage.notificationsSection.groupActivitiesLabel') }}</span>
|
||||||
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
|
<small>{{ $t('accountPage.notificationsSection.groupActivitiesDescription') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange" :label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle"/>
|
<VToggleSwitch v-model="preferences.groupActivities" @change="onPreferenceChange"
|
||||||
|
:label="$t('accountPage.notificationsSection.groupActivitiesLabel')" id="groupActivitiesToggle" />
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VCard>
|
</VCard>
|
||||||
@ -83,9 +92,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Assuming path
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import VHeading from '@/components/valerie/VHeading.vue';
|
import VHeading from '@/components/valerie/VHeading.vue';
|
||||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||||||
@ -130,6 +140,8 @@ const preferences = ref<Preferences>({
|
|||||||
groupActivities: true,
|
groupActivities: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
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 { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
|
||||||
import { choreService } from '../services/choreService'
|
import { choreService } from '../services/choreService'
|
||||||
import { useNotificationStore } from '../stores/notifications'
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory } from '../types/chore'
|
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory, ChoreWithCompletion } from '../types/chore'
|
||||||
import { groupService } from '../services/groupService'
|
import { groupService } from '../services/groupService'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import ChoreItem from '@/components/ChoreItem.vue';
|
||||||
|
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{ groupId?: number | string }>();
|
const props = defineProps<{ groupId?: number | string }>();
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface ChoreWithCompletion extends Chore {
|
// ChoreWithCompletion is now imported from ../types/chore
|
||||||
current_assignment_id: number | null;
|
|
||||||
is_completed: boolean;
|
|
||||||
completed_at: string | null;
|
|
||||||
updating: boolean;
|
|
||||||
assigned_user_name?: string;
|
|
||||||
completed_by_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChoreFormData {
|
interface ChoreFormData {
|
||||||
name: string;
|
name: string;
|
||||||
@ -30,6 +27,7 @@ interface ChoreFormData {
|
|||||||
next_due_date: string;
|
next_due_date: string;
|
||||||
type: 'personal' | 'group';
|
type: 'personal' | 'group';
|
||||||
group_id: number | undefined;
|
group_id: number | undefined;
|
||||||
|
parent_chore_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationStore = useNotificationStore()
|
const notificationStore = useNotificationStore()
|
||||||
@ -60,11 +58,26 @@ const initialChoreFormState: ChoreFormData = {
|
|||||||
next_due_date: format(new Date(), 'yyyy-MM-dd'),
|
next_due_date: format(new Date(), 'yyyy-MM-dd'),
|
||||||
type: 'personal',
|
type: 'personal',
|
||||||
group_id: undefined,
|
group_id: undefined,
|
||||||
|
parent_chore_id: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const choreForm = ref({ ...initialChoreFormState })
|
const choreForm = ref({ ...initialChoreFormState })
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const { isGuest } = storeToRefs(authStore);
|
||||||
|
|
||||||
|
const timeEntryStore = useTimeEntryStore();
|
||||||
|
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
|
||||||
|
|
||||||
|
const activeTimer = computed(() => {
|
||||||
|
for (const assignmentId in timeEntries.value) {
|
||||||
|
const entry = timeEntries.value[assignmentId].find(te => !te.end_time);
|
||||||
|
if (entry) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const loadChores = async () => {
|
const loadChores = async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
|
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
|
||||||
@ -108,8 +121,16 @@ const loadGroups = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadTimeEntries = async () => {
|
||||||
|
chores.value.forEach(chore => {
|
||||||
|
if (chore.current_assignment_id) {
|
||||||
|
timeEntryStore.fetchTimeEntriesForAssignment(chore.current_assignment_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadChores()
|
loadChores().then(loadTimeEntries);
|
||||||
loadGroups()
|
loadGroups()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -173,17 +194,50 @@ const filteredChores = computed(() => {
|
|||||||
return chores.value;
|
return chores.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedChores = computed(() => {
|
const availableParentChores = computed(() => {
|
||||||
if (!filteredChores.value) return []
|
return chores.value.filter(c => {
|
||||||
|
// A chore cannot be its own parent
|
||||||
const choresByDate = filteredChores.value.reduce((acc, chore) => {
|
if (isEditing.value && selectedChore.value && c.id === selectedChore.value.id) {
|
||||||
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
|
return false;
|
||||||
if (!acc[dueDate]) {
|
|
||||||
acc[dueDate] = []
|
|
||||||
}
|
}
|
||||||
acc[dueDate].push(chore)
|
// A chore that is already a subtask cannot be a parent
|
||||||
return acc
|
if (c.parent_chore_id) {
|
||||||
}, {} as Record<string, ChoreWithCompletion[]>)
|
return false;
|
||||||
|
}
|
||||||
|
// If a group is selected, only show chores from that group or personal chores
|
||||||
|
if (choreForm.value.group_id) {
|
||||||
|
return c.group_id === choreForm.value.group_id || c.type === 'personal';
|
||||||
|
}
|
||||||
|
// If no group is selected, only show personal chores that are not in a group
|
||||||
|
return c.type === 'personal' && !c.group_id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedChores = computed(() => {
|
||||||
|
if (!filteredChores.value) return [];
|
||||||
|
|
||||||
|
const choreMap = new Map<number, ChoreWithCompletion>();
|
||||||
|
filteredChores.value.forEach(chore => {
|
||||||
|
choreMap.set(chore.id, { ...chore, child_chores: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootChores: ChoreWithCompletion[] = [];
|
||||||
|
choreMap.forEach(chore => {
|
||||||
|
if (chore.parent_chore_id && choreMap.has(chore.parent_chore_id)) {
|
||||||
|
choreMap.get(chore.parent_chore_id)?.child_chores?.push(chore);
|
||||||
|
} else {
|
||||||
|
rootChores.push(chore);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const choresByDate = rootChores.reduce((acc, chore) => {
|
||||||
|
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd');
|
||||||
|
if (!acc[dueDate]) {
|
||||||
|
acc[dueDate] = [];
|
||||||
|
}
|
||||||
|
acc[dueDate].push(chore);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, ChoreWithCompletion[]>);
|
||||||
|
|
||||||
return Object.keys(choresByDate)
|
return Object.keys(choresByDate)
|
||||||
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
||||||
@ -198,7 +252,7 @@ const groupedChores = computed(() => {
|
|||||||
...chore,
|
...chore,
|
||||||
subtext: getChoreSubtext(chore)
|
subtext: getChoreSubtext(chore)
|
||||||
}))
|
}))
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -238,6 +292,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
|
|||||||
next_due_date: chore.next_due_date,
|
next_due_date: chore.next_due_date,
|
||||||
type: chore.type,
|
type: chore.type,
|
||||||
group_id: chore.group_id ?? undefined,
|
group_id: chore.group_id ?? undefined,
|
||||||
|
parent_chore_id: chore.parent_chore_id,
|
||||||
}
|
}
|
||||||
showChoreModal.value = true
|
showChoreModal.value = true
|
||||||
}
|
}
|
||||||
@ -412,10 +467,29 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
if (isEqual(dueDate, today)) return 'due-today';
|
if (isEqual(dueDate, today)) return 'due-today';
|
||||||
return 'upcoming';
|
return 'upcoming';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startTimer = async (chore: ChoreWithCompletion) => {
|
||||||
|
if (chore.current_assignment_id) {
|
||||||
|
await timeEntryStore.startTimeEntry(chore.current_assignment_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||||
|
if (chore.current_assignment_id) {
|
||||||
|
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div v-if="isGuest" class="guest-banner">
|
||||||
|
<p>
|
||||||
|
You are using a guest account.
|
||||||
|
<router-link to="/auth/signup">Sign up</router-link>
|
||||||
|
to save your data permanently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<header v-if="!props.groupId" class="flex justify-between items-center">
|
<header v-if="!props.groupId" class="flex justify-between items-center">
|
||||||
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
|
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
|
||||||
<button class="btn btn-primary" @click="openCreateChoreModal">
|
<button class="btn btn-primary" @click="openCreateChoreModal">
|
||||||
@ -444,44 +518,11 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
|
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
|
||||||
<div class="neo-item-list-container">
|
<div class="neo-item-list-container">
|
||||||
<ul class="neo-item-list">
|
<ul class="neo-item-list">
|
||||||
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item"
|
<ChoreItem v-for="chore in group.chores" :key="chore.id" :chore="chore"
|
||||||
:class="`status-${getDueDateStatus(chore)}`">
|
:time-entries="chore.current_assignment_id ? timeEntries[chore.current_assignment_id] || [] : []"
|
||||||
<div class="neo-item-content">
|
:active-timer="activeTimer" @toggle-completion="toggleCompletion" @edit="openEditChoreModal"
|
||||||
<label class="neo-checkbox-label">
|
@delete="confirmDelete" @open-details="openChoreDetailModal" @open-history="openHistoryModal"
|
||||||
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
|
@start-timer="startTimer" @stop-timer="stopTimer" />
|
||||||
<div class="checkbox-content">
|
|
||||||
<div class="chore-main-info">
|
|
||||||
<span class="checkbox-text-span"
|
|
||||||
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
|
|
||||||
{{ chore.name }}
|
|
||||||
</span>
|
|
||||||
<div class="chore-badges">
|
|
||||||
<span v-if="chore.type === 'group'" class="badge badge-group">Group</span>
|
|
||||||
<span v-if="getDueDateStatus(chore) === 'overdue'" class="badge badge-overdue">Overdue</span>
|
|
||||||
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
|
|
||||||
Today</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
|
|
||||||
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<div class="neo-item-actions">
|
|
||||||
<button class="btn btn-sm btn-neutral" @click="openChoreDetailModal(chore)" title="View Details">
|
|
||||||
📋
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-neutral" @click="openHistoryModal(chore)" title="View History">
|
|
||||||
📅
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
|
|
||||||
{{ t('choresPage.edit', 'Edit') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger" @click="confirmDelete(chore)">
|
|
||||||
{{ t('choresPage.delete', 'Delete') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -523,7 +564,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
||||||
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
|
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
|
||||||
}}</label>
|
}}</label>
|
||||||
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
|
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
|
||||||
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||||
</div>
|
</div>
|
||||||
@ -544,16 +585,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="choreForm.type === 'group'" class="form-group">
|
<div v-if="choreForm.type === 'group'" class="form-group">
|
||||||
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
|
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
|
||||||
}}</label>
|
}}</label>
|
||||||
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
|
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
|
||||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
|
||||||
|
}}</label>
|
||||||
|
<select id="parent-chore" v-model="choreForm.parent_chore_id" class="form-input">
|
||||||
|
<option :value="null">None</option>
|
||||||
|
<option v-for="parent in availableParentChores" :key="parent.id" :value="parent.id">
|
||||||
|
{{ parent.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
||||||
t('choresPage.form.cancel', 'Cancel')
|
t('choresPage.form.cancel', 'Cancel')
|
||||||
}}</button>
|
}}</button>
|
||||||
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
||||||
t('choresPage.form.create', 'Create') }}</button>
|
t('choresPage.form.create', 'Create') }}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -578,7 +629,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
||||||
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
||||||
t('choresPage.deleteConfirm.delete', 'Delete')
|
t('choresPage.deleteConfirm.delete', 'Delete')
|
||||||
}}</button>
|
}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -603,7 +654,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="label">Created by:</span>
|
<span class="label">Created by:</span>
|
||||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="label">Due date:</span>
|
<span class="label">Due date:</span>
|
||||||
@ -635,7 +686,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
|
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
|
||||||
<div class="assignment-main">
|
<div class="assignment-main">
|
||||||
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
||||||
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
||||||
</span>
|
</span>
|
||||||
@ -693,6 +744,26 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.guest-banner {
|
||||||
|
background-color: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid #fBBF24;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-banner p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-banner a {
|
||||||
|
color: #92400e;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-group {
|
.schedule-group {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
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>
|
<template #footer>
|
||||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||||
t('groupDetailPage.settleShareModal.cancelButton')
|
t('groupDetailPage.settleShareModal.cancelButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
|
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
|
||||||
t('groupDetailPage.settleShareModal.confirmButton')
|
t('groupDetailPage.settleShareModal.confirmButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
|
|
||||||
@ -242,7 +242,7 @@
|
|||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<span class="label">Created by:</span>
|
<span class="label">Created by:</span>
|
||||||
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-item">
|
<div class="meta-item">
|
||||||
<span class="label">Created:</span>
|
<span class="label">Created:</span>
|
||||||
@ -383,7 +383,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, reactive } from 'vue';
|
import { ref, onMounted, computed, reactive } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import { useClipboard, useStorage } from '@vueuse/core';
|
import { useClipboard, useStorage } from '@vueuse/core';
|
||||||
import ListsPage from './ListsPage.vue';
|
import ListsPage from './ListsPage.vue';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
|
@ -122,7 +122,7 @@
|
|||||||
import { ref, onMounted, nextTick, watch } from 'vue';
|
import { ref, onMounted, nextTick, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
|
@ -48,112 +48,127 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="list.description" class="neo-description-internal">{{ list.description }}</p>
|
<p v-if="list.description" class="neo-description-internal">{{ list.description }}</p>
|
||||||
|
<div class="supermarkt-mode-toggle">
|
||||||
|
<label>
|
||||||
|
Supermarkt Mode
|
||||||
|
<VToggleSwitch v-model="supermarktMode" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<VProgressBar v-if="supermarktMode" :value="itemCompletionProgress" class="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
<!-- End Integrated Header -->
|
<!-- End Integrated Header -->
|
||||||
|
|
||||||
<draggable v-model="list.items" item-key="id" handle=".drag-handle" @end="handleDragEnd" :disabled="!isOnline"
|
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
|
||||||
class="neo-item-list">
|
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
|
||||||
<template #item="{ element: item }">
|
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
|
||||||
<li class="neo-list-item"
|
<draggable v-model="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
|
||||||
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
|
:disabled="!isOnline" class="neo-item-list">
|
||||||
<div class="neo-item-content">
|
<template #item="{ element: item }">
|
||||||
<!-- Drag Handle -->
|
<li class="neo-list-item"
|
||||||
<div class="drag-handle" v-if="isOnline">
|
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
<div class="neo-item-content">
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<!-- Drag Handle -->
|
||||||
<circle cx="9" cy="12" r="1"></circle>
|
<div class="drag-handle" v-if="isOnline">
|
||||||
<circle cx="9" cy="5" r="1"></circle>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
<circle cx="9" cy="19" r="1"></circle>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="15" cy="12" r="1"></circle>
|
<circle cx="9" cy="12" r="1"></circle>
|
||||||
<circle cx="15" cy="5" r="1"></circle>
|
<circle cx="9" cy="5" r="1"></circle>
|
||||||
<circle cx="15" cy="19" r="1"></circle>
|
<circle cx="9" cy="19" r="1"></circle>
|
||||||
</svg>
|
<circle cx="15" cy="12" r="1"></circle>
|
||||||
</div>
|
<circle cx="15" cy="5" r="1"></circle>
|
||||||
<!-- Content when NOT editing -->
|
<circle cx="15" cy="19" r="1"></circle>
|
||||||
<template v-if="!item.isEditing">
|
</svg>
|
||||||
<label class="neo-checkbox-label" @click.stop>
|
</div>
|
||||||
<input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" />
|
<!-- Content when NOT editing -->
|
||||||
<div class="checkbox-content">
|
<template v-if="!item.isEditing">
|
||||||
<span class="checkbox-text-span"
|
<label class="neo-checkbox-label" @click.stop>
|
||||||
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
|
<input type="checkbox" :checked="item.is_complete" @change="handleCheckboxChange(item, $event)" />
|
||||||
{{ item.name }}
|
<div class="checkbox-content">
|
||||||
</span>
|
<span class="checkbox-text-span"
|
||||||
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
|
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
|
||||||
<div v-if="item.is_complete" class="neo-price-input">
|
{{ item.name }}
|
||||||
<VInput type="number" :model-value="item.priceInput || ''"
|
</span>
|
||||||
@update:modelValue="item.priceInput = $event"
|
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
|
||||||
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24" step="0.01"
|
<div v-if="item.is_complete" class="neo-price-input">
|
||||||
@blur="updateItemPrice(item)"
|
<VInput type="number" :model-value="item.priceInput || ''"
|
||||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
@update:modelValue="item.priceInput = $event"
|
||||||
|
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
|
||||||
|
step="0.01" @blur="updateItemPrice(item)"
|
||||||
|
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</label>
|
||||||
|
<div class="neo-item-actions">
|
||||||
|
<button class="neo-icon-button neo-edit-button" @click.stop="startItemEdit(item)"
|
||||||
|
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
|
||||||
|
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"></path>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
||||||
|
</path>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</template>
|
||||||
<div class="neo-item-actions">
|
<!-- Content WHEN editing -->
|
||||||
<button class="neo-icon-button neo-edit-button" @click.stop="startItemEdit(item)"
|
<template v-else>
|
||||||
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
|
<div class="inline-edit-form flex-grow flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
<VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
@keydown.esc.prevent="cancelItemEdit(item)" />
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
<VInput type="number" :model-value="item.editQuantity || ''"
|
||||||
</svg>
|
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
|
||||||
</button>
|
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
|
||||||
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
|
<VSelect :model-value="item.editCategoryId" @update:modelValue="item.editCategoryId = $event"
|
||||||
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
|
:options="categoryOptions" placeholder="Category" class="w-40" size="sm" />
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
</div>
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<div class="neo-item-actions">
|
||||||
<path d="M3 6h18"></path>
|
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)"
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
:aria-label="$t('listDetailPage.buttons.saveChanges')">
|
||||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</svg>
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||||
</button>
|
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||||
</div>
|
<polyline points="7 3 7 8 15 8"></polyline>
|
||||||
</template>
|
</svg>
|
||||||
<!-- Content WHEN editing -->
|
</button>
|
||||||
<template v-else>
|
<button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)"
|
||||||
<div class="inline-edit-form flex-grow flex items-center gap-2">
|
:aria-label="$t('listDetailPage.buttons.cancel')">
|
||||||
<VInput type="text" :model-value="item.editName ?? ''" @update:modelValue="item.editName = $event"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@keydown.esc.prevent="cancelItemEdit(item)" />
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
<VInput type="number" :model-value="item.editQuantity || ''"
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||||
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||||
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
<div class="neo-item-actions">
|
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
|
||||||
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(item)"
|
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
|
||||||
:aria-label="$t('listDetailPage.buttons.saveChanges')">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<path d="M3 6h18"></path>
|
||||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
||||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
</path>
|
||||||
<polyline points="7 3 7 8 15 8"></polyline>
|
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||||
</svg>
|
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||||
</button>
|
</svg>
|
||||||
<button class="neo-icon-button neo-cancel-button" @click.stop="cancelItemEdit(item)"
|
</button>
|
||||||
:aria-label="$t('listDetailPage.buttons.cancel')">
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
</template>
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
</div>
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
</li>
|
||||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
</template>
|
||||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
</draggable>
|
||||||
</svg>
|
</div>
|
||||||
</button>
|
|
||||||
<button class="neo-icon-button neo-delete-button" @click.stop="deleteItem(item)"
|
|
||||||
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 6h18"></path>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
||||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
||||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
|
|
||||||
<!-- New Add Item LI, integrated into the list -->
|
<!-- New Add Item LI, integrated into the list -->
|
||||||
<li class="neo-list-item new-item-input-container">
|
<li class="neo-list-item new-item-input-container">
|
||||||
@ -163,6 +178,8 @@
|
|||||||
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
|
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
|
||||||
:data-list-id="list?.id" @keyup.enter="onAddItem" @blur="handleNewItemBlur" v-model="newItem.name"
|
:data-list-id="list?.id" @keyup.enter="onAddItem" @blur="handleNewItemBlur" v-model="newItem.name"
|
||||||
@click.stop />
|
@click.stop />
|
||||||
|
<VSelect v-model="newItem.category_id" :options="categoryOptions" placeholder="Category" class="w-40"
|
||||||
|
size="sm" />
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@ -379,10 +396,10 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||||
$t('listDetailPage.modals.settleShare.cancelButton')
|
$t('listDetailPage.modals.settleShare.cancelButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
<VButton variant="primary" @click="handleConfirmSettle">{{
|
<VButton variant="primary" @click="handleConfirmSettle">{{
|
||||||
$t('listDetailPage.modals.settleShare.confirmButton')
|
$t('listDetailPage.modals.settleShare.confirmButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
|
|
||||||
@ -393,9 +410,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
|
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
|
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
||||||
@ -421,7 +438,12 @@ import VInput from '@/components/valerie/VInput.vue';
|
|||||||
import VList from '@/components/valerie/VList.vue';
|
import VList from '@/components/valerie/VList.vue';
|
||||||
import VListItem from '@/components/valerie/VListItem.vue';
|
import VListItem from '@/components/valerie/VListItem.vue';
|
||||||
import VCheckbox from '@/components/valerie/VCheckbox.vue';
|
import VCheckbox from '@/components/valerie/VCheckbox.vue';
|
||||||
|
import VProgressBar from '@/components/valerie/VProgressBar.vue';
|
||||||
|
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
|
||||||
import draggable from 'vuedraggable';
|
import draggable from 'vuedraggable';
|
||||||
|
import { useCategoryStore } from '@/stores/categoryStore';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import ExpenseCard from '@/components/ExpenseCard.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@ -469,6 +491,7 @@ interface ItemWithUI extends Item {
|
|||||||
isEditing?: boolean; // For inline editing state
|
isEditing?: boolean; // For inline editing state
|
||||||
editName?: string; // Temporary name for inline editing
|
editName?: string; // Temporary name for inline editing
|
||||||
editQuantity?: number | string | null; // Temporary quantity for inline editing
|
editQuantity?: number | string | null; // Temporary quantity for inline editing
|
||||||
|
editCategoryId?: number | null; // Temporary category for inline editing
|
||||||
showFirework?: boolean; // For firework animation
|
showFirework?: boolean; // For firework animation
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -523,9 +546,21 @@ const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
|||||||
const lastListUpdate = ref<string | null>(null);
|
const lastListUpdate = ref<string | null>(null);
|
||||||
const lastItemCount = ref<number | null>(null);
|
const lastItemCount = ref<number | null>(null);
|
||||||
|
|
||||||
const newItem = ref<{ name: string; quantity?: number | string }>({ name: '' });
|
const supermarktMode = ref(false);
|
||||||
|
|
||||||
|
const categoryStore = useCategoryStore();
|
||||||
|
const { categories } = storeToRefs(categoryStore);
|
||||||
|
|
||||||
|
const newItem = ref<{ name: string; quantity?: number | string; category_id?: number | null }>({ name: '', category_id: null });
|
||||||
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
|
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
|
||||||
|
|
||||||
|
const categoryOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: 'No Category', value: null },
|
||||||
|
...categories.value.map(c => ({ label: c.name, value: c.id })),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
// OCR
|
// OCR
|
||||||
const showOcrDialogState = ref(false);
|
const showOcrDialogState = ref(false);
|
||||||
// const ocrModalRef = ref<HTMLElement | null>(null); // Removed
|
// const ocrModalRef = ref<HTMLElement | null>(null); // Removed
|
||||||
@ -547,6 +582,12 @@ const listCostSummary = ref<ListCostSummaryData | null>(null);
|
|||||||
const costSummaryLoading = ref(false);
|
const costSummaryLoading = ref(false);
|
||||||
const costSummaryError = ref<string | null>(null);
|
const costSummaryError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const itemCompletionProgress = computed(() => {
|
||||||
|
if (!list.value?.items.length) return 0;
|
||||||
|
const completedCount = list.value.items.filter(i => i.is_complete).length;
|
||||||
|
return (completedCount / list.value.items.length) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
// Settle Share
|
// Settle Share
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const showSettleModal = ref(false);
|
const showSettleModal = ref(false);
|
||||||
@ -703,6 +744,7 @@ const onAddItem = async () => {
|
|||||||
is_complete: false,
|
is_complete: false,
|
||||||
price: null,
|
price: null,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
category_id: newItem.value.category_id,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
list_id: list.value.id,
|
list_id: list.value.id,
|
||||||
@ -715,6 +757,7 @@ const onAddItem = async () => {
|
|||||||
list.value.items.push(optimisticItem);
|
list.value.items.push(optimisticItem);
|
||||||
|
|
||||||
newItem.value.name = '';
|
newItem.value.name = '';
|
||||||
|
newItem.value.category_id = null;
|
||||||
if (itemNameInputRef.value?.$el) {
|
if (itemNameInputRef.value?.$el) {
|
||||||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||||||
}
|
}
|
||||||
@ -733,6 +776,9 @@ const onAddItem = async () => {
|
|||||||
offlinePayload.quantity = String(rawQuantity);
|
offlinePayload.quantity = String(rawQuantity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (newItem.value.category_id) {
|
||||||
|
offlinePayload.category_id = newItem.value.category_id;
|
||||||
|
}
|
||||||
|
|
||||||
offlineStore.addAction({
|
offlineStore.addAction({
|
||||||
type: 'create_list_item',
|
type: 'create_list_item',
|
||||||
@ -752,7 +798,8 @@ const onAddItem = async () => {
|
|||||||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||||||
{
|
{
|
||||||
name: itemName,
|
name: itemName,
|
||||||
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null
|
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null,
|
||||||
|
category_id: newItem.value.category_id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1105,6 +1152,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch categories relevant to the list (either personal or group)
|
||||||
|
categoryStore.fetchCategories(list.value?.group_id);
|
||||||
|
|
||||||
fetchListDetails().then(() => {
|
fetchListDetails().then(() => {
|
||||||
startPolling();
|
startPolling();
|
||||||
});
|
});
|
||||||
@ -1121,6 +1171,7 @@ const startItemEdit = (item: ItemWithUI) => {
|
|||||||
item.isEditing = true;
|
item.isEditing = true;
|
||||||
item.editName = item.name;
|
item.editName = item.name;
|
||||||
item.editQuantity = item.quantity ?? '';
|
item.editQuantity = item.quantity ?? '';
|
||||||
|
item.editCategoryId = item.category_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelItemEdit = (item: ItemWithUI) => {
|
const cancelItemEdit = (item: ItemWithUI) => {
|
||||||
@ -1140,6 +1191,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
|
|||||||
name: String(item.editName).trim(),
|
name: String(item.editName).trim(),
|
||||||
quantity: item.editQuantity ? String(item.editQuantity) : null,
|
quantity: item.editQuantity ? String(item.editQuantity) : null,
|
||||||
version: item.version,
|
version: item.version,
|
||||||
|
category_id: item.editCategoryId,
|
||||||
};
|
};
|
||||||
|
|
||||||
item.updating = true;
|
item.updating = true;
|
||||||
@ -1157,6 +1209,7 @@ const saveItemEdit = async (item: ItemWithUI) => {
|
|||||||
item.is_complete = updatedItemFromApi.is_complete;
|
item.is_complete = updatedItemFromApi.is_complete;
|
||||||
item.price = updatedItemFromApi.price;
|
item.price = updatedItemFromApi.price;
|
||||||
item.updated_at = updatedItemFromApi.updated_at;
|
item.updated_at = updatedItemFromApi.updated_at;
|
||||||
|
item.category_id = updatedItemFromApi.category_id;
|
||||||
|
|
||||||
item.isEditing = false;
|
item.isEditing = false;
|
||||||
notificationStore.addNotification({
|
notificationStore.addNotification({
|
||||||
@ -1310,6 +1363,24 @@ const isExpenseExpanded = (expenseId: number) => {
|
|||||||
return expandedExpenses.value.has(expenseId);
|
return expandedExpenses.value.has(expenseId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const groupedItems = computed(() => {
|
||||||
|
if (!list.value?.items) return [];
|
||||||
|
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
|
||||||
|
|
||||||
|
list.value.items.forEach(item => {
|
||||||
|
const categoryId = item.category_id;
|
||||||
|
const category = categories.value.find(c => c.id === categoryId);
|
||||||
|
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
|
||||||
|
|
||||||
|
if (!groups[categoryName]) {
|
||||||
|
groups[categoryName] = { categoryName, items: [] };
|
||||||
|
}
|
||||||
|
groups[categoryName].items.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups);
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -1999,4 +2070,18 @@ const isExpenseExpanded = (expenseId: number) => {
|
|||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-group.highlight .neo-list-item:not(.is-complete) {
|
||||||
|
background-color: #e6f7ff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<main class="container page-padding">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h1 class="text-2xl font-bold">{{ pageTitle }}</h1>
|
||||||
|
<VToggleSwitch v-model="showArchived" :label="t('listsPage.showArchived')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
|
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
|
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
|
<VCard v-else-if="filteredLists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
|
||||||
:empty-title="t(noListsMessageKey)">
|
:empty-title="t(noListsMessageKey)">
|
||||||
<template #default>
|
<template #default>
|
||||||
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
|
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
|
||||||
@ -19,17 +24,24 @@
|
|||||||
</template>
|
</template>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
||||||
<div v-else-if="loading && lists.length === 0" class="loading-placeholder">
|
<div v-else-if="loading && filteredLists.length === 0" class="loading-placeholder">
|
||||||
{{ t('listsPage.loadingLists') }}
|
{{ t('listsPage.loadingLists') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="neo-lists-grid">
|
<div class="neo-lists-grid">
|
||||||
<div v-for="list in lists" :key="list.id" class="neo-list-card"
|
<div v-for="list in filteredLists" :key="list.id" class="neo-list-card"
|
||||||
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
|
:class="{ 'touch-active': touchActiveListId === list.id, 'archived': list.archived_at }"
|
||||||
@touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd"
|
@click="navigateToList(list.id)" @touchstart.passive="handleTouchStart(list.id)"
|
||||||
@touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
|
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
|
||||||
<div class="neo-list-header">{{ list.name }}</div>
|
<div class="neo-list-header">
|
||||||
|
<span>{{ list.name }}</span>
|
||||||
|
<div class="actions">
|
||||||
|
<VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm"
|
||||||
|
icon="archive" />
|
||||||
|
<VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
|
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
|
||||||
<ul class="neo-item-list">
|
<ul class="neo-item-list">
|
||||||
<li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
|
<li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
|
||||||
@ -44,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li class="neo-list-item new-item-input-container">
|
<li v-if="!list.archived_at" class="neo-list-item new-item-input-container">
|
||||||
<label class="neo-checkbox-label">
|
<label class="neo-checkbox-label">
|
||||||
<input type="checkbox" disabled />
|
<input type="checkbox" disabled />
|
||||||
<input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')"
|
<input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')"
|
||||||
@ -68,12 +80,13 @@
|
|||||||
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
|
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import CreateListModal from '@/components/CreateListModal.vue';
|
import CreateListModal from '@/components/CreateListModal.vue';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import VAlert from '@/components/valerie/VAlert.vue';
|
import VAlert from '@/components/valerie/VAlert.vue';
|
||||||
import VCard from '@/components/valerie/VCard.vue';
|
import VCard from '@/components/valerie/VCard.vue';
|
||||||
import VButton from '@/components/valerie/VButton.vue';
|
import VButton from '@/components/valerie/VButton.vue';
|
||||||
|
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@ -95,6 +108,7 @@ interface List {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
version: number;
|
version: number;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
|
archived_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
@ -125,6 +139,8 @@ const router = useRouter();
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const lists = ref<(List & { items: Item[] })[]>([]);
|
const lists = ref<(List & { items: Item[] })[]>([]);
|
||||||
|
const archivedLists = ref<List[]>([]);
|
||||||
|
const haveFetchedArchived = ref(false);
|
||||||
const allFetchedGroups = ref<Group[]>([]);
|
const allFetchedGroups = ref<Group[]>([]);
|
||||||
const currentViewedGroup = ref<Group | null>(null);
|
const currentViewedGroup = ref<Group | null>(null);
|
||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
@ -220,6 +236,18 @@ const fetchLists = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchArchivedLists = async () => {
|
||||||
|
if (haveFetchedArchived.value) return;
|
||||||
|
try {
|
||||||
|
const endpoint = API_ENDPOINTS.LISTS.ARCHIVED;
|
||||||
|
const response = await apiClient.get(endpoint);
|
||||||
|
archivedLists.value = response.data as List[];
|
||||||
|
haveFetchedArchived.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch archived lists:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchListsAndGroups = async () => {
|
const fetchListsAndGroups = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@ -494,6 +522,54 @@ const stopPolling = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showArchived = ref(false);
|
||||||
|
|
||||||
|
watch(showArchived, (isShowing) => {
|
||||||
|
if (isShowing) {
|
||||||
|
fetchArchivedLists();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredLists = computed(() => {
|
||||||
|
if (showArchived.value) {
|
||||||
|
const combined = [...lists.value, ...archivedLists.value];
|
||||||
|
const uniqueLists = Array.from(new Map(combined.map(l => [l.id, l])).values());
|
||||||
|
return uniqueLists.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||||
|
}
|
||||||
|
return lists.value.filter(list => !list.archived_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveList = async (list: List) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(API_ENDPOINTS.LISTS.BY_ID(list.id.toString()));
|
||||||
|
list.archived_at = new Date().toISOString();
|
||||||
|
const listIndex = lists.value.findIndex(l => l.id === list.id);
|
||||||
|
if (listIndex > -1) {
|
||||||
|
const [archivedItem] = lists.value.splice(listIndex, 1);
|
||||||
|
archivedLists.value.push(archivedItem);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to archive list', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unarchiveList = async (list: List) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(API_ENDPOINTS.LISTS.UNARCHIVE(list.id.toString()));
|
||||||
|
const unarchivedList = response.data as List;
|
||||||
|
|
||||||
|
const listIndex = archivedLists.value.findIndex(l => l.id === list.id);
|
||||||
|
if (listIndex > -1) {
|
||||||
|
archivedLists.value.splice(listIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
lists.value.push({ ...unarchivedList, items: unarchivedList.items || [] });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unarchive list', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCachedData();
|
loadCachedData();
|
||||||
fetchListsAndGroups().then(() => {
|
fetchListsAndGroups().then(() => {
|
||||||
@ -506,6 +582,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
watch(currentGroupId, () => {
|
watch(currentGroupId, () => {
|
||||||
loadCachedData();
|
loadCachedData();
|
||||||
|
haveFetchedArchived.value = false;
|
||||||
|
archivedLists.value = [];
|
||||||
fetchListsAndGroups().then(() => {
|
fetchListsAndGroups().then(() => {
|
||||||
if (lists.value.length > 0) {
|
if (lists.value.length > 0) {
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
@ -918,4 +996,14 @@ onUnmounted(() => {
|
|||||||
.item-appear {
|
.item-appear {
|
||||||
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archived {
|
||||||
|
opacity: 0.6;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -34,6 +34,12 @@
|
|||||||
{{ t('loginPage.loginButton') }}
|
{{ t('loginPage.loginButton') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="divider my-3">or</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-secondary w-full" @click="handleGuestLogin" :disabled="loading">
|
||||||
|
Continue as Guest
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="text-center mt-2">
|
<div class="text-center mt-2">
|
||||||
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
|
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
@ -103,6 +109,24 @@ const onSubmit = async () => {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGuestLogin = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
formErrors.value.general = undefined;
|
||||||
|
try {
|
||||||
|
await authStore.loginAsGuest();
|
||||||
|
notificationStore.addNotification({ message: 'Welcome, Guest!', type: 'success' });
|
||||||
|
const redirectPath = (route.query.redirect as string) || '/';
|
||||||
|
router.push(redirectPath);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to login as guest.';
|
||||||
|
formErrors.value.general = message;
|
||||||
|
console.error(message, error);
|
||||||
|
notificationStore.addNotification({ message, type: 'error' });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -117,6 +141,30 @@ const onSubmit = async () => {
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.8em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider::before,
|
||||||
|
.divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider:not(:empty)::before {
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider:not(:empty)::after {
|
||||||
|
margin-left: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
.link-styled {
|
.link-styled {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { mount, flushPromises } from '@vue/test-utils';
|
import { mount, flushPromises } from '@vue/test-utils';
|
||||||
import AccountPage from '../AccountPage.vue'; // Adjust path
|
import AccountPage from '../AccountPage.vue'; // Adjust path
|
||||||
import { apiClient, API_ENDPOINTS as MOCK_API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS as MOCK_API_ENDPOINTS } from '@/services/api';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
vi.mock('@/config/api', () => ({
|
vi.mock('@/services/api', () => ({
|
||||||
apiClient: {
|
apiClient: {
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
put: vi.fn(),
|
put: vi.fn(),
|
||||||
@ -69,7 +71,7 @@ describe('AccountPage.vue', () => {
|
|||||||
|
|
||||||
describe('Rendering and Initial Data Fetching', () => {
|
describe('Rendering and Initial Data Fetching', () => {
|
||||||
it('renders loading state initially', async () => {
|
it('renders loading state initially', async () => {
|
||||||
mockApiClient.get.mockImplementationOnce(() => new Promise(() => {})); // Keep it pending
|
mockApiClient.get.mockImplementationOnce(() => new Promise(() => { })); // Keep it pending
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
expect(wrapper.text()).toContain('Loading profile...');
|
expect(wrapper.text()).toContain('Loading profile...');
|
||||||
expect(wrapper.find('.spinner-dots').exists()).toBe(true);
|
expect(wrapper.find('.spinner-dots').exists()).toBe(true);
|
||||||
@ -128,23 +130,23 @@ describe('AccountPage.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles profile update failure', async () => {
|
it('handles profile update failure', async () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
mockApiClient.put.mockRejectedValueOnce(new Error('Update failed'));
|
mockApiClient.put.mockRejectedValueOnce(new Error('Update failed'));
|
||||||
await wrapper.find('form').trigger('submit.prevent');
|
await wrapper.find('form').trigger('submit.prevent');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' });
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Update failed', type: 'error' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Change Password Form', () => {
|
describe('Change Password Form', () => {
|
||||||
let wrapper: ReturnType<typeof createWrapper>;
|
let wrapper: ReturnType<typeof createWrapper>;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
wrapper = createWrapper();
|
wrapper = createWrapper();
|
||||||
await flushPromises(); // Initial load
|
await flushPromises(); // Initial load
|
||||||
});
|
});
|
||||||
|
|
||||||
it('changes password successfully', async () => {
|
it('changes password successfully', async () => {
|
||||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||||
@ -166,79 +168,79 @@ describe('AccountPage.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows validation error if new password is too short', async () => {
|
it('shows validation error if new password is too short', async () => {
|
||||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||||
await wrapper.find('#newPassword').setValue('short');
|
await wrapper.find('#newPassword').setValue('short');
|
||||||
|
|
||||||
const forms = wrapper.findAll('form');
|
const forms = wrapper.findAll('form');
|
||||||
await forms[1].trigger('submit.prevent');
|
await forms[1].trigger('submit.prevent');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||||
message: 'New password must be at least 8 characters long.', type: 'warning'
|
message: 'New password must be at least 8 characters long.', type: 'warning'
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('shows validation error if fields are empty', async () => {
|
it('shows validation error if fields are empty', async () => {
|
||||||
const forms = wrapper.findAll('form');
|
const forms = wrapper.findAll('form');
|
||||||
await forms[1].trigger('submit.prevent'); // Submit with empty fields
|
await forms[1].trigger('submit.prevent'); // Submit with empty fields
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||||
message: 'Please fill in both current and new password fields.', type: 'warning'
|
message: 'Please fill in both current and new password fields.', type: 'warning'
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('handles password change failure', async () => {
|
it('handles password change failure', async () => {
|
||||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||||
await wrapper.find('#newPassword').setValue('newPasswordSecure');
|
await wrapper.find('#newPassword').setValue('newPasswordSecure');
|
||||||
mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed'));
|
mockApiClient.put.mockRejectedValueOnce(new Error('Password change failed'));
|
||||||
|
|
||||||
const forms = wrapper.findAll('form');
|
const forms = wrapper.findAll('form');
|
||||||
await forms[1].trigger('submit.prevent');
|
await forms[1].trigger('submit.prevent');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' });
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Password change failed', type: 'error' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Notification Preferences', () => {
|
describe('Notification Preferences', () => {
|
||||||
it('updates preferences successfully when a toggle is changed', async () => {
|
it('updates preferences successfully when a toggle is changed', async () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
await flushPromises(); // Initial load
|
await flushPromises(); // Initial load
|
||||||
|
|
||||||
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
|
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
|
||||||
const initialEmailPref = mockPreferencesData.emailNotifications;
|
const initialEmailPref = mockPreferencesData.emailNotifications;
|
||||||
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
|
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
|
||||||
|
|
||||||
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
|
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
|
||||||
|
|
||||||
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
|
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
|
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
|
||||||
expect(mockApiClient.put).toHaveBeenCalledWith(
|
expect(mockApiClient.put).toHaveBeenCalledWith(
|
||||||
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
|
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
|
||||||
expectedPreferences
|
expectedPreferences
|
||||||
);
|
);
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles preference update failure', async () => {
|
it('handles preference update failure', async () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
|
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
|
||||||
const initialListPref = mockPreferencesData.listUpdates;
|
const initialListPref = mockPreferencesData.listUpdates;
|
||||||
|
|
||||||
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
|
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
|
||||||
|
|
||||||
await listUpdatesToggle.setValue(!initialListPref);
|
await listUpdatesToggle.setValue(!initialListPref);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' });
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' });
|
||||||
// Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error.
|
// Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error.
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -58,6 +58,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/pages/ChoresPage.vue'),
|
component: () => import('@/pages/ChoresPage.vue'),
|
||||||
meta: { requiresAuth: true, keepAlive: false },
|
meta: { requiresAuth: true, keepAlive: false },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/expenses',
|
||||||
|
name: 'Expenses',
|
||||||
|
component: () => import('@/pages/ExpensePage.vue'),
|
||||||
|
meta: { requiresAuth: true, keepAlive: false },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { API_BASE_URL, API_ENDPOINTS } from '@/config/api-config' // api-config.ts can be moved to src/config/
|
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from '@/config/api-config' // api-config.ts can be moved to src/config/
|
||||||
import router from '@/router' // Import the router instance
|
import router from '@/router' // Import the router instance
|
||||||
import { useAuthStore } from '@/stores/auth' // Import the auth store
|
import { useAuthStore } from '@/stores/auth' // Import the auth store
|
||||||
import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
|
import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
|
||||||
@ -7,7 +7,7 @@ import { stringify } from 'qs';
|
|||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_BASE_URL, // API_BASE_URL should come from env or config
|
baseURL: `${API_BASE_URL}/api/${API_VERSION}`,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
|
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
|
||||||
import { groupService } from './groupService'
|
import { groupService } from './groupService'
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api'
|
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||||
import type { Group } from '@/types/group'
|
import type { Group } from '@/types/group'
|
||||||
|
|
||||||
export const choreService = {
|
export const choreService = {
|
||||||
async getAllChores(): Promise<Chore[]> {
|
async getAllChores(): Promise<Chore[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/v1/chores/all')
|
const response = await api.get('/chores/all')
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)
|
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||||
import type { Group } from '@/types/group';
|
import type { Group, GroupCreate, GroupUpdate } from '@/types/group';
|
||||||
import type { ChoreHistory } from '@/types/chore';
|
import type { ChoreHistory } from '@/types/chore';
|
||||||
|
|
||||||
export const groupService = {
|
export const groupService = {
|
||||||
|
@ -11,6 +11,7 @@ export interface AuthState {
|
|||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
id?: string | number
|
id?: string | number
|
||||||
|
is_guest?: boolean
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// Getters
|
// Getters
|
||||||
const isAuthenticated = computed(() => !!accessToken.value)
|
const isAuthenticated = computed(() => !!accessToken.value)
|
||||||
const getUser = computed(() => user.value)
|
const getUser = computed(() => user.value)
|
||||||
|
const isGuest = computed(() => user.value?.is_guest ?? false)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const setTokens = (tokens: { access_token: string; refresh_token?: string }) => {
|
const setTokens = (tokens: { access_token: string; refresh_token?: string }) => {
|
||||||
@ -109,6 +111,14 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loginAsGuest = async () => {
|
||||||
|
const response = await api.post(API_ENDPOINTS.AUTH.GUEST)
|
||||||
|
const { access_token, refresh_token } = response.data
|
||||||
|
setTokens({ access_token, refresh_token })
|
||||||
|
await fetchCurrentUser()
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
const signup = async (userData: { name: string; email: string; password: string }) => {
|
const signup = async (userData: { name: string; email: string; password: string }) => {
|
||||||
const response = await api.post(API_ENDPOINTS.AUTH.SIGNUP, userData)
|
const response = await api.post(API_ENDPOINTS.AUTH.SIGNUP, userData)
|
||||||
return response.data
|
return response.data
|
||||||
@ -125,11 +135,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
getUser,
|
getUser,
|
||||||
|
isGuest,
|
||||||
setTokens,
|
setTokens,
|
||||||
clearTokens,
|
clearTokens,
|
||||||
setUser,
|
setUser,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
login,
|
login,
|
||||||
|
loginAsGuest,
|
||||||
signup,
|
signup,
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
|
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 { defineStore } from 'pinia'
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api'
|
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||||
import type {
|
import type {
|
||||||
Expense,
|
Expense,
|
||||||
ExpenseSplit,
|
ExpenseSplit,
|
||||||
|
@ -20,7 +20,8 @@ export type CreateListItemPayload = {
|
|||||||
name: string
|
name: string
|
||||||
quantity?: number | string
|
quantity?: number | string
|
||||||
completed?: boolean
|
completed?: boolean
|
||||||
price?: number | null /* other item properties */
|
price?: number | null
|
||||||
|
category_id?: number | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type UpdateListItemPayload = {
|
export type UpdateListItemPayload = {
|
||||||
|
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 ChoreFrequency = 'one_time' | 'daily' | 'weekly' | 'monthly' | 'custom'
|
||||||
export type ChoreType = 'personal' | 'group'
|
export type ChoreType = 'personal' | 'group'
|
||||||
@ -69,3 +69,15 @@ export interface ChoreAssignmentHistory {
|
|||||||
changed_by_user?: User
|
changed_by_user?: User
|
||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChoreWithCompletion extends Chore {
|
||||||
|
current_assignment_id: number | null;
|
||||||
|
is_completed: boolean;
|
||||||
|
completed_at: string | null;
|
||||||
|
updating: boolean;
|
||||||
|
assigned_user_name?: string;
|
||||||
|
completed_by_name?: string;
|
||||||
|
parent_chore_id?: number | null;
|
||||||
|
child_chores?: ChoreWithCompletion[];
|
||||||
|
subtext?: string;
|
||||||
|
}
|
||||||
|
@ -50,16 +50,15 @@ export interface ExpenseSplit {
|
|||||||
id: number
|
id: number
|
||||||
expense_id: number
|
expense_id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
user?: UserPublic | null
|
|
||||||
owed_amount: string // String representation of Decimal
|
owed_amount: string // String representation of Decimal
|
||||||
share_percentage?: string | null
|
share_percentage?: string | null
|
||||||
share_units?: number | null
|
share_units?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|
||||||
status: ExpenseSplitStatusEnum
|
status: ExpenseSplitStatusEnum
|
||||||
paid_at?: string | null
|
paid_at?: string | null
|
||||||
settlement_activities: SettlementActivity[]
|
settlement_activities: SettlementActivity[]
|
||||||
|
user?: UserPublic | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecurrencePatternCreate {
|
export interface RecurrencePatternCreate {
|
||||||
@ -124,3 +123,32 @@ export interface Expense {
|
|||||||
parentExpenseId?: number
|
parentExpenseId?: number
|
||||||
generatedExpenses?: Expense[]
|
generatedExpenses?: Expense[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
|
||||||
|
export type SettlementStatus = 'unpaid' | 'paid' | 'partially_paid';
|
||||||
|
|
||||||
|
export interface Expense {
|
||||||
|
id: number;
|
||||||
|
description: string;
|
||||||
|
total_amount: string; // Decimal is string
|
||||||
|
currency: string;
|
||||||
|
expense_date?: string;
|
||||||
|
split_type: SplitType;
|
||||||
|
list_id?: number;
|
||||||
|
group_id?: number;
|
||||||
|
item_id?: number;
|
||||||
|
paid_by_user_id: number;
|
||||||
|
is_recurring: boolean;
|
||||||
|
recurrence_pattern?: any;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
|
created_by_user_id: number;
|
||||||
|
splits: ExpenseSplit[];
|
||||||
|
paid_by_user?: UserPublic;
|
||||||
|
overall_settlement_status: SettlementStatus;
|
||||||
|
next_occurrence?: string;
|
||||||
|
last_occurrence?: string;
|
||||||
|
parent_expense_id?: number;
|
||||||
|
generated_expenses: Expense[];
|
||||||
|
}
|
||||||
|
@ -5,6 +5,7 @@ export interface Item {
|
|||||||
is_complete: boolean
|
is_complete: boolean
|
||||||
price?: string | null // String representation of Decimal
|
price?: string | null // String representation of Decimal
|
||||||
list_id: number
|
list_id: number
|
||||||
|
category_id?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
version: number
|
version: number
|
||||||
|
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