commit
d13a231113
@ -0,0 +1,75 @@
|
|||||||
|
"""Add chore history and scheduling tables
|
||||||
|
|
||||||
|
Revision ID: 05bf96a9e18b
|
||||||
|
Revises: 91d00c100f5b
|
||||||
|
Create Date: 2025-06-08 00:41:10.516324
|
||||||
|
|
||||||
|
"""
|
||||||
|
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 = '05bf96a9e18b'
|
||||||
|
down_revision: Union[str, None] = '91d00c100f5b'
|
||||||
|
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('chore_history',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chore_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
|
||||||
|
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chore_history_chore_id'), 'chore_history', ['chore_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chore_history_group_id'), 'chore_history', ['group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chore_history_id'), 'chore_history', ['id'], unique=False)
|
||||||
|
op.create_table('chore_assignment_history',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('event_type', sa.Enum('CREATED', 'UPDATED', 'DELETED', 'COMPLETED', 'REOPENED', 'ASSIGNED', 'UNASSIGNED', 'REASSIGNED', 'SCHEDULE_GENERATED', 'DUE_DATE_CHANGED', 'DETAILS_CHANGED', name='chorehistoryeventtypeenum'), nullable=False),
|
||||||
|
sa.Column('event_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('changed_by_user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['assignment_id'], ['chore_assignments.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['changed_by_user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chore_assignment_history_assignment_id'), 'chore_assignment_history', ['assignment_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chore_assignment_history_id'), 'chore_assignment_history', ['id'], unique=False)
|
||||||
|
op.drop_index('ix_apscheduler_jobs_next_run_time', table_name='apscheduler_jobs')
|
||||||
|
op.drop_table('apscheduler_jobs')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
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)
|
||||||
|
op.drop_index(op.f('ix_chore_assignment_history_id'), table_name='chore_assignment_history')
|
||||||
|
op.drop_index(op.f('ix_chore_assignment_history_assignment_id'), table_name='chore_assignment_history')
|
||||||
|
op.drop_table('chore_assignment_history')
|
||||||
|
op.drop_index(op.f('ix_chore_history_id'), table_name='chore_history')
|
||||||
|
op.drop_index(op.f('ix_chore_history_group_id'), table_name='chore_history')
|
||||||
|
op.drop_index(op.f('ix_chore_history_chore_id'), table_name='chore_history')
|
||||||
|
op.drop_table('chore_history')
|
||||||
|
# ### end Alembic commands ###
|
@ -8,8 +8,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
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
|
||||||
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic
|
from app.schemas.chore import (
|
||||||
|
ChoreCreate, ChoreUpdate, ChorePublic,
|
||||||
|
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
|
||||||
|
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
||||||
|
)
|
||||||
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.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -451,3 +456,65 @@ async def complete_chore_assignment(
|
|||||||
except DatabaseIntegrityError as e:
|
except DatabaseIntegrityError as e:
|
||||||
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
|
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
|
||||||
|
|
||||||
|
# === CHORE HISTORY ENDPOINTS ===
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{chore_id}/history",
|
||||||
|
response_model=PyList[ChoreHistoryPublic],
|
||||||
|
summary="Get Chore History",
|
||||||
|
tags=["Chores", "History"]
|
||||||
|
)
|
||||||
|
async def get_chore_history(
|
||||||
|
chore_id: int,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Retrieves the history of a specific chore."""
|
||||||
|
# First, check if user has permission to view the chore itself
|
||||||
|
chore = await crud_chore.get_chore_by_id(db, chore_id)
|
||||||
|
if not chore:
|
||||||
|
raise ChoreNotFoundError(chore_id=chore_id)
|
||||||
|
|
||||||
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
||||||
|
raise PermissionDeniedError("You can only view history for your own personal chores.")
|
||||||
|
|
||||||
|
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 PermissionDeniedError("You must be a member of the group to view this chore's history.")
|
||||||
|
|
||||||
|
logger.info(f"User {current_user.email} getting history for chore {chore_id}")
|
||||||
|
return await crud_history.get_chore_history(db=db, chore_id=chore_id)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assignments/{assignment_id}/history",
|
||||||
|
response_model=PyList[ChoreAssignmentHistoryPublic],
|
||||||
|
summary="Get Chore Assignment History",
|
||||||
|
tags=["Chore Assignments", "History"]
|
||||||
|
)
|
||||||
|
async def get_chore_assignment_history(
|
||||||
|
assignment_id: int,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Retrieves the history of a specific chore assignment."""
|
||||||
|
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
|
||||||
|
if not assignment:
|
||||||
|
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||||
|
|
||||||
|
# Check permission by checking permission on the parent chore
|
||||||
|
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
|
||||||
|
if not chore:
|
||||||
|
raise ChoreNotFoundError(chore_id=assignment.chore_id) # Should not happen if assignment exists
|
||||||
|
|
||||||
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
||||||
|
raise PermissionDeniedError("You can only view history for assignments of your own personal chores.")
|
||||||
|
|
||||||
|
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 PermissionDeniedError("You must be a member of the group to view this assignment's history.")
|
||||||
|
|
||||||
|
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
|
||||||
|
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
|
@ -8,13 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
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, UserRoleEnum # Import model and enum
|
from app.models import User as UserModel, UserRoleEnum # Import model and enum
|
||||||
from app.schemas.group import GroupCreate, GroupPublic
|
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
|
||||||
from app.schemas.invite import InviteCodePublic
|
from app.schemas.invite import InviteCodePublic
|
||||||
from app.schemas.message import Message # For simple responses
|
from app.schemas.message import Message # For simple responses
|
||||||
from app.schemas.list import ListPublic, ListDetail
|
from app.schemas.list import ListPublic, ListDetail
|
||||||
|
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
|
||||||
from app.crud import group as crud_group
|
from app.crud import group as crud_group
|
||||||
from app.crud import invite as crud_invite
|
from app.crud import invite as crud_invite
|
||||||
from app.crud import list as crud_list
|
from app.crud import list as crud_list
|
||||||
|
from app.crud import history as crud_history
|
||||||
|
from app.crud import schedule as crud_schedule
|
||||||
from app.core.exceptions import (
|
from app.core.exceptions import (
|
||||||
GroupNotFoundError,
|
GroupNotFoundError,
|
||||||
GroupPermissionError,
|
GroupPermissionError,
|
||||||
@ -265,3 +268,54 @@ async def read_group_lists(
|
|||||||
group_lists = [list for list in lists if list.group_id == group_id]
|
group_lists = [list for list in lists if list.group_id == group_id]
|
||||||
|
|
||||||
return group_lists
|
return group_lists
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{group_id}/chores/generate-schedule",
|
||||||
|
response_model=List[ChoreAssignmentPublic],
|
||||||
|
summary="Generate Group Chore Schedule",
|
||||||
|
tags=["Groups", "Chores"]
|
||||||
|
)
|
||||||
|
async def generate_group_chore_schedule(
|
||||||
|
group_id: int,
|
||||||
|
schedule_in: GroupScheduleGenerateRequest,
|
||||||
|
db: AsyncSession = Depends(get_transactional_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Generates a round-robin chore schedule for a group."""
|
||||||
|
logger.info(f"User {current_user.email} generating chore schedule for group {group_id}")
|
||||||
|
# Permission check: ensure user is a member (or owner/admin if stricter rules are needed)
|
||||||
|
if not await crud_group.is_user_member(db, group_id, current_user.id):
|
||||||
|
raise GroupMembershipError(group_id, "generate chore schedule for this group")
|
||||||
|
|
||||||
|
try:
|
||||||
|
assignments = await crud_schedule.generate_group_chore_schedule(
|
||||||
|
db=db,
|
||||||
|
group_id=group_id,
|
||||||
|
start_date=schedule_in.start_date,
|
||||||
|
end_date=schedule_in.end_date,
|
||||||
|
user_id=current_user.id,
|
||||||
|
member_ids=schedule_in.member_ids,
|
||||||
|
)
|
||||||
|
return assignments
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating schedule for group {group_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{group_id}/chores/history",
|
||||||
|
response_model=List[ChoreHistoryPublic],
|
||||||
|
summary="Get Group Chore History",
|
||||||
|
tags=["Groups", "Chores", "History"]
|
||||||
|
)
|
||||||
|
async def get_group_chore_history(
|
||||||
|
group_id: int,
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
current_user: UserModel = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""Retrieves all chore-related history for a specific group."""
|
||||||
|
logger.info(f"User {current_user.email} requesting chore history for group {group_id}")
|
||||||
|
# Permission check
|
||||||
|
if not await crud_group.is_user_member(db, group_id, current_user.id):
|
||||||
|
raise GroupMembershipError(group_id, "view chore history for this group")
|
||||||
|
|
||||||
|
return await crud_history.get_group_chore_history(db=db, group_id=group_id)
|
@ -332,9 +332,17 @@ class UserOperationError(HTTPException):
|
|||||||
detail=detail
|
detail=detail
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ChoreOperationError(HTTPException):
|
||||||
|
"""Raised when a chore-related operation fails."""
|
||||||
|
def __init__(self, detail: str):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=detail
|
||||||
|
)
|
||||||
|
|
||||||
class ChoreNotFoundError(HTTPException):
|
class ChoreNotFoundError(HTTPException):
|
||||||
"""Raised when a chore is not found."""
|
"""Raised when a chore or assignment is not found."""
|
||||||
def __init__(self, chore_id: int, group_id: Optional[int] = None, detail: Optional[str] = None):
|
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
|
||||||
if detail:
|
if detail:
|
||||||
error_detail = detail
|
error_detail = detail
|
||||||
elif group_id is not None:
|
elif group_id is not None:
|
||||||
|
@ -6,10 +6,11 @@ from typing import List, Optional
|
|||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup
|
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum
|
||||||
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
|
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
|
||||||
from app.core.chore_utils import calculate_next_due_date
|
from app.core.chore_utils import calculate_next_due_date
|
||||||
from app.crud.group import get_group_by_id, is_user_member
|
from app.crud.group import get_group_by_id, is_user_member
|
||||||
|
from app.crud.history import create_chore_history_entry, create_assignment_history_entry
|
||||||
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
|
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -39,7 +40,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
|||||||
personal_chores_query
|
personal_chores_query
|
||||||
.options(
|
.options(
|
||||||
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.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -56,7 +59,9 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
|||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
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.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -99,6 +104,16 @@ async def create_chore(
|
|||||||
db.add(db_chore)
|
db.add(db_chore)
|
||||||
await db.flush() # Get the ID for the chore
|
await db.flush() # Get the ID for the chore
|
||||||
|
|
||||||
|
# Log history
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=db_chore.id,
|
||||||
|
group_id=db_chore.group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.CREATED,
|
||||||
|
event_data={"chore_name": db_chore.name}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load relationships for the response with eager loading
|
# Load relationships for the response with eager loading
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -107,7 +122,9 @@ async def create_chore(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
selectinload(Chore.group),
|
selectinload(Chore.group),
|
||||||
selectinload(Chore.assignments)
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -120,7 +137,13 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Chore)
|
select(Chore)
|
||||||
.where(Chore.id == chore_id)
|
.where(Chore.id == chore_id)
|
||||||
.options(selectinload(Chore.creator), selectinload(Chore.group), selectinload(Chore.assignments))
|
.options(
|
||||||
|
selectinload(Chore.creator),
|
||||||
|
selectinload(Chore.group),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||||
|
selectinload(Chore.history)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@ -152,7 +175,9 @@ async def get_personal_chores(
|
|||||||
)
|
)
|
||||||
.options(
|
.options(
|
||||||
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.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -175,7 +200,9 @@ async def get_chores_by_group_id(
|
|||||||
)
|
)
|
||||||
.options(
|
.options(
|
||||||
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.history)
|
||||||
)
|
)
|
||||||
.order_by(Chore.next_due_date, Chore.name)
|
.order_by(Chore.next_due_date, Chore.name)
|
||||||
)
|
)
|
||||||
@ -194,6 +221,9 @@ async def update_chore(
|
|||||||
if not db_chore:
|
if not db_chore:
|
||||||
raise ChoreNotFoundError(chore_id, group_id)
|
raise ChoreNotFoundError(chore_id, group_id)
|
||||||
|
|
||||||
|
# Store original state for history
|
||||||
|
original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)}
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
if db_chore.type == ChoreTypeEnum.group:
|
if db_chore.type == ChoreTypeEnum.group:
|
||||||
if not group_id:
|
if not group_id:
|
||||||
@ -245,6 +275,23 @@ async def update_chore(
|
|||||||
if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None:
|
if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None:
|
||||||
raise ValueError("custom_interval_days must be set for custom frequency chores.")
|
raise ValueError("custom_interval_days must be set for custom frequency chores.")
|
||||||
|
|
||||||
|
# Log history for changes
|
||||||
|
changes = {}
|
||||||
|
for field, old_value in original_data.items():
|
||||||
|
new_value = getattr(db_chore, field)
|
||||||
|
if old_value != new_value:
|
||||||
|
changes[field] = {"old": str(old_value), "new": str(new_value)}
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=chore_id,
|
||||||
|
group_id=db_chore.group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.UPDATED,
|
||||||
|
event_data=changes
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await db.flush() # Flush changes within the transaction
|
await db.flush() # Flush changes within the transaction
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -253,7 +300,9 @@ async def update_chore(
|
|||||||
.options(
|
.options(
|
||||||
selectinload(Chore.creator),
|
selectinload(Chore.creator),
|
||||||
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.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -273,6 +322,16 @@ async def delete_chore(
|
|||||||
if not db_chore:
|
if not db_chore:
|
||||||
raise ChoreNotFoundError(chore_id, group_id)
|
raise ChoreNotFoundError(chore_id, group_id)
|
||||||
|
|
||||||
|
# Log history before deleting
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=chore_id,
|
||||||
|
group_id=db_chore.group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.DELETED,
|
||||||
|
event_data={"chore_name": db_chore.name}
|
||||||
|
)
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
if db_chore.type == ChoreTypeEnum.group:
|
if db_chore.type == ChoreTypeEnum.group:
|
||||||
if not group_id:
|
if not group_id:
|
||||||
@ -324,6 +383,15 @@ async def create_chore_assignment(
|
|||||||
db.add(db_assignment)
|
db.add(db_assignment)
|
||||||
await db.flush() # Get the ID for the assignment
|
await db.flush() # Get the ID for the assignment
|
||||||
|
|
||||||
|
# Log history
|
||||||
|
await create_assignment_history_entry(
|
||||||
|
db,
|
||||||
|
assignment_id=db_assignment.id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.ASSIGNED,
|
||||||
|
event_data={"assigned_to_user_id": db_assignment.assigned_to_user_id, "due_date": db_assignment.due_date.isoformat()}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load relationships for the response
|
# Load relationships for the response
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -331,7 +399,8 @@ 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.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -346,7 +415,8 @@ 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.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@ -364,7 +434,8 @@ 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.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
@ -393,7 +464,8 @@ 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.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||||
)
|
)
|
||||||
@ -411,7 +483,6 @@ async def update_chore_assignment(
|
|||||||
if not db_assignment:
|
if not db_assignment:
|
||||||
raise ChoreNotFoundError(assignment_id=assignment_id)
|
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||||
|
|
||||||
# Load the chore for permission checking
|
|
||||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
||||||
if not chore:
|
if not chore:
|
||||||
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
|
||||||
@ -427,19 +498,27 @@ async def update_chore_assignment(
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
# Check specific permissions for different updates
|
# Check specific permissions for different updates
|
||||||
if 'is_complete' in update_data and not can_complete:
|
if 'is_complete' in update_data and not can_complete:
|
||||||
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
|
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
|
||||||
|
|
||||||
if 'due_date' in update_data and not can_manage:
|
if 'due_date' in update_data and update_data['due_date'] != original_due_date:
|
||||||
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
|
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']})
|
||||||
|
|
||||||
# Handle completion logic
|
# Handle completion logic
|
||||||
if 'is_complete' in update_data and update_data['is_complete']:
|
if 'is_complete' in update_data:
|
||||||
if not db_assignment.is_complete: # Only if not already complete
|
if update_data['is_complete'] and not db_assignment.is_complete:
|
||||||
update_data['completed_at'] = datetime.utcnow()
|
update_data['completed_at'] = datetime.utcnow()
|
||||||
|
|
||||||
# Update parent chore's last_completed_at and recalculate next_due_date
|
|
||||||
chore.last_completed_at = update_data['completed_at']
|
chore.last_completed_at = update_data['completed_at']
|
||||||
chore.next_due_date = calculate_next_due_date(
|
chore.next_due_date = calculate_next_due_date(
|
||||||
current_due_date=chore.next_due_date,
|
current_due_date=chore.next_due_date,
|
||||||
@ -447,24 +526,25 @@ async def update_chore_assignment(
|
|||||||
custom_interval_days=chore.custom_interval_days,
|
custom_interval_days=chore.custom_interval_days,
|
||||||
last_completed_date=chore.last_completed_at
|
last_completed_date=chore.last_completed_at
|
||||||
)
|
)
|
||||||
elif 'is_complete' in update_data and not update_data['is_complete']:
|
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.COMPLETED)
|
||||||
# If marking as incomplete, clear completed_at
|
elif not update_data['is_complete'] and db_assignment.is_complete:
|
||||||
update_data['completed_at'] = None
|
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)
|
||||||
|
|
||||||
# Apply updates
|
# Apply updates
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(db_assignment, field, value)
|
setattr(db_assignment, field, value)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await db.flush() # Flush changes within the transaction
|
await db.flush()
|
||||||
|
|
||||||
# Load relationships for the response
|
# Load relationships for the response
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(ChoreAssignment)
|
select(ChoreAssignment)
|
||||||
.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.assigned_user)
|
selectinload(ChoreAssignment.assigned_user),
|
||||||
|
selectinload(ChoreAssignment.history)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
@ -483,6 +563,15 @@ async def delete_chore_assignment(
|
|||||||
if not db_assignment:
|
if not db_assignment:
|
||||||
raise ChoreNotFoundError(assignment_id=assignment_id)
|
raise ChoreNotFoundError(assignment_id=assignment_id)
|
||||||
|
|
||||||
|
# Log history before deleting
|
||||||
|
await create_assignment_history_entry(
|
||||||
|
db,
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.UNASSIGNED,
|
||||||
|
event_data={"unassigned_user_id": db_assignment.assigned_to_user_id}
|
||||||
|
)
|
||||||
|
|
||||||
# Load the chore for permission checking
|
# Load the chore for permission checking
|
||||||
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
chore = await get_chore_by_id(db, db_assignment.chore_id)
|
||||||
if not chore:
|
if not chore:
|
||||||
|
@ -79,7 +79,8 @@ async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
|
|||||||
.options(
|
.options(
|
||||||
selectinload(GroupModel.member_associations).options(
|
selectinload(GroupModel.member_associations).options(
|
||||||
selectinload(UserGroupModel.user)
|
selectinload(UserGroupModel.user)
|
||||||
)
|
),
|
||||||
|
selectinload(GroupModel.chore_history) # Eager load chore history
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
@ -95,7 +96,8 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode
|
|||||||
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) # Eager load chore history
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
83
be/app/crud/history.py
Normal file
83
be/app/crud/history.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# be/app/crud/history.py
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from typing import List, Optional, Any, Dict
|
||||||
|
|
||||||
|
from app.models import ChoreHistory, ChoreAssignmentHistory, ChoreHistoryEventTypeEnum, User, Chore, Group
|
||||||
|
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
||||||
|
|
||||||
|
async def create_chore_history_entry(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
chore_id: Optional[int],
|
||||||
|
group_id: Optional[int],
|
||||||
|
changed_by_user_id: Optional[int],
|
||||||
|
event_type: ChoreHistoryEventTypeEnum,
|
||||||
|
event_data: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ChoreHistory:
|
||||||
|
"""Logs an event in the chore history."""
|
||||||
|
history_entry = ChoreHistory(
|
||||||
|
chore_id=chore_id,
|
||||||
|
group_id=group_id,
|
||||||
|
changed_by_user_id=changed_by_user_id,
|
||||||
|
event_type=event_type,
|
||||||
|
event_data=event_data or {},
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(history_entry)
|
||||||
|
return history_entry
|
||||||
|
|
||||||
|
async def create_assignment_history_entry(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
assignment_id: int,
|
||||||
|
changed_by_user_id: int,
|
||||||
|
event_type: ChoreHistoryEventTypeEnum,
|
||||||
|
event_data: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> ChoreAssignmentHistory:
|
||||||
|
"""Logs an event in the chore assignment history."""
|
||||||
|
history_entry = ChoreAssignmentHistory(
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
changed_by_user_id=changed_by_user_id,
|
||||||
|
event_type=event_type,
|
||||||
|
event_data=event_data or {},
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(history_entry)
|
||||||
|
return history_entry
|
||||||
|
|
||||||
|
async def get_chore_history(db: AsyncSession, chore_id: int) -> List[ChoreHistory]:
|
||||||
|
"""Gets all history for a specific chore."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChoreHistory)
|
||||||
|
.where(ChoreHistory.chore_id == chore_id)
|
||||||
|
.options(selectinload(ChoreHistory.changed_by_user))
|
||||||
|
.order_by(ChoreHistory.timestamp.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_assignment_history(db: AsyncSession, assignment_id: int) -> List[ChoreAssignmentHistory]:
|
||||||
|
"""Gets all history for a specific assignment."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChoreAssignmentHistory)
|
||||||
|
.where(ChoreAssignmentHistory.assignment_id == assignment_id)
|
||||||
|
.options(selectinload(ChoreAssignmentHistory.changed_by_user))
|
||||||
|
.order_by(ChoreAssignmentHistory.timestamp.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[ChoreHistory]:
|
||||||
|
"""Gets all chore-related history for a group, including chore-specific and group-level events."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChoreHistory)
|
||||||
|
.where(ChoreHistory.group_id == group_id)
|
||||||
|
.options(
|
||||||
|
selectinload(ChoreHistory.changed_by_user),
|
||||||
|
selectinload(ChoreHistory.chore) # Also load chore info if available
|
||||||
|
)
|
||||||
|
.order_by(ChoreHistory.timestamp.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
120
be/app/crud/schedule.py
Normal file
120
be/app/crud/schedule.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# be/app/crud/schedule.py
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import List
|
||||||
|
from itertools import cycle
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.models import Chore, Group, User, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
|
||||||
|
from app.crud.group import get_group_by_id
|
||||||
|
from app.crud.history import create_chore_history_entry
|
||||||
|
from app.core.exceptions import GroupNotFoundError, ChoreOperationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def generate_group_chore_schedule(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
group_id: int,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
user_id: int, # The user initiating the action
|
||||||
|
member_ids: List[int] = None
|
||||||
|
) -> List[ChoreAssignment]:
|
||||||
|
"""
|
||||||
|
Generates a round-robin chore schedule for all group chores within a date range.
|
||||||
|
"""
|
||||||
|
if start_date > end_date:
|
||||||
|
raise ChoreOperationError("Start date cannot be after end date.")
|
||||||
|
|
||||||
|
group = await get_group_by_id(db, group_id)
|
||||||
|
if not group:
|
||||||
|
raise GroupNotFoundError(group_id)
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
# If no members are specified, use all members from the group
|
||||||
|
members_result = await db.execute(
|
||||||
|
select(UserGroup.user_id).where(UserGroup.group_id == group_id)
|
||||||
|
)
|
||||||
|
member_ids = members_result.scalars().all()
|
||||||
|
|
||||||
|
if not member_ids:
|
||||||
|
raise ChoreOperationError("Cannot generate schedule with no members.")
|
||||||
|
|
||||||
|
# Fetch all chores belonging to this group
|
||||||
|
chores_result = await db.execute(
|
||||||
|
select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group)
|
||||||
|
)
|
||||||
|
group_chores = chores_result.scalars().all()
|
||||||
|
if not group_chores:
|
||||||
|
logger.info(f"No chores found in group {group_id} to generate a schedule for.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
member_cycle = cycle(member_ids)
|
||||||
|
new_assignments = []
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
for chore in group_chores:
|
||||||
|
# Check if a chore is due on the current day based on its frequency
|
||||||
|
# This is a simplified check. A more robust system would use the chore's next_due_date
|
||||||
|
# and frequency to see if it falls on the current_date.
|
||||||
|
# For this implementation, we assume we generate assignments for ALL chores on ALL days
|
||||||
|
# in the range, which might not be desired.
|
||||||
|
# A better approach is needed here. Let's assume for now we just create assignments for each chore
|
||||||
|
# on its *next* due date if it falls within the range.
|
||||||
|
|
||||||
|
if start_date <= chore.next_due_date <= end_date:
|
||||||
|
# Check if an assignment for this chore on this due date already exists
|
||||||
|
existing_assignment_result = await db.execute(
|
||||||
|
select(ChoreAssignment.id)
|
||||||
|
.where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if existing_assignment_result.scalar_one_or_none():
|
||||||
|
logger.info(f"Skipping assignment for chore '{chore.name}' on {chore.next_due_date} as it already exists.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
assigned_to_user_id = next(member_cycle)
|
||||||
|
|
||||||
|
assignment = ChoreAssignment(
|
||||||
|
chore_id=chore.id,
|
||||||
|
assigned_to_user_id=assigned_to_user_id,
|
||||||
|
due_date=chore.next_due_date, # Assign on the chore's own next_due_date
|
||||||
|
is_complete=False
|
||||||
|
)
|
||||||
|
db.add(assignment)
|
||||||
|
new_assignments.append(assignment)
|
||||||
|
logger.info(f"Created assignment for chore '{chore.name}' to user {assigned_to_user_id} on {chore.next_due_date}")
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
if not new_assignments:
|
||||||
|
logger.info(f"No new assignments were generated for group {group_id} in the specified date range.")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Log a single group-level event for the schedule generation
|
||||||
|
await create_chore_history_entry(
|
||||||
|
db,
|
||||||
|
chore_id=None, # This is a group-level event
|
||||||
|
group_id=group_id,
|
||||||
|
changed_by_user_id=user_id,
|
||||||
|
event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED,
|
||||||
|
event_data={
|
||||||
|
"start_date": start_date.isoformat(),
|
||||||
|
"end_date": end_date.isoformat(),
|
||||||
|
"member_ids": member_ids,
|
||||||
|
"assignments_created": len(new_assignments)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Refresh assignments to load relationships if needed, although not strictly necessary
|
||||||
|
# as the objects are already in the session.
|
||||||
|
for assign in new_assignments:
|
||||||
|
await db.refresh(assign)
|
||||||
|
|
||||||
|
return new_assignments
|
@ -24,6 +24,7 @@ from sqlalchemy import (
|
|||||||
Date # Added Date for Chore model
|
Date # Added Date for Chore model
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, backref
|
from sqlalchemy.orm import relationship, backref
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
@ -71,6 +72,20 @@ class ChoreTypeEnum(enum.Enum):
|
|||||||
personal = "personal"
|
personal = "personal"
|
||||||
group = "group"
|
group = "group"
|
||||||
|
|
||||||
|
class ChoreHistoryEventTypeEnum(str, enum.Enum):
|
||||||
|
CREATED = "created"
|
||||||
|
UPDATED = "updated"
|
||||||
|
DELETED = "deleted"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
REOPENED = "reopened"
|
||||||
|
ASSIGNED = "assigned"
|
||||||
|
UNASSIGNED = "unassigned"
|
||||||
|
REASSIGNED = "reassigned"
|
||||||
|
SCHEDULE_GENERATED = "schedule_generated"
|
||||||
|
# Add more specific events as needed
|
||||||
|
DUE_DATE_CHANGED = "due_date_changed"
|
||||||
|
DETAILS_CHANGED = "details_changed"
|
||||||
|
|
||||||
# --- User Model ---
|
# --- User Model ---
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
@ -109,6 +124,11 @@ 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")
|
||||||
# --- End Relationships for Chores ---
|
# --- End Relationships for Chores ---
|
||||||
|
|
||||||
|
# --- History Relationships ---
|
||||||
|
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")
|
||||||
|
# --- End History Relationships ---
|
||||||
|
|
||||||
|
|
||||||
# --- Group Model ---
|
# --- Group Model ---
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
@ -137,6 +157,10 @@ class Group(Base):
|
|||||||
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
|
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
|
||||||
# --- End Relationship for Chores ---
|
# --- End Relationship for Chores ---
|
||||||
|
|
||||||
|
# --- History Relationships ---
|
||||||
|
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
|
||||||
|
# --- End History Relationships ---
|
||||||
|
|
||||||
|
|
||||||
# --- UserGroup Association Model ---
|
# --- UserGroup Association Model ---
|
||||||
class UserGroup(Base):
|
class UserGroup(Base):
|
||||||
@ -383,6 +407,7 @@ class Chore(Base):
|
|||||||
group = relationship("Group", back_populates="chores")
|
group = relationship("Group", back_populates="chores")
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
# --- ChoreAssignment Model ---
|
# --- ChoreAssignment Model ---
|
||||||
@ -403,6 +428,7 @@ class ChoreAssignment(Base):
|
|||||||
# --- Relationships ---
|
# --- Relationships ---
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
# === NEW: RecurrencePattern Model ===
|
# === NEW: RecurrencePattern Model ===
|
||||||
@ -430,3 +456,35 @@ class RecurrencePattern(Base):
|
|||||||
|
|
||||||
|
|
||||||
# === END: RecurrencePattern Model ===
|
# === END: RecurrencePattern Model ===
|
||||||
|
|
||||||
|
# === NEW: Chore History Models ===
|
||||||
|
|
||||||
|
class ChoreHistory(Base):
|
||||||
|
__tablename__ = "chore_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True) # For group-level events
|
||||||
|
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
|
||||||
|
event_data = Column(JSONB, nullable=True) # e.g., {'field': 'name', 'old': 'Old', 'new': 'New'}
|
||||||
|
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable if system-generated
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# --- Relationships ---
|
||||||
|
chore = relationship("Chore", back_populates="history")
|
||||||
|
group = relationship("Group", back_populates="chore_history")
|
||||||
|
changed_by_user = relationship("User", back_populates="chore_history_entries")
|
||||||
|
|
||||||
|
class ChoreAssignmentHistory(Base):
|
||||||
|
__tablename__ = "chore_assignment_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) # Reusing enum
|
||||||
|
event_data = Column(JSONB, nullable=True)
|
||||||
|
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# --- Relationships ---
|
||||||
|
assignment = relationship("ChoreAssignment", back_populates="history")
|
||||||
|
changed_by_user = relationship("User", back_populates="assignment_history_entries")
|
||||||
|
@ -1,13 +1,37 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Any
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
# Assuming ChoreFrequencyEnum is imported from models
|
# Assuming ChoreFrequencyEnum is imported from models
|
||||||
# Adjust the import path if necessary based on your project structure.
|
# Adjust the import path if necessary based on your project structure.
|
||||||
# e.g., from app.models import ChoreFrequencyEnum
|
# e.g., from app.models import ChoreFrequencyEnum
|
||||||
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel # For UserPublic relation
|
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # For UserPublic relation
|
||||||
from .user import UserPublic # For embedding user information
|
from .user import UserPublic # For embedding user information
|
||||||
|
|
||||||
|
# Forward declaration for circular dependencies
|
||||||
|
class ChoreAssignmentPublic(BaseModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# History Schemas
|
||||||
|
class ChoreHistoryPublic(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_type: ChoreHistoryEventTypeEnum
|
||||||
|
event_data: Optional[dict[str, Any]] = None
|
||||||
|
changed_by_user: Optional[UserPublic] = None
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
class ChoreAssignmentHistoryPublic(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_type: ChoreHistoryEventTypeEnum
|
||||||
|
event_data: Optional[dict[str, Any]] = None
|
||||||
|
changed_by_user: Optional[UserPublic] = None
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# Chore Schemas
|
# Chore Schemas
|
||||||
class ChoreBase(BaseModel):
|
class ChoreBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -75,7 +99,8 @@ class ChorePublic(ChoreBase):
|
|||||||
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
|
||||||
# group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed
|
assignments: List[ChoreAssignmentPublic] = []
|
||||||
|
history: List[ChoreHistoryPublic] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@ -92,6 +117,7 @@ class ChoreAssignmentUpdate(BaseModel):
|
|||||||
# Only completion status and perhaps due_date can be updated for an assignment
|
# Only completion status and perhaps due_date can be updated for an assignment
|
||||||
is_complete: Optional[bool] = None
|
is_complete: Optional[bool] = None
|
||||||
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed
|
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed
|
||||||
|
assigned_to_user_id: Optional[int] = None # For reassigning the chore
|
||||||
|
|
||||||
class ChoreAssignmentPublic(ChoreAssignmentBase):
|
class ChoreAssignmentPublic(ChoreAssignmentBase):
|
||||||
id: int
|
id: int
|
||||||
@ -102,10 +128,11 @@ class ChoreAssignmentPublic(ChoreAssignmentBase):
|
|||||||
# Embed ChorePublic and UserPublic for richer responses
|
# Embed ChorePublic and UserPublic for richer responses
|
||||||
chore: Optional[ChorePublic] = None
|
chore: Optional[ChorePublic] = None
|
||||||
assigned_user: Optional[UserPublic] = None
|
assigned_user: Optional[UserPublic] = None
|
||||||
|
history: List[ChoreAssignmentHistoryPublic] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
|
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
|
||||||
# We can update forward refs after all models are defined.
|
# We can update forward refs after all models are defined.
|
||||||
# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings
|
ChorePublic.model_rebuild()
|
||||||
# ChoreAssignmentPublic.model_rebuild()
|
ChoreAssignmentPublic.model_rebuild()
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
# app/schemas/group.py
|
# app/schemas/group.py
|
||||||
from pydantic import BaseModel, ConfigDict, computed_field
|
from pydantic import BaseModel, ConfigDict, computed_field
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from .user import UserPublic # Import UserPublic to represent members
|
from .user import UserPublic # Import UserPublic to represent members
|
||||||
|
from .chore import ChoreHistoryPublic # Import for history
|
||||||
|
|
||||||
# Properties to receive via API on creation
|
# Properties to receive via API on creation
|
||||||
class GroupCreate(BaseModel):
|
class GroupCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
# New schema for generating a schedule
|
||||||
|
class GroupScheduleGenerateRequest(BaseModel):
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
member_ids: Optional[List[int]] = None # Optional: if not provided, use all members
|
||||||
|
|
||||||
# Properties to return to client
|
# Properties to return to client
|
||||||
class GroupPublic(BaseModel):
|
class GroupPublic(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@ -16,6 +23,7 @@ class GroupPublic(BaseModel):
|
|||||||
created_by_id: int
|
created_by_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
member_associations: Optional[List["UserGroupPublic"]] = None
|
member_associations: Optional[List["UserGroupPublic"]] = None
|
||||||
|
chore_history: Optional[List[ChoreHistoryPublic]] = []
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
@ -40,3 +48,6 @@ class UserGroupPublic(BaseModel):
|
|||||||
# Properties stored in DB (if needed, often GroupPublic is sufficient)
|
# Properties stored in DB (if needed, often GroupPublic is sufficient)
|
||||||
# class GroupInDB(GroupPublic):
|
# class GroupInDB(GroupPublic):
|
||||||
# pass
|
# pass
|
||||||
|
|
||||||
|
# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic
|
||||||
|
GroupPublic.model_rebuild()
|
@ -917,11 +917,13 @@ select.form-input {
|
|||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: rgba(57, 62, 70, 0.7);
|
background-color: rgba(57, 62, 70, 0.9);
|
||||||
|
/* Increased opacity for better visibility */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 9999;
|
||||||
|
/* Increased z-index to ensure it's above other elements */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition:
|
transition:
|
||||||
@ -941,16 +943,18 @@ select.form-input {
|
|||||||
background-color: var(--light);
|
background-color: var(--light);
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 550px;
|
max-width: 850px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: scroll;
|
overflow-y: auto;
|
||||||
/* Can cause tooltip clipping */
|
/* Changed from scroll to auto */
|
||||||
transform: scale(0.95) translateY(-20px);
|
transform: scale(0.95) translateY(-20px);
|
||||||
transition: transform var(--transition-speed) var(--transition-ease-out);
|
transition: transform var(--transition-speed) var(--transition-ease-out);
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
z-index: 10000;
|
||||||
|
/* Ensure modal content is above backdrop */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-container::before {
|
.modal-container::before {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
export const API_VERSION = 'v1'
|
export const API_VERSION = 'v1'
|
||||||
|
|
||||||
// API Base URL
|
// API Base URL
|
||||||
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev'
|
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
// API Endpoints
|
// API Endpoints
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
@ -33,7 +33,6 @@ export const API_ENDPOINTS = {
|
|||||||
BASE: '/lists',
|
BASE: '/lists',
|
||||||
BY_ID: (id: string) => `/lists/${id}`,
|
BY_ID: (id: string) => `/lists/${id}`,
|
||||||
STATUS: (id: string) => `/lists/${id}/status`,
|
STATUS: (id: string) => `/lists/${id}/status`,
|
||||||
STATUSES: '/lists/statuses',
|
|
||||||
ITEMS: (listId: string) => `/lists/${listId}/items`,
|
ITEMS: (listId: string) => `/lists/${listId}/items`,
|
||||||
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
|
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
|
||||||
EXPENSES: (listId: string) => `/lists/${listId}/expenses`,
|
EXPENSES: (listId: string) => `/lists/${listId}/expenses`,
|
||||||
@ -62,13 +61,15 @@ export const API_ENDPOINTS = {
|
|||||||
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
|
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
|
||||||
ROLES: (groupId: string) => `/groups/${groupId}/roles`,
|
ROLES: (groupId: string) => `/groups/${groupId}/roles`,
|
||||||
ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`,
|
ROLE: (groupId: string, roleId: string) => `/groups/${groupId}/roles/${roleId}`,
|
||||||
|
GENERATE_SCHEDULE: (groupId: string) => `/groups/${groupId}/chores/generate-schedule`,
|
||||||
|
CHORE_HISTORY: (groupId: string) => `/groups/${groupId}/chores/history`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Invites
|
// Invites
|
||||||
INVITES: {
|
INVITES: {
|
||||||
BASE: '/invites',
|
BASE: '/invites',
|
||||||
BY_ID: (id: string) => `/invites/${id}`,
|
BY_ID: (id: string) => `/invites/${id}`,
|
||||||
ACCEPT: '/invites/accept',
|
ACCEPT: (id: string) => `/invites/accept/${id}`,
|
||||||
DECLINE: (id: string) => `/invites/decline/${id}`,
|
DECLINE: (id: string) => `/invites/decline/${id}`,
|
||||||
REVOKE: (id: string) => `/invites/revoke/${id}`,
|
REVOKE: (id: string) => `/invites/revoke/${id}`,
|
||||||
LIST: '/invites',
|
LIST: '/invites',
|
||||||
@ -120,4 +121,12 @@ export const API_ENDPOINTS = {
|
|||||||
METRICS: '/health/metrics',
|
METRICS: '/health/metrics',
|
||||||
LOGS: '/health/logs',
|
LOGS: '/health/logs',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
CHORES: {
|
||||||
|
BASE: '/chores',
|
||||||
|
BY_ID: (id: number) => `/chores/${id}`,
|
||||||
|
HISTORY: (id: number) => `/chores/${id}/history`,
|
||||||
|
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
|
||||||
|
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from './api-config';
|
import { API_BASE_URL, API_VERSION } from './api-config';
|
||||||
|
export { API_ENDPOINTS } from './api-config';
|
||||||
|
|
||||||
// Helper function to get full API URL
|
// Helper function to get full API URL
|
||||||
export const getApiUrl = (endpoint: string): string => {
|
export const getApiUrl = (endpoint: string): string => {
|
||||||
@ -14,5 +15,3 @@ export const apiClient = {
|
|||||||
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
|
patch: (endpoint: string, data = {}, config = {}) => api.patch(getApiUrl(endpoint), data, config),
|
||||||
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
|
delete: (endpoint: string, config = {}) => api.delete(getApiUrl(endpoint), config),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { API_ENDPOINTS };
|
|
File diff suppressed because it is too large
Load Diff
@ -73,7 +73,10 @@
|
|||||||
"groupNameRequired": "Group name is required",
|
"groupNameRequired": "Group name is required",
|
||||||
"createFailed": "Failed to create group. Please try again.",
|
"createFailed": "Failed to create group. Please try again.",
|
||||||
"inviteCodeRequired": "Invite code is required",
|
"inviteCodeRequired": "Invite code is required",
|
||||||
"joinFailed": "Failed to join group. Please check the invite code and try again."
|
"joinFailed": "Failed to join group. Please check the invite code and try again.",
|
||||||
|
"invalidDataFromServer": "Invalid data received from server.",
|
||||||
|
"createFailedConsole": "Error creating group:",
|
||||||
|
"joinFailedConsole": "Error joining group:"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"groupCreatedSuccess": "Group '{groupName}' created successfully.",
|
"groupCreatedSuccess": "Group '{groupName}' created successfully.",
|
||||||
@ -85,7 +88,8 @@
|
|||||||
"authCallbackPage": {
|
"authCallbackPage": {
|
||||||
"redirecting": "Redirecting...",
|
"redirecting": "Redirecting...",
|
||||||
"errors": {
|
"errors": {
|
||||||
"authenticationFailed": "Authentication failed"
|
"authenticationFailed": "Authentication failed",
|
||||||
|
"noTokenProvided": "No token provided"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"choresPage": {
|
"choresPage": {
|
||||||
@ -125,7 +129,17 @@
|
|||||||
"save": "Save Changes",
|
"save": "Save Changes",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"editChore": "Edit Chore",
|
"editChore": "Edit Chore",
|
||||||
"createChore": "Create Chore"
|
"createChore": "Create Chore",
|
||||||
|
"intervalPlaceholder": "e.g., 10"
|
||||||
|
},
|
||||||
|
"consoleErrors": {
|
||||||
|
"loadFailed": "Failed to load all chores:",
|
||||||
|
"loadGroupsFailed": "Failed to load groups",
|
||||||
|
"createAssignmentForNewChoreFailed": "Failed to create assignment for new chore:",
|
||||||
|
"saveFailed": "Failed to save chore:",
|
||||||
|
"deleteFailed": "Failed to delete chore:",
|
||||||
|
"createAssignmentFailed": "Failed to create assignment:",
|
||||||
|
"updateCompletionStatusFailed": "Failed to update chore completion status:"
|
||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Confirm Deletion",
|
"title": "Confirm Deletion",
|
||||||
@ -160,10 +174,14 @@
|
|||||||
"title": "Group Members",
|
"title": "Group Members",
|
||||||
"defaultRole": "Member",
|
"defaultRole": "Member",
|
||||||
"removeButton": "Remove",
|
"removeButton": "Remove",
|
||||||
"emptyState": "No members found."
|
"emptyState": "No members found.",
|
||||||
|
"closeMenuLabel": "Close menu"
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
"title": "Invite Members",
|
"title": "Invite Members",
|
||||||
|
"description": "Invite new members by generating a shareable code.",
|
||||||
|
"addMemberButtonLabel": "Add member",
|
||||||
|
"closeInviteLabel": "Close invite",
|
||||||
"regenerateButton": "Regenerate Invite Code",
|
"regenerateButton": "Regenerate Invite Code",
|
||||||
"generateButton": "Generate Invite Code",
|
"generateButton": "Generate Invite Code",
|
||||||
"activeCodeLabel": "Current Active Invite Code:",
|
"activeCodeLabel": "Current Active Invite Code:",
|
||||||
@ -174,6 +192,15 @@
|
|||||||
"newDataInvalid": "New invite code data is invalid."
|
"newDataInvalid": "New invite code data is invalid."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"failedToFetchActiveInvite": "Failed to fetch active invite code.",
|
||||||
|
"failedToFetchGroupDetails": "Failed to fetch group details.",
|
||||||
|
"failedToLoadUpcomingChores": "Error loading upcoming chores:",
|
||||||
|
"failedToLoadRecentExpenses": "Error loading recent expenses:"
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"noActiveInvite": "No active invite code found for this group."
|
||||||
|
},
|
||||||
"chores": {
|
"chores": {
|
||||||
"title": "Group Chores",
|
"title": "Group Chores",
|
||||||
"manageButton": "Manage Chores",
|
"manageButton": "Manage Chores",
|
||||||
@ -191,6 +218,8 @@
|
|||||||
"settleShareButton": "Settle My Share",
|
"settleShareButton": "Settle My Share",
|
||||||
"activityLabel": "Activity:",
|
"activityLabel": "Activity:",
|
||||||
"byUser": "by",
|
"byUser": "by",
|
||||||
|
"fallbackUserName": "User ID: {userId}",
|
||||||
|
"activityByUserFallback": "User {userId}",
|
||||||
"splitTypes": {
|
"splitTypes": {
|
||||||
"equal": "Equal",
|
"equal": "Equal",
|
||||||
"exactAmounts": "Exact Amounts",
|
"exactAmounts": "Exact Amounts",
|
||||||
@ -526,5 +555,15 @@
|
|||||||
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
|
"sampleTodosHeader": "Sample Todos (from IndexPage data)",
|
||||||
"totalCountLabel": "Total count from meta:",
|
"totalCountLabel": "Total count from meta:",
|
||||||
"noTodos": "No todos to display."
|
"noTodos": "No todos to display."
|
||||||
|
},
|
||||||
|
"languageSelector": {
|
||||||
|
"title": "Language",
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,13 @@
|
|||||||
import en from './en.json';
|
import en from './en.json';
|
||||||
import de from './de.json';
|
import de from './de.json';
|
||||||
|
import nl from './nl.json';
|
||||||
import fr from './fr.json';
|
import fr from './fr.json';
|
||||||
import es from './es.json';
|
import es from './es.json';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
en,
|
en,
|
||||||
de,
|
de,
|
||||||
|
nl,
|
||||||
fr,
|
fr,
|
||||||
es
|
es
|
||||||
};
|
};
|
||||||
|
641
fe/src/i18n/nl.json
Normal file
641
fe/src/i18n/nl.json
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"hello": "Hallo"
|
||||||
|
},
|
||||||
|
"loginPage": {
|
||||||
|
"emailLabel": "E-mail",
|
||||||
|
"passwordLabel": "Wachtwoord",
|
||||||
|
"togglePasswordVisibilityLabel": "Wachtwoord zichtbaarheid wisselen",
|
||||||
|
"loginButton": "Inloggen",
|
||||||
|
"signupLink": "Geen account? Aanmelden",
|
||||||
|
"errors": {
|
||||||
|
"emailRequired": "E-mail is vereist",
|
||||||
|
"emailInvalid": "Ongeldig e-mailformaat",
|
||||||
|
"passwordRequired": "Wachtwoord is vereist",
|
||||||
|
"loginFailed": "Inloggen mislukt. Controleer uw gegevens."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"loginSuccess": "Succesvol ingelogd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"listsPage": {
|
||||||
|
"retryButton": "Opnieuw proberen",
|
||||||
|
"emptyState": {
|
||||||
|
"noListsForGroup": "Geen lijsten gevonden voor deze groep.",
|
||||||
|
"noListsYet": "U heeft nog geen lijsten.",
|
||||||
|
"personalGlobalInfo": "Maak een persoonlijke lijst of word lid van een groep om gedeelde lijsten te zien.",
|
||||||
|
"groupSpecificInfo": "Deze groep heeft nog geen lijsten."
|
||||||
|
},
|
||||||
|
"createNewListButton": "Nieuwe lijst maken",
|
||||||
|
"loadingLists": "Lijsten laden...",
|
||||||
|
"noDescription": "Geen beschrijving",
|
||||||
|
"addItemPlaceholder": "Nieuw item toevoegen...",
|
||||||
|
"createCard": {
|
||||||
|
"title": "+ Lijst"
|
||||||
|
},
|
||||||
|
"pageTitle": {
|
||||||
|
"forGroup": "Lijsten voor {groupName}",
|
||||||
|
"forGroupId": "Lijsten voor Groep {groupId}",
|
||||||
|
"myLists": "Mijn Lijsten"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"fetchFailed": "Ophalen van lijsten mislukt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groupsPage": {
|
||||||
|
"retryButton": "Opnieuw proberen",
|
||||||
|
"emptyState": {
|
||||||
|
"title": "Nog geen groepen!",
|
||||||
|
"description": "U bent nog geen lid van groepen. Maak er een aan of word lid met een uitnodigingscode.",
|
||||||
|
"createButton": "Nieuwe groep maken"
|
||||||
|
},
|
||||||
|
"groupCard": {
|
||||||
|
"newListButton": "Lijst"
|
||||||
|
},
|
||||||
|
"createCard": {
|
||||||
|
"title": "+ Groep"
|
||||||
|
},
|
||||||
|
"joinGroup": {
|
||||||
|
"title": "Lid worden van een groep met uitnodigingscode",
|
||||||
|
"inputLabel": "Voer uitnodigingscode in",
|
||||||
|
"inputPlaceholder": "Voer uitnodigingscode in",
|
||||||
|
"joinButton": "Deelnemen"
|
||||||
|
},
|
||||||
|
"createDialog": {
|
||||||
|
"title": "Nieuwe groep maken",
|
||||||
|
"closeButtonLabel": "Sluiten",
|
||||||
|
"groupNameLabel": "Groepsnaam",
|
||||||
|
"cancelButton": "Annuleren",
|
||||||
|
"createButton": "Maken"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"fetchFailed": "Laden van groepen mislukt",
|
||||||
|
"groupNameRequired": "Groepsnaam is vereist",
|
||||||
|
"createFailed": "Maken van groep mislukt. Probeer het opnieuw.",
|
||||||
|
"inviteCodeRequired": "Uitnodigingscode is vereist",
|
||||||
|
"joinFailed": "Deelnemen aan groep mislukt. Controleer de uitnodigingscode en probeer het opnieuw.",
|
||||||
|
"invalidDataFromServer": "Ongeldige gegevens ontvangen van server.",
|
||||||
|
"createFailedConsole": "Fout bij het maken van groep:",
|
||||||
|
"joinFailedConsole": "Fout bij het deelnemen aan groep:"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"groupCreatedSuccess": "Groep '{groupName}' succesvol aangemaakt.",
|
||||||
|
"joinSuccessNamed": "Succesvol lid geworden van groep '{groupName}'.",
|
||||||
|
"joinSuccessGeneric": "Succesvol lid geworden van groep.",
|
||||||
|
"listCreatedSuccess": "Lijst '{listName}' succesvol aangemaakt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authCallbackPage": {
|
||||||
|
"redirecting": "Bezig met omleiden...",
|
||||||
|
"errors": {
|
||||||
|
"authenticationFailed": "Authenticatie mislukt",
|
||||||
|
"noTokenProvided": "Geen token opgegeven"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"choresPage": {
|
||||||
|
"title": "Taken",
|
||||||
|
"tabs": {
|
||||||
|
"overdue": "Achterstallig",
|
||||||
|
"today": "Vandaag",
|
||||||
|
"upcoming": "Aankomend",
|
||||||
|
"allPending": "Alle openstaande",
|
||||||
|
"completed": "Voltooid"
|
||||||
|
},
|
||||||
|
"viewToggle": {
|
||||||
|
"calendarLabel": "Kalenderweergave",
|
||||||
|
"calendarText": "Kalender",
|
||||||
|
"listLabel": "Lijstweergave",
|
||||||
|
"listText": "Lijst"
|
||||||
|
},
|
||||||
|
"newChoreButtonLabel": "Nieuwe taak",
|
||||||
|
"newChoreButtonText": "Nieuwe taak",
|
||||||
|
"loadingState": {
|
||||||
|
"loadingChores": "Taken laden..."
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"prevMonthLabel": "Vorige maand",
|
||||||
|
"nextMonthLabel": "Volgende maand",
|
||||||
|
"weekdays": {
|
||||||
|
"sun": "Zo",
|
||||||
|
"mon": "Ma",
|
||||||
|
"tue": "Di",
|
||||||
|
"wed": "Wo",
|
||||||
|
"thu": "Do",
|
||||||
|
"fri": "Vr",
|
||||||
|
"sat": "Za"
|
||||||
|
},
|
||||||
|
"addChoreToDayLabel": "Taak aan deze dag toevoegen",
|
||||||
|
"emptyState": "Geen taken om weer te geven voor deze periode."
|
||||||
|
},
|
||||||
|
"listView": {
|
||||||
|
"choreTypePersonal": "Persoonlijk",
|
||||||
|
"choreTypeGroupFallback": "Groep",
|
||||||
|
"completedDatePrefix": "Voltooid:",
|
||||||
|
"actions": {
|
||||||
|
"doneTitle": "Markeer als voltooid",
|
||||||
|
"doneText": "Gedaan",
|
||||||
|
"undoTitle": "Markeer als niet voltooid",
|
||||||
|
"undoText": "Ongedaan maken",
|
||||||
|
"editTitle": "Bewerken",
|
||||||
|
"editLabel": "Taak bewerken",
|
||||||
|
"editText": "Bewerken",
|
||||||
|
"deleteTitle": "Verwijderen",
|
||||||
|
"deleteLabel": "Taak verwijderen",
|
||||||
|
"deleteText": "Verwijderen"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"message": "Geen taken in deze weergave. Goed gedaan!",
|
||||||
|
"viewAllButton": "Alle openstaande bekijken"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"choreModal": {
|
||||||
|
"editTitle": "Taak bewerken",
|
||||||
|
"newTitle": "Nieuwe taak",
|
||||||
|
"closeButtonLabel": "Modal sluiten",
|
||||||
|
"nameLabel": "Naam",
|
||||||
|
"namePlaceholder": "Voer taaknaam in",
|
||||||
|
"typeLabel": "Type",
|
||||||
|
"typePersonal": "Persoonlijk",
|
||||||
|
"typeGroup": "Groep",
|
||||||
|
"groupLabel": "Groep",
|
||||||
|
"groupSelectDefault": "Selecteer een groep",
|
||||||
|
"descriptionLabel": "Beschrijving",
|
||||||
|
"descriptionPlaceholder": "Voeg een beschrijving toe (optioneel)",
|
||||||
|
"frequencyLabel": "Frequentie",
|
||||||
|
"intervalLabel": "Interval (dagen)",
|
||||||
|
"intervalPlaceholder": "bijv. 10",
|
||||||
|
"dueDateLabel": "Vervaldatum",
|
||||||
|
"quickDueDateToday": "Vandaag",
|
||||||
|
"quickDueDateTomorrow": "Morgen",
|
||||||
|
"quickDueDateNextWeek": "Volgende week",
|
||||||
|
"cancelButton": "Annuleren",
|
||||||
|
"saveButton": "Opslaan"
|
||||||
|
},
|
||||||
|
"consoleErrors": {
|
||||||
|
"loadFailed": "Laden van alle taken mislukt:",
|
||||||
|
"loadGroupsFailed": "Laden van groepen mislukt",
|
||||||
|
"createAssignmentForNewChoreFailed": "Toewijzing voor nieuwe taak kon niet worden gemaakt:",
|
||||||
|
"saveFailed": "Opslaan van taak mislukt:",
|
||||||
|
"deleteFailed": "Verwijderen van taak mislukt:",
|
||||||
|
"createAssignmentFailed": "Toewijzing kon niet worden gemaakt:",
|
||||||
|
"updateCompletionStatusFailed": "Voltooiingsstatus van taak kon niet worden bijgewerkt:"
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"title": "Taak verwijderen",
|
||||||
|
"confirmationText": "Weet u zeker dat u deze taak wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||||
|
"deleteButton": "Verwijderen"
|
||||||
|
},
|
||||||
|
"shortcutsModal": {
|
||||||
|
"title": "Sneltoetsen",
|
||||||
|
"descNewChore": "Nieuwe taak",
|
||||||
|
"descToggleView": "Weergave wisselen (Lijst/Kalender)",
|
||||||
|
"descToggleShortcuts": "Sneltoetsen tonen/verbergen",
|
||||||
|
"descCloseModal": "Open Modal/Dialoog sluiten"
|
||||||
|
},
|
||||||
|
"frequencyOptions": {
|
||||||
|
"oneTime": "Eenmalig",
|
||||||
|
"daily": "Dagelijks",
|
||||||
|
"weekly": "Wekelijks",
|
||||||
|
"monthly": "Maandelijks",
|
||||||
|
"custom": "Aangepast"
|
||||||
|
},
|
||||||
|
"frequency": {
|
||||||
|
"customInterval": "Elke {n} dag | Elke {n} dagen"
|
||||||
|
},
|
||||||
|
"formatters": {
|
||||||
|
"noDueDate": "Geen vervaldatum",
|
||||||
|
"dueToday": "Vandaag te doen",
|
||||||
|
"dueTomorrow": "Morgen te doen",
|
||||||
|
"overdueFull": "Achterstallig: {date}",
|
||||||
|
"dueFull": "Vervalt op {date}",
|
||||||
|
"invalidDate": "Ongeldige datum"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"loadFailed": "Laden van taken mislukt.",
|
||||||
|
"loadGroupsFailed": "Laden van groepen mislukt.",
|
||||||
|
"updateSuccess": "Taak '{name}' succesvol bijgewerkt.",
|
||||||
|
"createSuccess": "Taak '{name}' succesvol aangemaakt.",
|
||||||
|
"updateFailed": "Bijwerken van taak mislukt.",
|
||||||
|
"createFailed": "Aanmaken van taak mislukt.",
|
||||||
|
"deleteSuccess": "Taak '{name}' succesvol verwijderd.",
|
||||||
|
"deleteFailed": "Verwijderen van taak mislukt.",
|
||||||
|
"markedDone": "{name} gemarkeerd als voltooid.",
|
||||||
|
"markedNotDone": "{name} gemarkeerd als niet voltooid.",
|
||||||
|
"statusUpdateFailed": "Status van taak kon niet worden bijgewerkt.",
|
||||||
|
"createAssignmentFailed": "Toewijzing voor taak kon niet worden gemaakt."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Taaknaam is vereist.",
|
||||||
|
"groupRequired": "Selecteer een groep voor groepstaken.",
|
||||||
|
"intervalRequired": "Aangepast interval moet minimaal 1 dag zijn.",
|
||||||
|
"dueDateRequired": "Vervaldatum is vereist.",
|
||||||
|
"invalidDueDate": "Ongeldig formaat vervaldatum."
|
||||||
|
},
|
||||||
|
"unsavedChangesConfirmation": "U heeft niet-opgeslagen wijzigingen in het taakformulier. Weet u zeker dat u wilt vertrekken?"
|
||||||
|
},
|
||||||
|
"errorNotFoundPage": {
|
||||||
|
"errorCode": "404",
|
||||||
|
"errorMessage": "Oeps. Hier is niets...",
|
||||||
|
"goHomeButton": "Naar de startpagina"
|
||||||
|
},
|
||||||
|
"groupDetailPage": {
|
||||||
|
"loadingLabel": "Groepsdetails laden...",
|
||||||
|
"retryButton": "Opnieuw proberen",
|
||||||
|
"groupNotFound": "Groep niet gevonden of er is een fout opgetreden.",
|
||||||
|
"members": {
|
||||||
|
"title": "Groepsleden",
|
||||||
|
"defaultRole": "Lid",
|
||||||
|
"removeButton": "Verwijderen",
|
||||||
|
"emptyState": "Geen leden gevonden.",
|
||||||
|
"closeMenuLabel": "Menu sluiten"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"title": "Leden uitnodigen",
|
||||||
|
"description": "Nodig nieuwe leden uit door een deelbare code te genereren.",
|
||||||
|
"addMemberButtonLabel": "Lid toevoegen",
|
||||||
|
"closeInviteLabel": "Uitnodiging sluiten",
|
||||||
|
"regenerateButton": "Uitnodigingscode opnieuw genereren",
|
||||||
|
"generateButton": "Uitnodigingscode genereren",
|
||||||
|
"activeCodeLabel": "Huidige actieve uitnodigingscode:",
|
||||||
|
"copyButtonLabel": "Kopieer uitnodigingscode",
|
||||||
|
"copySuccess": "Uitnodigingscode gekopieerd naar klembord!",
|
||||||
|
"emptyState": "Geen actieve uitnodigingscode. Klik op de knop hierboven om er een te genereren.",
|
||||||
|
"errors": {
|
||||||
|
"newDataInvalid": "Gegevens van nieuwe uitnodigingscode zijn ongeldig."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"failedToFetchActiveInvite": "Ophalen van actieve uitnodigingscode mislukt.",
|
||||||
|
"failedToFetchGroupDetails": "Ophalen van groepsdetails mislukt.",
|
||||||
|
"failedToLoadUpcomingChores": "Fout bij het laden van aankomende taken:",
|
||||||
|
"failedToLoadRecentExpenses": "Fout bij het laden van recente uitgaven:"
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"noActiveInvite": "Geen actieve uitnodigingscode gevonden voor deze groep."
|
||||||
|
},
|
||||||
|
"chores": {
|
||||||
|
"title": "Groepstaken",
|
||||||
|
"manageButton": "Taken beheren",
|
||||||
|
"duePrefix": "Vervalt:",
|
||||||
|
"emptyState": "Geen taken gepland. Klik op \"Taken beheren\" om er enkele aan te maken!"
|
||||||
|
},
|
||||||
|
"expenses": {
|
||||||
|
"title": "Groepsuitgaven",
|
||||||
|
"manageButton": "Uitgaven beheren",
|
||||||
|
"emptyState": "Geen uitgaven geregistreerd. Klik op \"Uitgaven beheren\" om er enkele toe te voegen!",
|
||||||
|
"paidBy": "Betaald door:",
|
||||||
|
"owes": "is verschuldigd",
|
||||||
|
"paidAmount": "Betaald:",
|
||||||
|
"onDate": "op",
|
||||||
|
"settleShareButton": "Mijn deel vereffenen",
|
||||||
|
"activityLabel": "Activiteit:",
|
||||||
|
"byUser": "door",
|
||||||
|
"fallbackUserName": "Gebruikers-ID: {userId}",
|
||||||
|
"activityByUserFallback": "Gebruiker {userId}",
|
||||||
|
"splitTypes": {
|
||||||
|
"equal": "Gelijk",
|
||||||
|
"exactAmounts": "Exacte bedragen",
|
||||||
|
"percentage": "Percentage",
|
||||||
|
"shares": "Aandelen",
|
||||||
|
"itemBased": "Op item gebaseerd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"fetchDetailsFailed": "Ophalen van groepsdetails mislukt.",
|
||||||
|
"fetchInviteFailed": "Ophalen van actieve uitnodigingscode mislukt.",
|
||||||
|
"generateInviteSuccess": "Nieuwe uitnodigingscode succesvol gegenereerd!",
|
||||||
|
"generateInviteError": "Genereren van uitnodigingscode mislukt.",
|
||||||
|
"clipboardNotSupported": "Klembord niet ondersteund of geen code om te kopiëren.",
|
||||||
|
"copyInviteFailed": "Kopiëren van uitnodigingscode mislukt.",
|
||||||
|
"removeMemberSuccess": "Lid succesvol verwijderd",
|
||||||
|
"removeMemberFailed": "Verwijderen van lid mislukt",
|
||||||
|
"loadExpensesFailed": "Laden van recente uitgaven mislukt.",
|
||||||
|
"cannotSettleOthersShares": "U kunt alleen uw eigen aandelen vereffenen.",
|
||||||
|
"settlementDataMissing": "Kan vereffening niet verwerken: gegevens ontbreken.",
|
||||||
|
"settleShareSuccess": "Aandeel succesvol vereffend!",
|
||||||
|
"settleShareFailed": "Vereffenen van aandeel mislukt."
|
||||||
|
},
|
||||||
|
"loading": {
|
||||||
|
"settlement": "Bezig met vereffenen..."
|
||||||
|
},
|
||||||
|
"settleShareModal": {
|
||||||
|
"title": "Aandeel vereffenen",
|
||||||
|
"settleAmountFor": "Bedrag vereffenen voor {userName}:",
|
||||||
|
"amountLabel": "Bedrag",
|
||||||
|
"cancelButton": "Annuleren",
|
||||||
|
"confirmButton": "Bevestigen",
|
||||||
|
"errors": {
|
||||||
|
"enterAmount": "Voer een bedrag in.",
|
||||||
|
"positiveAmount": "Voer een positief bedrag in.",
|
||||||
|
"exceedsRemaining": "Bedrag mag resterend bedrag niet overschrijden: {amount}.",
|
||||||
|
"noSplitSelected": "Fout: Geen verdeling geselecteerd."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"settled": "Vereffend",
|
||||||
|
"partiallySettled": "Gedeeltelijk vereffend",
|
||||||
|
"unsettled": "Openstaand",
|
||||||
|
"paid": "Betaald",
|
||||||
|
"partiallyPaid": "Gedeeltelijk betaald",
|
||||||
|
"unpaid": "Onbetaald",
|
||||||
|
"unknown": "Onbekende status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"accountPage": {
|
||||||
|
"title": "Accountinstellingen",
|
||||||
|
"loadingProfile": "Profiel laden...",
|
||||||
|
"retryButton": "Opnieuw proberen",
|
||||||
|
"profileSection": {
|
||||||
|
"header": "Profielinformatie",
|
||||||
|
"nameLabel": "Naam",
|
||||||
|
"emailLabel": "E-mail",
|
||||||
|
"saveButton": "Wijzigingen opslaan"
|
||||||
|
},
|
||||||
|
"passwordSection": {
|
||||||
|
"header": "Wachtwoord wijzigen",
|
||||||
|
"currentPasswordLabel": "Huidig wachtwoord",
|
||||||
|
"newPasswordLabel": "Nieuw wachtwoord",
|
||||||
|
"changeButton": "Wachtwoord wijzigen"
|
||||||
|
},
|
||||||
|
"notificationsSection": {
|
||||||
|
"header": "Notificatievoorkeuren",
|
||||||
|
"emailNotificationsLabel": "E-mailnotificaties",
|
||||||
|
"emailNotificationsDescription": "Ontvang e-mailnotificaties voor belangrijke updates",
|
||||||
|
"listUpdatesLabel": "Lijstupdates",
|
||||||
|
"listUpdatesDescription": "Ontvang een melding wanneer lijsten worden bijgewerkt",
|
||||||
|
"groupActivitiesLabel": "Groepsactiviteiten",
|
||||||
|
"groupActivitiesDescription": "Ontvang meldingen voor groepsactiviteiten"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"profileLoadFailed": "Laden van profiel mislukt",
|
||||||
|
"profileUpdateSuccess": "Profiel succesvol bijgewerkt",
|
||||||
|
"profileUpdateFailed": "Bijwerken van profiel mislukt",
|
||||||
|
"passwordFieldsRequired": "Vul zowel het huidige als het nieuwe wachtwoordveld in.",
|
||||||
|
"passwordTooShort": "Nieuw wachtwoord moet minimaal 8 tekens lang zijn.",
|
||||||
|
"passwordChangeSuccess": "Wachtwoord succesvol gewijzigd",
|
||||||
|
"passwordChangeFailed": "Wijzigen van wachtwoord mislukt",
|
||||||
|
"preferencesUpdateSuccess": "Voorkeuren succesvol bijgewerkt",
|
||||||
|
"preferencesUpdateFailed": "Bijwerken van voorkeuren mislukt"
|
||||||
|
},
|
||||||
|
"saving": "Opslaan..."
|
||||||
|
},
|
||||||
|
"signupPage": {
|
||||||
|
"header": "Aanmelden",
|
||||||
|
"fullNameLabel": "Volledige naam",
|
||||||
|
"emailLabel": "E-mail",
|
||||||
|
"passwordLabel": "Wachtwoord",
|
||||||
|
"confirmPasswordLabel": "Bevestig wachtwoord",
|
||||||
|
"togglePasswordVisibility": "Wachtwoord zichtbaarheid wisselen",
|
||||||
|
"submitButton": "Aanmelden",
|
||||||
|
"loginLink": "Heeft u al een account? Inloggen",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Naam is vereist",
|
||||||
|
"emailRequired": "E-mail is vereist",
|
||||||
|
"emailInvalid": "Ongeldig e-mailformaat",
|
||||||
|
"passwordRequired": "Wachtwoord is vereist",
|
||||||
|
"passwordLength": "Wachtwoord moet minimaal 8 tekens lang zijn",
|
||||||
|
"confirmPasswordRequired": "Bevestig uw wachtwoord",
|
||||||
|
"passwordsNoMatch": "Wachtwoorden komen niet overeen"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"signupFailed": "Aanmelden mislukt. Probeer het opnieuw.",
|
||||||
|
"signupSuccess": "Account succesvol aangemaakt. Log in alstublieft."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"listDetailPage": {
|
||||||
|
"loading": {
|
||||||
|
"list": "Lijst laden...",
|
||||||
|
"items": "Items laden...",
|
||||||
|
"ocrProcessing": "Afbeelding verwerken...",
|
||||||
|
"addingOcrItems": "OCR-items toevoegen...",
|
||||||
|
"costSummary": "Samenvatting laden...",
|
||||||
|
"expenses": "Uitgaven laden...",
|
||||||
|
"settlement": "Bezig met vereffenen..."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"fetchFailed": "Laden van lijstdetails mislukt.",
|
||||||
|
"genericLoadFailure": "Groep niet gevonden of er is een fout opgetreden.",
|
||||||
|
"ocrNoItems": "Geen items uit de afbeelding gehaald.",
|
||||||
|
"ocrFailed": "Verwerken van afbeelding mislukt.",
|
||||||
|
"addItemFailed": "Toevoegen van item mislukt.",
|
||||||
|
"updateItemFailed": "Bijwerken van item mislukt.",
|
||||||
|
"updateItemPriceFailed": "Bijwerken van itemprijs mislukt.",
|
||||||
|
"deleteItemFailed": "Verwijderen van item mislukt.",
|
||||||
|
"addOcrItemsFailed": "Toevoegen van OCR-items mislukt.",
|
||||||
|
"fetchItemsFailed": "Laden van items mislukt: {errorMessage}",
|
||||||
|
"loadCostSummaryFailed": "Laden van kostensamenvatting mislukt."
|
||||||
|
},
|
||||||
|
"retryButton": "Opnieuw proberen",
|
||||||
|
"buttons": {
|
||||||
|
"addViaOcr": "Toevoegen via OCR",
|
||||||
|
"addItem": "Toevoegen",
|
||||||
|
"addItems": "Items toevoegen",
|
||||||
|
"cancel": "Annuleren",
|
||||||
|
"confirm": "Bevestigen",
|
||||||
|
"saveChanges": "Wijzigingen opslaan",
|
||||||
|
"close": "Sluiten",
|
||||||
|
"costSummary": "Kostensamenvatting"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"groupList": "Groepslijst",
|
||||||
|
"personalList": "Persoonlijke lijst"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"emptyState": {
|
||||||
|
"title": "Nog geen items!",
|
||||||
|
"message": "Voeg items toe via het onderstaande formulier."
|
||||||
|
},
|
||||||
|
"addItemForm": {
|
||||||
|
"placeholder": "Nieuw item toevoegen",
|
||||||
|
"quantityPlaceholder": "Aantal",
|
||||||
|
"itemNameSrLabel": "Naam nieuw item",
|
||||||
|
"quantitySrLabel": "Hoeveelheid"
|
||||||
|
},
|
||||||
|
"pricePlaceholder": "Prijs",
|
||||||
|
"editItemAriaLabel": "Item bewerken",
|
||||||
|
"deleteItemAriaLabel": "Item verwijderen"
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"ocr": {
|
||||||
|
"title": "Items toevoegen via OCR",
|
||||||
|
"uploadLabel": "Afbeelding uploaden"
|
||||||
|
},
|
||||||
|
"confirmation": {
|
||||||
|
"title": "Bevestiging"
|
||||||
|
},
|
||||||
|
"editItem": {
|
||||||
|
"title": "Item bewerken",
|
||||||
|
"nameLabel": "Itemnaam",
|
||||||
|
"quantityLabel": "Hoeveelheid"
|
||||||
|
},
|
||||||
|
"costSummary": {
|
||||||
|
"title": "Kostensamenvatting lijst",
|
||||||
|
"totalCostLabel": "Totale kosten lijst:",
|
||||||
|
"equalShareLabel": "Gelijk deel per gebruiker:",
|
||||||
|
"participantsLabel": "Deelnemende gebruikers:",
|
||||||
|
"userBalancesHeader": "Gebruikerssaldi",
|
||||||
|
"tableHeaders": {
|
||||||
|
"user": "Gebruiker",
|
||||||
|
"itemsAddedValue": "Waarde toegevoegde items",
|
||||||
|
"amountDue": "Verschuldigd bedrag",
|
||||||
|
"balance": "Saldo"
|
||||||
|
},
|
||||||
|
"emptyState": "Geen kostensamenvatting beschikbaar."
|
||||||
|
},
|
||||||
|
"settleShare": {
|
||||||
|
"title": "Aandeel vereffenen",
|
||||||
|
"settleAmountFor": "Bedrag vereffenen voor {userName}:",
|
||||||
|
"amountLabel": "Bedrag",
|
||||||
|
"errors": {
|
||||||
|
"enterAmount": "Voer een bedrag in.",
|
||||||
|
"positiveAmount": "Voer een positief bedrag in.",
|
||||||
|
"exceedsRemaining": "Bedrag mag resterend bedrag niet overschrijden: {amount}.",
|
||||||
|
"noSplitSelected": "Fout: Geen verdeling geselecteerd."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"confirmations": {
|
||||||
|
"updateMessage": "'{itemName}' markeren als {status}?",
|
||||||
|
"statusComplete": "voltooid",
|
||||||
|
"statusIncomplete": "onvolledig",
|
||||||
|
"deleteMessage": "'{itemName}' verwijderen? Dit kan niet ongedaan worden gemaakt."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"itemAddedSuccess": "Item succesvol toegevoegd.",
|
||||||
|
"itemsAddedSuccessOcr": "{count} item(s) succesvol toegevoegd via OCR.",
|
||||||
|
"itemUpdatedSuccess": "Item succesvol bijgewerkt.",
|
||||||
|
"itemDeleteSuccess": "Item succesvol verwijderd.",
|
||||||
|
"enterItemName": "Voer een itemnaam in.",
|
||||||
|
"costSummaryLoadFailed": "Laden van kostensamenvatting mislukt.",
|
||||||
|
"cannotSettleOthersShares": "U kunt alleen uw eigen aandelen vereffenen.",
|
||||||
|
"settlementDataMissing": "Kan vereffening niet verwerken: gegevens ontbreken.",
|
||||||
|
"settleShareSuccess": "Aandeel succesvol vereffend!",
|
||||||
|
"settleShareFailed": "Vereffenen van aandeel mislukt."
|
||||||
|
},
|
||||||
|
"expensesSection": {
|
||||||
|
"title": "Uitgaven",
|
||||||
|
"addExpenseButton": "Uitgave toevoegen",
|
||||||
|
"loading": "Uitgaven laden...",
|
||||||
|
"emptyState": "Nog geen uitgaven geregistreerd voor deze lijst.",
|
||||||
|
"paidBy": "Betaald door:",
|
||||||
|
"onDate": "op",
|
||||||
|
"owes": "is verschuldigd",
|
||||||
|
"paidAmount": "Betaald:",
|
||||||
|
"activityLabel": "Activiteit:",
|
||||||
|
"byUser": "door",
|
||||||
|
"settleShareButton": "Mijn deel vereffenen",
|
||||||
|
"retryButton": "Opnieuw proberen"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"settled": "Vereffend",
|
||||||
|
"partiallySettled": "Gedeeltelijk vereffend",
|
||||||
|
"unsettled": "Openstaand",
|
||||||
|
"paid": "Betaald",
|
||||||
|
"partiallyPaid": "Gedeeltelijk betaald",
|
||||||
|
"unpaid": "Onbetaald",
|
||||||
|
"unknown": "Onbekende status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"myChoresPage": {
|
||||||
|
"title": "Mijn toegewezen taken",
|
||||||
|
"showCompletedToggle": "Voltooide tonen",
|
||||||
|
"timelineHeaders": {
|
||||||
|
"overdue": "Achterstallig",
|
||||||
|
"today": "Vandaag te doen",
|
||||||
|
"thisWeek": "Deze week",
|
||||||
|
"later": "Later",
|
||||||
|
"completed": "Voltooid"
|
||||||
|
},
|
||||||
|
"choreCard": {
|
||||||
|
"personal": "Persoonlijk",
|
||||||
|
"group": "Groep",
|
||||||
|
"duePrefix": "Vervalt",
|
||||||
|
"completedPrefix": "Voltooid",
|
||||||
|
"dueToday": "Vandaag te doen",
|
||||||
|
"markCompleteButton": "Markeer als voltooid"
|
||||||
|
},
|
||||||
|
"frequencies": {
|
||||||
|
"one_time": "Eenmalig",
|
||||||
|
"daily": "Dagelijks",
|
||||||
|
"weekly": "Wekelijks",
|
||||||
|
"monthly": "Maandelijks",
|
||||||
|
"custom": "Aangepast",
|
||||||
|
"unknown": "Onbekende frequentie"
|
||||||
|
},
|
||||||
|
"dates": {
|
||||||
|
"invalidDate": "Ongeldige datum",
|
||||||
|
"unknownDate": "Onbekende datum"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"title": "Nog geen toewijzingen!",
|
||||||
|
"noAssignmentsPending": "U heeft geen openstaande taaktoewijzingen.",
|
||||||
|
"noAssignmentsAll": "U heeft geen taaktoewijzingen (voltooid of openstaand).",
|
||||||
|
"viewAllChoresButton": "Alle taken bekijken"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"loadFailed": "Laden van toewijzingen mislukt",
|
||||||
|
"markedComplete": "\"{choreName}\" gemarkeerd als voltooid!",
|
||||||
|
"markCompleteFailed": "Markeren van toewijzing als voltooid mislukt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"personalChoresPage": {
|
||||||
|
"title": "Persoonlijke taken",
|
||||||
|
"newChoreButton": "Nieuwe taak",
|
||||||
|
"editButton": "Bewerken",
|
||||||
|
"deleteButton": "Verwijderen",
|
||||||
|
"cancelButton": "Annuleren",
|
||||||
|
"saveButton": "Opslaan",
|
||||||
|
"modals": {
|
||||||
|
"editChoreTitle": "Taak bewerken",
|
||||||
|
"newChoreTitle": "Nieuwe taak",
|
||||||
|
"deleteChoreTitle": "Taak verwijderen"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"nameLabel": "Naam",
|
||||||
|
"descriptionLabel": "Beschrijving",
|
||||||
|
"frequencyLabel": "Frequentie",
|
||||||
|
"intervalLabel": "Interval (dagen)",
|
||||||
|
"dueDateLabel": "Volgende vervaldatum"
|
||||||
|
},
|
||||||
|
"deleteDialog": {
|
||||||
|
"confirmationText": "Weet u zeker dat u deze taak wilt verwijderen?"
|
||||||
|
},
|
||||||
|
"frequencies": {
|
||||||
|
"one_time": "Eenmalig",
|
||||||
|
"daily": "Dagelijks",
|
||||||
|
"weekly": "Wekelijks",
|
||||||
|
"monthly": "Maandelijks",
|
||||||
|
"custom": "Aangepast",
|
||||||
|
"unknown": "Onbekende frequentie"
|
||||||
|
},
|
||||||
|
"dates": {
|
||||||
|
"invalidDate": "Ongeldige datum",
|
||||||
|
"duePrefix": "Vervalt"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"loadFailed": "Laden van persoonlijke taken mislukt",
|
||||||
|
"updateSuccess": "Persoonlijke taak succesvol bijgewerkt",
|
||||||
|
"createSuccess": "Persoonlijke taak succesvol aangemaakt",
|
||||||
|
"saveFailed": "Opslaan van persoonlijke taak mislukt",
|
||||||
|
"deleteSuccess": "Persoonlijke taak succesvol verwijderd",
|
||||||
|
"deleteFailed": "Verwijderen van persoonlijke taak mislukt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexPage": {
|
||||||
|
"welcomeMessage": "Welkom bij de Valerie UI App",
|
||||||
|
"mainPageInfo": "Dit is de hoofdindexpagina.",
|
||||||
|
"sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)",
|
||||||
|
"totalCountLabel": "Totaal aantal uit meta:",
|
||||||
|
"noTodos": "Geen taken om weer te geven."
|
||||||
|
},
|
||||||
|
"languageSelector": {
|
||||||
|
"title": "Taal",
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,17 +2,40 @@
|
|||||||
<div class="main-layout">
|
<div class="main-layout">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="toolbar-title">mitlist</div>
|
<div class="toolbar-title">mitlist</div>
|
||||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
|
||||||
<button @click="toggleUserMenu" class="user-menu-button">
|
|
||||||
<!-- Placeholder for user icon -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
|
<div class="flex align-end">
|
||||||
<path d="M0 0h24v24H0z" fill="none" />
|
<div class="language-selector" v-if="authStore.isAuthenticated">
|
||||||
<path
|
<button @click="toggleLanguageMenu" class="language-menu-button">
|
||||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54">
|
||||||
</svg>
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
</button>
|
<path
|
||||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
d="m12.87 15.07-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
|
||||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
</svg>
|
||||||
|
<span class="current-language">{{ currentLanguageCode.toUpperCase() }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="languageMenuOpen" class="dropdown-menu language-dropdown" ref="languageMenuDropdown">
|
||||||
|
<div class="dropdown-header">{{ $t('languageSelector.title') }}</div>
|
||||||
|
<a v-for="(name, code) in availableLanguages" :key="code" href="#" @click.prevent="changeLanguage(code)"
|
||||||
|
class="language-option" :class="{ 'active': currentLanguageCode === code }">
|
||||||
|
{{ name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||||
|
<button @click="toggleUserMenu" class="user-menu-button">
|
||||||
|
<!-- Placeholder for user icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path
|
||||||
|
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
||||||
|
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -53,13 +76,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, defineComponent, onMounted } from 'vue';
|
import { ref, defineComponent, onMounted, computed } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import { useGroupStore } from '@/stores/groupStore';
|
import { useGroupStore } from '@/stores/groupStore';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
defineComponent({
|
defineComponent({
|
||||||
name: 'MainLayout'
|
name: 'MainLayout'
|
||||||
@ -70,6 +94,7 @@ const route = useRoute();
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
const groupStore = useGroupStore();
|
const groupStore = useGroupStore();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
// Add initialization logic
|
// Add initialization logic
|
||||||
const initializeApp = async () => {
|
const initializeApp = async () => {
|
||||||
@ -90,6 +115,12 @@ onMounted(() => {
|
|||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
groupStore.fetchGroups();
|
groupStore.fetchGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load saved language from localStorage
|
||||||
|
const savedLanguage = localStorage.getItem('language');
|
||||||
|
if (savedLanguage && ['en', 'de', 'nl', 'fr', 'es'].includes(savedLanguage)) {
|
||||||
|
locale.value = savedLanguage;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const userMenuOpen = ref(false);
|
const userMenuOpen = ref(false);
|
||||||
@ -103,6 +134,37 @@ onClickOutside(userMenuDropdown, () => {
|
|||||||
userMenuOpen.value = false;
|
userMenuOpen.value = false;
|
||||||
}, { ignore: ['.user-menu-button'] });
|
}, { ignore: ['.user-menu-button'] });
|
||||||
|
|
||||||
|
// Language selector state and functions
|
||||||
|
const languageMenuOpen = ref(false);
|
||||||
|
const languageMenuDropdown = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const availableLanguages = computed(() => ({
|
||||||
|
en: t('languageSelector.languages.en'),
|
||||||
|
de: t('languageSelector.languages.de'),
|
||||||
|
nl: t('languageSelector.languages.nl'),
|
||||||
|
fr: t('languageSelector.languages.fr'),
|
||||||
|
es: t('languageSelector.languages.es')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentLanguageCode = computed(() => locale.value);
|
||||||
|
|
||||||
|
const toggleLanguageMenu = () => {
|
||||||
|
languageMenuOpen.value = !languageMenuOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeLanguage = (languageCode: string) => {
|
||||||
|
locale.value = languageCode;
|
||||||
|
localStorage.setItem('language', languageCode);
|
||||||
|
languageMenuOpen.value = false;
|
||||||
|
notificationStore.addNotification({
|
||||||
|
type: 'success',
|
||||||
|
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onClickOutside(languageMenuDropdown, () => {
|
||||||
|
languageMenuOpen.value = false;
|
||||||
|
}, { ignore: ['.language-menu-button'] });
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@ -163,23 +225,61 @@ const navigateToGroups = () => {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu {
|
.language-selector {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu-button {
|
.language-menu-button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.25rem;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 123, 84, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-language {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-dropdown {
|
||||||
|
min-width: 180px;
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-option {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: rgba(255, 123, 84, 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,6 +307,25 @@ const navigateToGroups = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.page-container {
|
.page-container {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -9,6 +9,7 @@ import enMessages from './i18n/en.json' // Import en.json directly
|
|||||||
import deMessages from './i18n/de.json'
|
import deMessages from './i18n/de.json'
|
||||||
import frMessages from './i18n/fr.json'
|
import frMessages from './i18n/fr.json'
|
||||||
import esMessages from './i18n/es.json'
|
import esMessages from './i18n/es.json'
|
||||||
|
import nlMessages from './i18n/nl.json'
|
||||||
|
|
||||||
// Global styles
|
// Global styles
|
||||||
import './assets/main.scss'
|
import './assets/main.scss'
|
||||||
@ -39,6 +40,7 @@ const i18n = createI18n({
|
|||||||
de: deMessages,
|
de: deMessages,
|
||||||
fr: frMessages,
|
fr: frMessages,
|
||||||
es: esMessages,
|
es: esMessages,
|
||||||
|
nl: nlMessages,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ onMounted(async () => {
|
|||||||
const tokenToUse = accessToken || legacyToken;
|
const tokenToUse = accessToken || legacyToken;
|
||||||
|
|
||||||
if (!tokenToUse) {
|
if (!tokenToUse) {
|
||||||
throw new Error('No token provided');
|
throw new Error(t('authCallbackPage.errors.noTokenProvided'));
|
||||||
}
|
}
|
||||||
|
|
||||||
await authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });
|
await authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { format, startOfDay, isEqual, isToday as isTodayDate } 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 } from '../types/chore'
|
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate, ChoreAssignment, ChoreHistory } from '../types/chore'
|
||||||
import { groupService } from '../services/groupService'
|
import { groupService } from '../services/groupService'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
@ -16,6 +16,8 @@ interface ChoreWithCompletion extends Chore {
|
|||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
updating: boolean;
|
updating: boolean;
|
||||||
|
assigned_user_name?: string;
|
||||||
|
completed_by_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChoreFormData {
|
interface ChoreFormData {
|
||||||
@ -35,8 +37,14 @@ const chores = ref<ChoreWithCompletion[]>([])
|
|||||||
const groups = ref<{ id: number, name: string }[]>([])
|
const groups = ref<{ id: number, name: string }[]>([])
|
||||||
const showChoreModal = ref(false)
|
const showChoreModal = ref(false)
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
|
const showChoreDetailModal = ref(false)
|
||||||
|
const showHistoryModal = ref(false)
|
||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
const selectedChore = ref<ChoreWithCompletion | null>(null)
|
const selectedChore = ref<ChoreWithCompletion | null>(null)
|
||||||
|
const selectedChoreHistory = ref<ChoreHistory[]>([])
|
||||||
|
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
|
||||||
|
const loadingHistory = ref(false)
|
||||||
|
const loadingAssignments = ref(false)
|
||||||
|
|
||||||
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
|
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
|
||||||
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
|
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
|
||||||
@ -71,8 +79,10 @@ const loadChores = async () => {
|
|||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
current_assignment_id: currentAssignment?.id ?? null,
|
current_assignment_id: currentAssignment?.id ?? null,
|
||||||
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false,
|
is_completed: currentAssignment?.is_complete ?? false,
|
||||||
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null,
|
completed_at: currentAssignment?.completed_at ?? null,
|
||||||
|
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
|
||||||
|
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
|
||||||
updating: false,
|
updating: false,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -80,7 +90,7 @@ const loadChores = async () => {
|
|||||||
cachedChores.value = mappedChores;
|
cachedChores.value = mappedChores;
|
||||||
cachedTimestamp.value = Date.now()
|
cachedTimestamp.value = Date.now()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load all chores:', error)
|
console.error(t('choresPage.consoleErrors.loadFailed'), error)
|
||||||
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' })
|
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' })
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@ -91,7 +101,7 @@ const loadGroups = async () => {
|
|||||||
try {
|
try {
|
||||||
groups.value = await groupService.getUserGroups();
|
groups.value = await groupService.getUserGroups();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load groups", error);
|
console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error);
|
||||||
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
|
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,13 +123,24 @@ const getChoreSubtext = (chore: ChoreWithCompletion): string => {
|
|||||||
if (chore.is_completed && chore.completed_at) {
|
if (chore.is_completed && chore.completed_at) {
|
||||||
const completedDate = new Date(chore.completed_at);
|
const completedDate = new Date(chore.completed_at);
|
||||||
if (isTodayDate(completedDate)) {
|
if (isTodayDate(completedDate)) {
|
||||||
return t('choresPage.completedToday');
|
return t('choresPage.completedToday') + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
|
||||||
}
|
}
|
||||||
return t('choresPage.completedOn', { date: format(completedDate, 'd MMM') });
|
const timeAgo = formatDistanceToNow(completedDate, { addSuffix: true });
|
||||||
|
return `Completed ${timeAgo}` + (chore.completed_by_name ? ` by ${chore.completed_by_name}` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Show who it's assigned to if there's an assignment
|
||||||
|
if (chore.current_assignment_id && chore.assigned_user_name) {
|
||||||
|
parts.push(`Assigned to ${chore.assigned_user_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show creator info for group chores
|
||||||
|
if (chore.type === 'group' && chore.creator) {
|
||||||
|
parts.push(`Created by ${chore.creator.name || chore.creator.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (chore.frequency && chore.frequency !== 'one_time') {
|
if (chore.frequency && chore.frequency !== 'one_time') {
|
||||||
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
|
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
|
||||||
if (freqOption) {
|
if (freqOption) {
|
||||||
@ -227,7 +248,7 @@ const handleFormSubmit = async () => {
|
|||||||
due_date: createdChore.next_due_date
|
due_date: createdChore.next_due_date
|
||||||
});
|
});
|
||||||
} catch (assignmentError) {
|
} catch (assignmentError) {
|
||||||
console.error('Failed to create assignment for new chore:', assignmentError);
|
console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError);
|
||||||
// Continue anyway since the chore was created
|
// Continue anyway since the chore was created
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,7 +258,7 @@ const handleFormSubmit = async () => {
|
|||||||
showChoreModal.value = false;
|
showChoreModal.value = false;
|
||||||
await loadChores();
|
await loadChores();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save chore:', error);
|
console.error(t('choresPage.consoleErrors.saveFailed'), error);
|
||||||
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
|
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -255,7 +276,7 @@ const deleteChore = async () => {
|
|||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
await loadChores()
|
await loadChores()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete chore:', error)
|
console.error(t('choresPage.consoleErrors.deleteFailed'), error)
|
||||||
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
|
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -271,7 +292,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
});
|
});
|
||||||
chore.current_assignment_id = assignment.id;
|
chore.current_assignment_id = assignment.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create assignment:', error);
|
console.error(t('choresPage.consoleErrors.createAssignmentFailed'), error);
|
||||||
notificationStore.addNotification({
|
notificationStore.addNotification({
|
||||||
message: t('choresPage.notifications.createAssignmentFailed', 'Failed to create assignment for chore.'),
|
message: t('choresPage.notifications.createAssignmentFailed', 'Failed to create assignment for chore.'),
|
||||||
type: 'error'
|
type: 'error'
|
||||||
@ -299,13 +320,84 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
});
|
});
|
||||||
await loadChores();
|
await loadChores();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update chore completion status:', error);
|
console.error(t('choresPage.consoleErrors.updateCompletionStatusFailed'), error);
|
||||||
notificationStore.addNotification({ message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'), type: 'error' });
|
notificationStore.addNotification({ message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'), type: 'error' });
|
||||||
chore.is_completed = originalCompleted;
|
chore.is_completed = originalCompleted;
|
||||||
} finally {
|
} finally {
|
||||||
chore.updating = false;
|
chore.updating = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
|
||||||
|
selectedChore.value = chore;
|
||||||
|
showChoreDetailModal.value = true;
|
||||||
|
|
||||||
|
// Load assignments for this chore
|
||||||
|
loadingAssignments.value = true;
|
||||||
|
try {
|
||||||
|
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chore assignments:', error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
message: 'Failed to load chore assignments.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingAssignments.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHistoryModal = async (chore: ChoreWithCompletion) => {
|
||||||
|
selectedChore.value = chore;
|
||||||
|
showHistoryModal.value = true;
|
||||||
|
|
||||||
|
// Load history for this chore
|
||||||
|
loadingHistory.value = true;
|
||||||
|
try {
|
||||||
|
selectedChoreHistory.value = await choreService.getChoreHistory(chore.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chore history:', error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
message: 'Failed to load chore history.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingHistory.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatHistoryEntry = (entry: ChoreHistory) => {
|
||||||
|
const timestamp = format(parseISO(entry.timestamp), 'MMM d, h:mm a');
|
||||||
|
const user = entry.changed_by_user?.name || entry.changed_by_user?.email || 'System';
|
||||||
|
|
||||||
|
switch (entry.event_type) {
|
||||||
|
case 'created':
|
||||||
|
return `${timestamp} - ${user} created this chore`;
|
||||||
|
case 'updated':
|
||||||
|
return `${timestamp} - ${user} updated this chore`;
|
||||||
|
case 'deleted':
|
||||||
|
return `${timestamp} - ${user} deleted this chore`;
|
||||||
|
case 'assigned':
|
||||||
|
return `${timestamp} - ${user} assigned this chore`;
|
||||||
|
case 'completed':
|
||||||
|
return `${timestamp} - ${user} completed this chore`;
|
||||||
|
case 'reopened':
|
||||||
|
return `${timestamp} - ${user} reopened this chore`;
|
||||||
|
default:
|
||||||
|
return `${timestamp} - ${user} performed action: ${entry.event_type}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDueDateStatus = (chore: ChoreWithCompletion) => {
|
||||||
|
if (chore.is_completed) return 'completed';
|
||||||
|
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
const dueDate = startOfDay(new Date(chore.next_due_date));
|
||||||
|
|
||||||
|
if (dueDate < today) return 'overdue';
|
||||||
|
if (isEqual(dueDate, today)) return 'due-today';
|
||||||
|
return 'upcoming';
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -338,19 +430,35 @@ const toggleCompletion = async (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">
|
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item"
|
||||||
|
:class="`status-${getDueDateStatus(chore)}`">
|
||||||
<div class="neo-item-content">
|
<div class="neo-item-content">
|
||||||
<label class="neo-checkbox-label">
|
<label class="neo-checkbox-label">
|
||||||
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
|
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
|
||||||
<div class="checkbox-content">
|
<div class="checkbox-content">
|
||||||
<span class="checkbox-text-span"
|
<div class="chore-main-info">
|
||||||
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
|
<span class="checkbox-text-span"
|
||||||
{{ chore.name }}
|
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
|
||||||
</span>
|
{{ 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>
|
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div class="neo-item-actions">
|
<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)">
|
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
|
||||||
{{ t('choresPage.edit', 'Edit') }}
|
{{ t('choresPage.edit', 'Edit') }}
|
||||||
</button>
|
</button>
|
||||||
@ -401,9 +509,9 @@ const toggleCompletion = async (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="e.g., 10" min="1">
|
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
|
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
|
||||||
@ -422,7 +530,7 @@ const toggleCompletion = async (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>
|
||||||
@ -431,7 +539,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
<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>
|
||||||
@ -456,7 +564,114 @@ const toggleCompletion = async (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>
|
||||||
|
|
||||||
|
<!-- Chore Detail Modal -->
|
||||||
|
<div v-if="showChoreDetailModal" class="modal-backdrop open" @click.self="showChoreDetailModal = false">
|
||||||
|
<div class="modal-container detail-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ selectedChore?.name }}</h3>
|
||||||
|
<button type="button" @click="showChoreDetailModal = false" class="close-button">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" v-if="selectedChore">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Details</h4>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Type:</span>
|
||||||
|
<span class="value">{{ selectedChore.type === 'group' ? 'Group' : 'Personal' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Created by:</span>
|
||||||
|
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Due date:</span>
|
||||||
|
<span class="value">{{ format(new Date(selectedChore.next_due_date), 'PPP') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Frequency:</span>
|
||||||
|
<span class="value">
|
||||||
|
{{selectedChore?.frequency === 'custom' && selectedChore?.custom_interval_days
|
||||||
|
? `Every ${selectedChore.custom_interval_days} days`
|
||||||
|
: frequencyOptions.find(f => f.value === selectedChore?.frequency)?.label || selectedChore?.frequency
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedChore.description" class="detail-item full-width">
|
||||||
|
<span class="label">Description:</span>
|
||||||
|
<span class="value">{{ selectedChore.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>Assignments</h4>
|
||||||
|
<div v-if="loadingAssignments" class="loading-spinner">Loading...</div>
|
||||||
|
<div v-else-if="selectedChoreAssignments.length === 0" class="no-data">
|
||||||
|
No assignments found for this chore.
|
||||||
|
</div>
|
||||||
|
<div v-else class="assignments-list">
|
||||||
|
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
|
||||||
|
<div class="assignment-main">
|
||||||
|
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||||
|
}}</span>
|
||||||
|
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
|
||||||
|
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="assignment-details">
|
||||||
|
<span>Due: {{ format(new Date(assignment.due_date), 'PPP') }}</span>
|
||||||
|
<span v-if="assignment.completed_at">
|
||||||
|
Completed: {{ format(new Date(assignment.completed_at), 'PPP') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History Modal -->
|
||||||
|
<div v-if="showHistoryModal" class="modal-backdrop open" @click.self="showHistoryModal = false">
|
||||||
|
<div class="modal-container history-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>History: {{ selectedChore?.name }}</h3>
|
||||||
|
<button type="button" @click="showHistoryModal = false" class="close-button">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div v-if="loadingHistory" class="loading-spinner">Loading history...</div>
|
||||||
|
<div v-else-if="selectedChoreHistory.length === 0" class="no-data">
|
||||||
|
No history found for this chore.
|
||||||
|
</div>
|
||||||
|
<div v-else class="history-list">
|
||||||
|
<div v-for="entry in selectedChoreHistory" :key="entry.id" class="history-item">
|
||||||
|
<div class="history-content">
|
||||||
|
<span class="history-text">{{ formatHistoryEntry(entry) }}</span>
|
||||||
|
<div v-if="entry.event_data && Object.keys(entry.event_data).length > 0" class="history-data">
|
||||||
|
<details>
|
||||||
|
<summary>Details</summary>
|
||||||
|
<pre>{{ JSON.stringify(entry.event_data, null, 2) }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-neutral" @click="showHistoryModal = false">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -679,4 +894,212 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
transform: scaleX(1);
|
transform: scaleX(1);
|
||||||
transform-origin: left;
|
transform-origin: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* New styles for enhanced UX */
|
||||||
|
.chore-main-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-group {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-overdue {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-due-today {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status-based styling */
|
||||||
|
.status-overdue {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-due-today {
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.detail-modal .modal-container,
|
||||||
|
.history-modal .modal-container {
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark);
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-user {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: #fbbf24;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status.completed {
|
||||||
|
background-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-left: 3px solid #e5e7eb;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-radius: 0 0.25rem 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-data {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-data details {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-data summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-data pre {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--dark);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -281,12 +281,12 @@ const handleCreateGroup = async () => {
|
|||||||
cachedGroups.value = groups.value;
|
cachedGroups.value = groups.value;
|
||||||
cachedTimestamp.value = Date.now();
|
cachedTimestamp.value = Date.now();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid data received from server.');
|
throw new Error(t('groupsPage.errors.invalidDataFromServer'));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.createFailed'));
|
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.createFailed'));
|
||||||
createGroupFormError.value = message;
|
createGroupFormError.value = message;
|
||||||
console.error('Error creating group:', error);
|
console.error(t('groupsPage.errors.createFailedConsole'), error);
|
||||||
notificationStore.addNotification({ message, type: 'error' });
|
notificationStore.addNotification({ message, type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
creatingGroup.value = false;
|
creatingGroup.value = false;
|
||||||
@ -327,7 +327,7 @@ const handleJoinGroup = async () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed'));
|
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed'));
|
||||||
joinGroupFormError.value = message;
|
joinGroupFormError.value = message;
|
||||||
console.error('Error joining group:', error);
|
console.error(t('groupsPage.errors.joinFailedConsole'), error);
|
||||||
notificationStore.addNotification({ message, type: 'error' });
|
notificationStore.addNotification({ message, type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
joiningGroup.value = false;
|
joiningGroup.value = false;
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate } 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 type { Group } from './groupService'
|
import type { Group } from './groupService'
|
||||||
|
import { apiClient, API_ENDPOINTS } from '@/config/api'
|
||||||
|
|
||||||
export const choreService = {
|
export const choreService = {
|
||||||
async getAllChores(): Promise<Chore[]> {
|
async getAllChores(): Promise<Chore[]> {
|
||||||
@ -117,7 +118,7 @@ export const choreService = {
|
|||||||
|
|
||||||
// Update assignment
|
// Update assignment
|
||||||
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
|
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
|
||||||
const response = await api.put(`/api/v1/chores/assignments/${assignmentId}`, update)
|
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -180,4 +181,9 @@ export const choreService = {
|
|||||||
// Renamed original for safety, to be removed
|
// Renamed original for safety, to be removed
|
||||||
await api.delete(`/api/v1/chores/personal/${choreId}`)
|
await api.delete(`/api/v1/chores/personal/${choreId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getChoreHistory(choreId: number): Promise<ChoreHistory[]> {
|
||||||
|
const response = await apiClient.get(API_ENDPOINTS.CHORES.HISTORY(choreId))
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { api } from './api'
|
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||||
|
import type { Group } from '@/types/group';
|
||||||
|
import type { ChoreHistory } from '@/types/chore';
|
||||||
|
|
||||||
// Define Group interface matching backend schema
|
// Define Group interface matching backend schema
|
||||||
export interface Group {
|
export interface Group {
|
||||||
@ -17,13 +19,17 @@ export interface Group {
|
|||||||
|
|
||||||
export const groupService = {
|
export const groupService = {
|
||||||
async getUserGroups(): Promise<Group[]> {
|
async getUserGroups(): Promise<Group[]> {
|
||||||
try {
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||||
const response = await api.get('/api/v1/groups')
|
return response.data;
|
||||||
return response.data
|
},
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user groups:', error)
|
async generateSchedule(groupId: string, data: { start_date: string; end_date: string; member_ids: number[] }): Promise<void> {
|
||||||
throw error
|
await apiClient.post(API_ENDPOINTS.GROUPS.GENERATE_SCHEDULE(groupId), data);
|
||||||
}
|
},
|
||||||
|
|
||||||
|
async getGroupChoreHistory(groupId: string): Promise<ChoreHistory[]> {
|
||||||
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.CHORE_HISTORY(groupId));
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Add other group-related service methods here, e.g.:
|
// Add other group-related service methods here, e.g.:
|
||||||
|
@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
interface AuthState {
|
export interface AuthState {
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
refreshToken: string | null
|
refreshToken: string | null
|
||||||
user: {
|
user: {
|
||||||
@ -57,6 +57,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const fetchCurrentUser = async () => {
|
const fetchCurrentUser = async () => {
|
||||||
if (!accessToken.value) {
|
if (!accessToken.value) {
|
||||||
|
// No token, so definitely clear any residual state and return.
|
||||||
clearTokens()
|
clearTokens()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -65,7 +66,28 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
setUser(response.data)
|
setUser(response.data)
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
clearTokens()
|
// Check if the error is from an Axios request and has a response status
|
||||||
|
if (error.isAxiosError && error.response) {
|
||||||
|
const status = error.response.status
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
// Authentication error from the server, clear tokens.
|
||||||
|
console.error('Authentication error fetching user, clearing tokens:', error)
|
||||||
|
clearTokens()
|
||||||
|
} else {
|
||||||
|
// Other HTTP error, log it but don't clear tokens.
|
||||||
|
// The user might be null, but the token remains for other cached calls.
|
||||||
|
console.error('HTTP error fetching user, token preserved:', error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Network error (offline) or other non-HTTP error.
|
||||||
|
// Log the error but preserve tokens.
|
||||||
|
// This allows the app to function with cached data if available.
|
||||||
|
console.error('Network or other error fetching user, token preserved:', error)
|
||||||
|
}
|
||||||
|
// In all error cases where tokens are not cleared, return null for the user object.
|
||||||
|
// The existing user object (if any) will remain until explicitly cleared or overwritten.
|
||||||
|
// If the intention is to clear the user object on any fetch error, uncomment the next line:
|
||||||
|
// setUser(null);
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import type { UserPublic } from './user'
|
import type { 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'
|
||||||
|
export type ChoreHistoryEventType = 'created' | 'updated' | 'deleted' | 'completed' | 'reopened' | 'assigned' | 'unassigned' | 'reassigned' | 'schedule_generated' | 'due_date_changed' | 'details_changed'
|
||||||
|
|
||||||
export interface Chore {
|
export interface Chore {
|
||||||
id: number
|
id: number
|
||||||
@ -16,14 +17,9 @@ export interface Chore {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
type: ChoreType
|
type: ChoreType
|
||||||
creator?: {
|
creator?: User
|
||||||
id: number
|
assignments: ChoreAssignment[]
|
||||||
name: string
|
history?: ChoreHistory[]
|
||||||
email: string
|
|
||||||
}
|
|
||||||
assignments?: ChoreAssignment[]
|
|
||||||
is_completed: boolean
|
|
||||||
completed_at: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoreCreate extends Omit<Chore, 'id'> { }
|
export interface ChoreCreate extends Omit<Chore, 'id'> { }
|
||||||
@ -38,11 +34,12 @@ export interface ChoreAssignment {
|
|||||||
assigned_by_id: number
|
assigned_by_id: number
|
||||||
due_date: string
|
due_date: string
|
||||||
is_complete: boolean
|
is_complete: boolean
|
||||||
completed_at: string | null
|
completed_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
chore?: Chore
|
chore?: Chore
|
||||||
assigned_user?: UserPublic
|
assigned_user?: User
|
||||||
|
history?: ChoreAssignmentHistory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoreAssignmentCreate {
|
export interface ChoreAssignmentCreate {
|
||||||
@ -52,6 +49,23 @@ export interface ChoreAssignmentCreate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoreAssignmentUpdate {
|
export interface ChoreAssignmentUpdate {
|
||||||
due_date?: string
|
|
||||||
is_complete?: boolean
|
is_complete?: boolean
|
||||||
|
due_date?: string
|
||||||
|
assigned_to_user_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoreHistory {
|
||||||
|
id: number
|
||||||
|
event_type: ChoreHistoryEventType
|
||||||
|
event_data?: Record<string, any>
|
||||||
|
changed_by_user?: User
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoreAssignmentHistory {
|
||||||
|
id: number
|
||||||
|
event_type: ChoreHistoryEventType
|
||||||
|
event_data?: Record<string, any>
|
||||||
|
changed_by_user?: User
|
||||||
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
12
fe/src/types/group.ts
Normal file
12
fe/src/types/group.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// fe/src/types/group.ts
|
||||||
|
import type { AuthState } from '@/stores/auth';
|
||||||
|
import type { ChoreHistory } from './chore';
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
created_by_id: number;
|
||||||
|
created_at: string;
|
||||||
|
members: AuthState['user'][];
|
||||||
|
chore_history?: ChoreHistory[];
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user