feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
This commit is contained in:
parent
bbe3b3a493
commit
f49e15c05c
32
.cursor/rules/fastapi.mdc
Normal file
32
.cursor/rules/fastapi.mdc
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# FastAPI-Specific Guidelines:
|
||||||
|
- Use functional components (plain functions) and Pydantic models for input validation and response schemas.
|
||||||
|
- Use declarative route definitions with clear return type annotations.
|
||||||
|
- Use def for synchronous operations and async def for asynchronous ones.
|
||||||
|
- Minimize @app.on_event("startup") and @app.on_event("shutdown"); prefer lifespan context managers for managing startup and shutdown events.
|
||||||
|
- Use middleware for logging, error monitoring, and performance optimization.
|
||||||
|
- Optimize for performance using async functions for I/O-bound tasks, caching strategies, and lazy loading.
|
||||||
|
- Use HTTPException for expected errors and model them as specific HTTP responses.
|
||||||
|
- Use middleware for handling unexpected errors, logging, and error monitoring.
|
||||||
|
- Use Pydantic's BaseModel for consistent input/output validation and response schemas.
|
||||||
|
|
||||||
|
Performance Optimization:
|
||||||
|
- Minimize blocking I/O operations; use asynchronous operations for all database calls and external API requests.
|
||||||
|
- Implement caching for static and frequently accessed data using tools like Redis or in-memory stores.
|
||||||
|
- Optimize data serialization and deserialization with Pydantic.
|
||||||
|
- Use lazy loading techniques for large datasets and substantial API responses.
|
||||||
|
|
||||||
|
Key Conventions
|
||||||
|
1. Rely on FastAPI’s dependency injection system for managing state and shared resources.
|
||||||
|
2. Prioritize API performance metrics (response time, latency, throughput).
|
||||||
|
3. Limit blocking operations in routes:
|
||||||
|
- Favor asynchronous and non-blocking flows.
|
||||||
|
- Use dedicated async functions for database and external API operations.
|
||||||
|
- Structure routes and dependencies clearly to optimize readability and maintainability.
|
||||||
|
|
||||||
|
|
||||||
|
Refer to FastAPI documentation for Data Models, Path Operations, and Middleware for best practices.
|
37
.cursor/rules/vue.mdc
Normal file
37
.cursor/rules/vue.mdc
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You have extensive expertise in Vue 3, TypeScript, Node.js, Vite, Vue Router, Pinia, VueUse, and CSS. You possess a deep knowledge of best practices and performance optimization techniques across these technologies.
|
||||||
|
|
||||||
|
Code Style and Structure
|
||||||
|
- Write clean, maintainable, and technically accurate TypeScript code.
|
||||||
|
- Emphasize iteration and modularization and minimize code duplication.
|
||||||
|
- Prefer Composition API <script setup> style.
|
||||||
|
- Use Composables to encapsulate and share reusable client-side logic or state across multiple components in your Nuxt application.
|
||||||
|
|
||||||
|
Fetching Data
|
||||||
|
1. Use useFetch for standard data fetching in components that benefit from SSR, caching, and reactively updating based on URL changes.
|
||||||
|
2. Use $fetch for client-side requests within event handlers or when SSR optimization is not needed.
|
||||||
|
3. Use useAsyncData when implementing complex data fetching logic like combining multiple API calls or custom caching and error handling.
|
||||||
|
4. Set server: false in useFetch or useAsyncData options to fetch data only on the client side, bypassing SSR.
|
||||||
|
5. Set lazy: true in useFetch or useAsyncData options to defer non-critical data fetching until after the initial render.
|
||||||
|
|
||||||
|
Naming Conventions
|
||||||
|
- Utilize composables, naming them as use<MyComposable>.
|
||||||
|
- Use **PascalCase** for component file names (e.g., components/MyComponent.vue).
|
||||||
|
- Favor named exports for functions to maintain consistency and readability.
|
||||||
|
|
||||||
|
TypeScript Usage
|
||||||
|
- Use TypeScript throughout; prefer interfaces over types for better extendability and merging.
|
||||||
|
- Avoid enums, opting for maps for improved type safety and flexibility.
|
||||||
|
- Use functional components with TypeScript interfaces.
|
||||||
|
|
||||||
|
UI and Styling.
|
||||||
|
- Implement responsive design; use a mobile-first approach.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
91
be/alembic/versions/bdf7427ccfa3_feature_updates_phase1.py
Normal file
91
be/alembic/versions/bdf7427ccfa3_feature_updates_phase1.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"""feature_updates_phase1
|
||||||
|
|
||||||
|
Revision ID: bdf7427ccfa3
|
||||||
|
Revises: 05bf96a9e18b
|
||||||
|
Create Date: 2025-06-09 18:00:11.083651
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'bdf7427ccfa3'
|
||||||
|
down_revision: Union[str, None] = '05bf96a9e18b'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('financial_audit_log',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('action_type', sa.String(), nullable=False),
|
||||||
|
sa.Column('entity_type', sa.String(), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('details', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_financial_audit_log_action_type'), 'financial_audit_log', ['action_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_financial_audit_log_id'), 'financial_audit_log', ['id'], unique=False)
|
||||||
|
op.create_table('categories',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=False)
|
||||||
|
op.create_table('time_entries',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chore_assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('end_time', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('duration_seconds', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['chore_assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_time_entries_id'), 'time_entries', ['id'], unique=False)
|
||||||
|
op.add_column('chores', sa.Column('parent_chore_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_chores_parent_chore_id'), 'chores', ['parent_chore_id'], unique=False)
|
||||||
|
op.create_foreign_key(None, 'chores', 'chores', ['parent_chore_id'], ['id'])
|
||||||
|
op.add_column('items', sa.Column('category_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(None, 'items', 'categories', ['category_id'], ['id'])
|
||||||
|
op.add_column('lists', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_index(op.f('ix_lists_archived_at'), 'lists', ['archived_at'], unique=False)
|
||||||
|
op.add_column('users', sa.Column('is_guest', sa.Boolean(), nullable=False, server_default='f'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'is_guest')
|
||||||
|
op.drop_index(op.f('ix_lists_archived_at'), table_name='lists')
|
||||||
|
op.drop_column('lists', 'archived_at')
|
||||||
|
op.drop_constraint(None, 'items', type_='foreignkey')
|
||||||
|
op.drop_column('items', 'category_id')
|
||||||
|
op.drop_constraint(None, 'chores', type_='foreignkey')
|
||||||
|
op.drop_index(op.f('ix_chores_parent_chore_id'), table_name='chores')
|
||||||
|
op.drop_column('chores', 'parent_chore_id')
|
||||||
|
op.drop_index(op.f('ix_time_entries_id'), table_name='time_entries')
|
||||||
|
op.drop_table('time_entries')
|
||||||
|
op.drop_index(op.f('ix_categories_name'), table_name='categories')
|
||||||
|
op.drop_index(op.f('ix_categories_id'), table_name='categories')
|
||||||
|
op.drop_table('categories')
|
||||||
|
op.drop_index(op.f('ix_financial_audit_log_id'), table_name='financial_audit_log')
|
||||||
|
op.drop_index(op.f('ix_financial_audit_log_action_type'), table_name='financial_audit_log')
|
||||||
|
op.drop_table('financial_audit_log')
|
||||||
|
# ### end Alembic commands ###
|
@ -0,0 +1,51 @@
|
|||||||
|
"""add_updated_at_and_version_to_groups
|
||||||
|
|
||||||
|
Revision ID: c693ade3601c
|
||||||
|
Revises: bdf7427ccfa3
|
||||||
|
Create Date: 2025-06-09 19:22:36.244072
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c693ade3601c'
|
||||||
|
down_revision: Union[str, None] = 'bdf7427ccfa3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
|
||||||
|
op.drop_table('apscheduler_jobs')
|
||||||
|
op.add_column('groups', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False))
|
||||||
|
op.add_column('groups', sa.Column('version', sa.Integer(), server_default='1', nullable=False))
|
||||||
|
op.alter_column('users', 'is_guest',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=None,
|
||||||
|
existing_nullable=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('users', 'is_guest',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.drop_column('groups', 'version')
|
||||||
|
op.drop_column('groups', 'updated_at')
|
||||||
|
op.create_table('apscheduler_jobs',
|
||||||
|
sa.Column('id', sa.VARCHAR(length=191), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('next_run_time', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('job_state', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='apscheduler_jobs_pkey')
|
||||||
|
)
|
||||||
|
op.create_index('ix_apscheduler_jobs_next_run_time', 'apscheduler_jobs', ['next_run_time'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
55
be/app/api/auth/guest.py
Normal file
55
be/app/api/auth/guest.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
from app.schemas.user import UserCreate, UserClaim, UserPublic
|
||||||
|
from app.schemas.token import Token
|
||||||
|
from app.database import get_session
|
||||||
|
from app.auth import current_active_user
|
||||||
|
from app.core.security import create_access_token, get_password_hash
|
||||||
|
from app.crud import user as crud_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/guest", response_model=Token)
|
||||||
|
async def create_guest_user(db: AsyncSession = Depends(get_session)):
|
||||||
|
"""
|
||||||
|
Creates a new guest user.
|
||||||
|
"""
|
||||||
|
guest_email = f"guest_{uuid.uuid4()}@guest.mitlist.app"
|
||||||
|
guest_password = uuid.uuid4().hex
|
||||||
|
|
||||||
|
user_in = UserCreate(email=guest_email, password=guest_password)
|
||||||
|
user = await crud_user.create_user(db, user_in=user_in, is_guest=True)
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": user.email})
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
@router.post("/guest/claim", response_model=UserPublic)
|
||||||
|
async def claim_guest_account(
|
||||||
|
claim_in: UserClaim,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
current_user: models.User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Claims a guest account, converting it to a full user.
|
||||||
|
"""
|
||||||
|
if not current_user.is_guest:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Not a guest account.")
|
||||||
|
|
||||||
|
existing_user = await crud_user.get_user_by_email(db, email=claim_in.email)
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered.")
|
||||||
|
|
||||||
|
hashed_password = get_password_hash(claim_in.password)
|
||||||
|
current_user.email = claim_in.email
|
||||||
|
current_user.hashed_password = hashed_password
|
||||||
|
current_user.is_guest = False
|
||||||
|
current_user.is_verified = False # Require email verification
|
||||||
|
|
||||||
|
db.add(current_user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
return current_user
|
26
be/app/api/auth/jwt.py
Normal file
26
be/app/api/auth/jwt.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from app.auth import auth_backend, fastapi_users
|
||||||
|
from app.schemas.user import UserCreate, UserPublic, UserUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(
|
||||||
|
fastapi_users.get_auth_router(auth_backend),
|
||||||
|
prefix="/jwt",
|
||||||
|
tags=["auth"],
|
||||||
|
)
|
||||||
|
router.include_router(
|
||||||
|
fastapi_users.get_register_router(UserPublic, UserCreate),
|
||||||
|
prefix="",
|
||||||
|
tags=["auth"],
|
||||||
|
)
|
||||||
|
router.include_router(
|
||||||
|
fastapi_users.get_reset_password_router(),
|
||||||
|
prefix="",
|
||||||
|
tags=["auth"],
|
||||||
|
)
|
||||||
|
router.include_router(
|
||||||
|
fastapi_users.get_verify_router(UserPublic),
|
||||||
|
prefix="",
|
||||||
|
tags=["auth"],
|
||||||
|
)
|
@ -9,7 +9,10 @@ from app.api.v1.endpoints import ocr
|
|||||||
from app.api.v1.endpoints import costs
|
from app.api.v1.endpoints import 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
|
||||||
@ -506,4 +509,123 @@ async def get_chore_assignment_history(
|
|||||||
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
|
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
|
||||||
|
|
||||||
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
|
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")
|
||||||
|
|
||||||
@ -32,4 +35,40 @@ def hash_password(password: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
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)
|
||||||
)
|
)
|
||||||
@ -228,6 +240,14 @@ async def update_chore(
|
|||||||
raise PermissionDeniedError(detail="Only the creator can update personal chores")
|
raise PermissionDeniedError(detail="Only the creator can update personal chores")
|
||||||
|
|
||||||
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']
|
||||||
@ -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)
|
|
||||||
if not chore:
|
|
||||||
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
|
||||||
|
|
||||||
can_manage = False
|
# Permission Check: only assigned user or group owner can update
|
||||||
if chore.type == ChoreTypeEnum.personal:
|
is_allowed = db_assignment.assigned_to_user_id == user_id
|
||||||
can_manage = chore.created_by_id == user_id
|
if not is_allowed and db_assignment.chore.group_id:
|
||||||
else:
|
user_role = await get_user_role_in_group(db, db_assignment.chore.group_id, user_id)
|
||||||
can_manage = await is_user_member(db, chore.group_id, user_id)
|
is_allowed = user_role == UserRoleEnum.owner
|
||||||
|
|
||||||
can_complete = db_assignment.assigned_to_user_id == user_id
|
if not is_allowed:
|
||||||
|
raise PermissionDeniedError("You cannot update this chore assignment.")
|
||||||
|
|
||||||
|
original_status = db_assignment.is_complete
|
||||||
update_data = assignment_in.model_dump(exclude_unset=True)
|
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__)
|
||||||
|
|
||||||
@ -75,6 +76,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:
|
||||||
@ -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
|
||||||
@ -210,6 +223,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:
|
||||||
@ -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
|
||||||
|
@ -24,4 +24,5 @@ httpx>=0.24.0 # For async HTTP testing
|
|||||||
aiosqlite>=0.19.0 # For async SQLite support in tests
|
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');
|
||||||
@ -164,81 +166,81 @@ describe('AccountPage.vue', () => {
|
|||||||
expect(wrapper.find<HTMLInputElement>('#currentPassword').element.value).toBe('');
|
expect(wrapper.find<HTMLInputElement>('#currentPassword').element.value).toBe('');
|
||||||
expect(wrapper.find<HTMLInputElement>('#newPassword').element.value).toBe('');
|
expect(wrapper.find<HTMLInputElement>('#newPassword').element.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows validation error if new password is too short', async () => {
|
|
||||||
await wrapper.find('#currentPassword').setValue('currentPass123');
|
|
||||||
await wrapper.find('#newPassword').setValue('short');
|
|
||||||
|
|
||||||
const forms = wrapper.findAll('form');
|
|
||||||
await forms[1].trigger('submit.prevent');
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
|
||||||
message: 'New password must be at least 8 characters long.', type: 'warning'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows validation error if fields are empty', async () => {
|
it('shows validation error if new password is too short', async () => {
|
||||||
const forms = wrapper.findAll('form');
|
await wrapper.find('#currentPassword').setValue('currentPass123');
|
||||||
await forms[1].trigger('submit.prevent'); // Submit with empty fields
|
await wrapper.find('#newPassword').setValue('short');
|
||||||
await flushPromises();
|
|
||||||
|
const forms = wrapper.findAll('form');
|
||||||
expect(mockApiClient.put).not.toHaveBeenCalled();
|
await forms[1].trigger('submit.prevent');
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
await flushPromises();
|
||||||
message: 'Please fill in both current and new password fields.', type: 'warning'
|
|
||||||
});
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||||
|
message: 'New password must be at least 8 characters long.', type: 'warning'
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows validation error if fields are empty', async () => {
|
||||||
|
const forms = wrapper.findAll('form');
|
||||||
|
await forms[1].trigger('submit.prevent'); // Submit with empty fields
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mockApiClient.put).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({
|
||||||
|
message: 'Please fill in both current and new password fields.', type: 'warning'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('handles password change failure', async () => {
|
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 initialEmailPref = mockPreferencesData.emailNotifications;
|
|
||||||
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
|
|
||||||
|
|
||||||
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
|
|
||||||
|
|
||||||
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
|
|
||||||
expect(mockApiClient.put).toHaveBeenCalledWith(
|
|
||||||
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
|
|
||||||
expectedPreferences
|
|
||||||
);
|
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles preference update failure', async () => {
|
const emailNotificationsToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[0];
|
||||||
const wrapper = createWrapper();
|
const initialEmailPref = mockPreferencesData.emailNotifications;
|
||||||
await flushPromises();
|
expect(emailNotificationsToggle.element.checked).toBe(initialEmailPref);
|
||||||
|
|
||||||
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
|
mockApiClient.put.mockResolvedValueOnce({ data: {} }); // Mock successful preference update
|
||||||
const initialListPref = mockPreferencesData.listUpdates;
|
|
||||||
|
await emailNotificationsToggle.setValue(!initialEmailPref); // This also triggers @change
|
||||||
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
|
await flushPromises();
|
||||||
|
|
||||||
await listUpdatesToggle.setValue(!initialListPref);
|
const expectedPreferences = { ...mockPreferencesData, emailNotifications: !initialEmailPref };
|
||||||
await flushPromises();
|
expect(mockApiClient.put).toHaveBeenCalledWith(
|
||||||
|
MOCK_API_ENDPOINTS.USERS.PREFERENCES,
|
||||||
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' });
|
expectedPreferences
|
||||||
// Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error.
|
);
|
||||||
});
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Preferences updated successfully', type: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles preference update failure', async () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const listUpdatesToggle = wrapper.findAll<HTMLInputElement>('input[type="checkbox"]')[1];
|
||||||
|
const initialListPref = mockPreferencesData.listUpdates;
|
||||||
|
|
||||||
|
mockApiClient.put.mockRejectedValueOnce(new Error('Pref update failed'));
|
||||||
|
|
||||||
|
await listUpdatesToggle.setValue(!initialListPref);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mockNotificationStore.addNotification).toHaveBeenCalledWith({ message: 'Pref update failed', type: 'error' });
|
||||||
|
// Optionally, test if the toggle reverts, though the current component code doesn't explicitly do that on error.
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -58,6 +58,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/pages/ChoresPage.vue'),
|
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