Compare commits
65 Commits
Author | SHA1 | Date | |
---|---|---|---|
51474695ef | |||
![]() |
81f551a21d | ||
d13a231113 | |||
![]() |
88c9516308 | ||
![]() |
402489c928 | ||
![]() |
f20f3c960d | ||
![]() |
fb951acb72 | ||
3d2bc3846a | |||
ef2caaee56 | |||
6004911912 | |||
ef41ebb954 | |||
24a5024e88 | |||
acdf1af9b9 | |||
f3fdbc0592 | |||
1f7abcbd85 | |||
76446cf84e | |||
df08bdaf9e | |||
6a61bb8df4 | |||
e124f05e7b | |||
f60002d98e | |||
708a6280d6 | |||
20e1c2ac69 | |||
e777268643 | |||
3be38002e7 | |||
d23219fd60 | |||
088f371547 | |||
b5f16a3d0d | |||
0a6877852a | |||
d3d5f88e09 | |||
1ccd4456f6 | |||
acdb628777 | |||
463cfe070c | |||
8a98aee6c1 | |||
0a42d68853 | |||
26315cd407 | |||
8517cbee99 | |||
f882b86f05 | |||
5e79be16d3 | |||
d1b8191c8d | |||
8d3bf927b6 | |||
e62bceb955 | |||
99d06baa03 | |||
530867bb16 | |||
de5f54f970 | |||
792a7878f0 | |||
c62c0d0157 | |||
855dd852c5 | |||
028c991d91 | |||
1f7f573f64 | |||
350ccaf5d8 | |||
ca73d6ca79 | |||
d7bd69f68c | |||
fd15ed5a35 | |||
0cdc47d0d2 | |||
c90ee6b73f | |||
3c30eaeaee | |||
1907911779 | |||
cda51e34ba | |||
c7f296597e | |||
b3fd3acad9 | |||
258798846d | |||
6f69ad8fcc | |||
7a3e91a324 | |||
e43b4fe50a | |||
b37cbebf8a |
@ -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:
|
||||||
|
if not can_manage:
|
||||||
raise PermissionDeniedError(detail="Only chore managers can reschedule assignments")
|
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 || 'https://mitlistbe.mohmad.dev'
|
||||||
|
|
||||||
// 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 };
|
|
@ -627,5 +627,15 @@
|
|||||||
"sampleTodosHeader": "Beispiel-Todos (aus IndexPage-Daten)",
|
"sampleTodosHeader": "Beispiel-Todos (aus IndexPage-Daten)",
|
||||||
"totalCountLabel": "Gesamtzahl aus Meta:",
|
"totalCountLabel": "Gesamtzahl aus Meta:",
|
||||||
"noTodos": "Keine Todos zum Anzeigen."
|
"noTodos": "Keine Todos zum Anzeigen."
|
||||||
|
},
|
||||||
|
"languageSelector": {
|
||||||
|
"title": "Sprache",
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -555,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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -627,5 +627,15 @@
|
|||||||
"sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)",
|
"sampleTodosHeader": "Tareas de ejemplo (de datos de IndexPage)",
|
||||||
"totalCountLabel": "Recuento total de meta:",
|
"totalCountLabel": "Recuento total de meta:",
|
||||||
"noTodos": "No hay tareas para mostrar."
|
"noTodos": "No hay tareas para mostrar."
|
||||||
|
},
|
||||||
|
"languageSelector": {
|
||||||
|
"title": "Idioma",
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -627,5 +627,15 @@
|
|||||||
"sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)",
|
"sampleTodosHeader": "Exemples de tâches (depuis les données IndexPage)",
|
||||||
"totalCountLabel": "Nombre total depuis meta :",
|
"totalCountLabel": "Nombre total depuis meta :",
|
||||||
"noTodos": "Aucune tâche à afficher."
|
"noTodos": "Aucune tâche à afficher."
|
||||||
|
},
|
||||||
|
"languageSelector": {
|
||||||
|
"title": "Langue",
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -627,5 +627,15 @@
|
|||||||
"sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)",
|
"sampleTodosHeader": "Voorbeeldtaken (uit IndexPage-gegevens)",
|
||||||
"totalCountLabel": "Totaal aantal uit meta:",
|
"totalCountLabel": "Totaal aantal uit meta:",
|
||||||
"noTodos": "Geen taken om weer te geven."
|
"noTodos": "Geen taken om weer te geven."
|
||||||
|
},
|
||||||
|
"languageSelector": {
|
||||||
|
"title": "Taal",
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,6 +2,28 @@
|
|||||||
<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="flex align-end">
|
||||||
|
<div class="language-selector" v-if="authStore.isAuthenticated">
|
||||||
|
<button @click="toggleLanguageMenu" class="language-menu-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="20px" fill="#ff7b54">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path
|
||||||
|
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" />
|
||||||
|
</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">
|
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||||
<button @click="toggleUserMenu" class="user-menu-button">
|
<button @click="toggleUserMenu" class="user-menu-button">
|
||||||
<!-- Placeholder for user icon -->
|
<!-- Placeholder for user icon -->
|
||||||
@ -15,6 +37,7 @@
|
|||||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="page-container">
|
<main class="page-container">
|
||||||
@ -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;
|
||||||
|
@ -40,6 +40,7 @@ const i18n = createI18n({
|
|||||||
de: deMessages,
|
de: deMessages,
|
||||||
fr: frMessages,
|
fr: frMessages,
|
||||||
es: esMessages,
|
es: esMessages,
|
||||||
|
nl: nlMessages,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -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) {
|
||||||
@ -306,6 +327,77 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
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">
|
||||||
|
<div class="chore-main-info">
|
||||||
<span class="checkbox-text-span"
|
<span class="checkbox-text-span"
|
||||||
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
|
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
|
||||||
{{ chore.name }}
|
{{ chore.name }}
|
||||||
</span>
|
</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>
|
||||||
@ -460,6 +568,113 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -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>
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<main class="container page-padding">
|
||||||
|
<div class="group-detail-container">
|
||||||
<div v-if="loading" class="text-center">
|
<div v-if="loading" class="text-center">
|
||||||
<VSpinner :label="t('groupDetailPage.loadingLabel')" />
|
<VSpinner :label="t('groupDetailPage.loadingLabel')" />
|
||||||
</div>
|
</div>
|
||||||
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
|
<VAlert v-else-if="error" type="error" :message="error" class="mb-3">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VButton variant="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}</VButton>
|
<VButton variant="danger" size="sm" @click="fetchGroupDetails">{{ t('groupDetailPage.retryButton') }}
|
||||||
|
</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VAlert>
|
</VAlert>
|
||||||
<div v-else-if="group">
|
<div v-else-if="group">
|
||||||
@ -37,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button ref="addMemberButtonRef" @click="toggleInviteUI" class="add-member-btn"
|
<button ref="addMemberButtonRef" @click="toggleInviteUI" class="add-member-btn"
|
||||||
:aria-label="t('groupDetailPage.invites.title')">
|
:aria-label="t('groupDetailPage.invites.title')">
|
||||||
{{ t('groupDetailPage.invites.addMemberButtonLabel') }}
|
+
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Invite Members Popup -->
|
<!-- Invite Members Popup -->
|
||||||
@ -80,25 +82,94 @@
|
|||||||
<div class="mt-4 neo-section">
|
<div class="mt-4 neo-section">
|
||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
||||||
|
<VButton @click="showGenerateScheduleModal = true">{{ t('groupDetailPage.chores.generateScheduleButton')
|
||||||
|
}}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
<div v-if="upcomingChores.length > 0" class="enhanced-chores-list">
|
||||||
|
<div v-for="chore in upcomingChores" :key="chore.id" class="enhanced-chore-item"
|
||||||
|
:class="`status-${getDueDateStatus(chore)} ${getChoreStatusInfo(chore).isCompleted ? 'completed' : ''}`"
|
||||||
|
@click="openChoreDetailModal(chore)">
|
||||||
|
<div class="chore-main-content">
|
||||||
|
<div class="chore-icon-container">
|
||||||
|
<div class="chore-status-indicator" :class="{
|
||||||
|
'overdue': getDueDateStatus(chore) === 'overdue',
|
||||||
|
'due-today': getDueDateStatus(chore) === 'due-today',
|
||||||
|
'completed': getChoreStatusInfo(chore).isCompleted
|
||||||
|
}">
|
||||||
|
{{ getChoreStatusInfo(chore).isCompleted ? '✅' :
|
||||||
|
getDueDateStatus(chore) === 'overdue' ? '⚠️' :
|
||||||
|
getDueDateStatus(chore) === 'due-today' ? '📅' : '📋' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chore-text-content">
|
||||||
|
<div class="chore-header">
|
||||||
|
<span class="neo-chore-name" :class="{ completed: getChoreStatusInfo(chore).isCompleted }">
|
||||||
|
{{ chore.name }}
|
||||||
|
</span>
|
||||||
|
<div class="chore-badges">
|
||||||
|
<VBadge :text="formatFrequency(chore.frequency)"
|
||||||
|
:variant="getFrequencyBadgeVariant(chore.frequency)" />
|
||||||
|
<VBadge v-if="getDueDateStatus(chore) === 'overdue'" text="Overdue" variant="danger" />
|
||||||
|
<VBadge v-if="getDueDateStatus(chore) === 'due-today'" text="Due Today" variant="warning" />
|
||||||
|
<VBadge v-if="getChoreStatusInfo(chore).isCompleted" text="Completed" variant="success" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chore-details">
|
||||||
|
<div class="chore-due-info">
|
||||||
|
<span class="due-label">Due:</span>
|
||||||
|
<span class="due-date" :class="getDueDateStatus(chore)">
|
||||||
|
{{ formatDate(chore.next_due_date) }}
|
||||||
|
<span v-if="getDueDateStatus(chore) === 'due-today'" class="today-indicator">(Today)</span>
|
||||||
|
<span v-if="getDueDateStatus(chore) === 'overdue'" class="overdue-indicator">
|
||||||
|
({{ formatDistanceToNow(new Date(chore.next_due_date), { addSuffix: true }) }})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="chore-assignment-info">
|
||||||
|
<span class="assignment-label">Assigned to:</span>
|
||||||
|
<span class="assigned-user">{{ getChoreStatusInfo(chore).assignedUserName }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="chore.description" class="chore-description">
|
||||||
|
{{ chore.description }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="getChoreStatusInfo(chore).isCompleted && getChoreStatusInfo(chore).currentAssignment?.completed_at"
|
||||||
|
class="completion-info">
|
||||||
|
Completed {{ formatDistanceToNow(new
|
||||||
|
Date(getChoreStatusInfo(chore).currentAssignment!.completed_at!),
|
||||||
|
{ addSuffix: true }) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chore-actions">
|
||||||
|
<VButton size="sm" variant="neutral" @click.stop="openChoreDetailModal(chore)" title="View Details">
|
||||||
|
👁️
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VList v-if="upcomingChores.length > 0">
|
|
||||||
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
|
|
||||||
<div class="neo-chore-info">
|
|
||||||
<span class="neo-chore-name">{{ chore.name }}</span>
|
|
||||||
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
|
|
||||||
formatDate(chore.next_due_date)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
<div v-else class="text-center py-4">
|
<div v-else class="text-center py-4">
|
||||||
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" />
|
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" />
|
||||||
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
|
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Activity Log Section -->
|
||||||
|
<div class="mt-4 neo-section">
|
||||||
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
|
||||||
|
<div v-if="groupHistoryLoading" class="text-center">
|
||||||
|
<VSpinner />
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="groupChoreHistory.length > 0" class="activity-log-list">
|
||||||
|
<li v-for="entry in groupChoreHistory" :key="entry.id" class="activity-log-item">
|
||||||
|
{{ formatHistoryEntry(entry) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else>{{ t('groupDetailPage.activityLog.emptyState') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Expenses Section -->
|
<!-- Expenses Section -->
|
||||||
<div class="mt-4 neo-section">
|
<div class="mt-4 neo-section">
|
||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
@ -145,7 +216,11 @@
|
|||||||
<div class="neo-splits-list">
|
<div class="neo-splits-list">
|
||||||
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
|
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
|
||||||
<div class="split-col split-user">
|
<div class="split-col split-user">
|
||||||
<strong>{{ split.user?.name || split.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: split.user_id }) }}</strong>
|
<strong>{{ split.user?.name || split.user?.email ||
|
||||||
|
t('groupDetailPage.expenses.fallbackUserName',
|
||||||
|
{
|
||||||
|
userId: split.user_id
|
||||||
|
}) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="split-col split-owes">
|
<div class="split-col split-owes">
|
||||||
{{ t('groupDetailPage.expenses.owes') }} <strong>{{
|
{{ t('groupDetailPage.expenses.owes') }} <strong>{{
|
||||||
@ -177,7 +252,10 @@
|
|||||||
{{ t('groupDetailPage.expenses.activityLabel') }} {{
|
{{ t('groupDetailPage.expenses.activityLabel') }} {{
|
||||||
formatCurrency(activity.amount_paid) }}
|
formatCurrency(activity.amount_paid) }}
|
||||||
{{
|
{{
|
||||||
t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name || t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }} {{ t('groupDetailPage.expenses.onDate') }} {{ new
|
t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name ||
|
||||||
|
t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }}
|
||||||
|
{{
|
||||||
|
t('groupDetailPage.expenses.onDate') }} {{ new
|
||||||
Date(activity.paid_at).toLocaleDateString() }}
|
Date(activity.paid_at).toLocaleDateString() }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -207,7 +285,10 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
|
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
|
||||||
userName: selectedSplitForSettlement?.user?.name
|
userName: selectedSplitForSettlement?.user?.name
|
||||||
|| selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', { userId: selectedSplitForSettlement?.user_id })
|
|| selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', {
|
||||||
|
userId:
|
||||||
|
selectedSplitForSettlement?.user_id
|
||||||
|
})
|
||||||
}) }}</p>
|
}) }}</p>
|
||||||
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
|
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
|
||||||
:error-message="settleAmountError || undefined">
|
:error-message="settleAmountError || undefined">
|
||||||
@ -224,11 +305,171 @@
|
|||||||
}}</VButton>
|
}}</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
|
|
||||||
|
<!-- Enhanced Chore Detail Modal -->
|
||||||
|
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
|
||||||
|
<template #default>
|
||||||
|
<div v-if="selectedChore" class="chore-detail-content">
|
||||||
|
<!-- Chore Overview -->
|
||||||
|
<div class="chore-overview-section">
|
||||||
|
<div class="chore-status-summary">
|
||||||
|
<div class="status-badges">
|
||||||
|
<VBadge :text="formatFrequency(selectedChore.frequency)"
|
||||||
|
:variant="getFrequencyBadgeVariant(selectedChore.frequency)" />
|
||||||
|
<VBadge v-if="getDueDateStatus(selectedChore) === 'overdue'" text="Overdue" variant="danger" />
|
||||||
|
<VBadge v-if="getDueDateStatus(selectedChore) === 'due-today'" text="Due Today" variant="warning" />
|
||||||
|
<VBadge v-if="getChoreStatusInfo(selectedChore).isCompleted" text="Completed" variant="success" />
|
||||||
|
</div>
|
||||||
|
<div class="chore-meta-info">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="label">Created by:</span>
|
||||||
|
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="label">Created:</span>
|
||||||
|
<span class="value">{{ format(new Date(selectedChore.created_at), 'MMM d, yyyy') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="label">Next due:</span>
|
||||||
|
<span class="value" :class="getDueDateStatus(selectedChore)">
|
||||||
|
{{ formatDate(selectedChore.next_due_date) }}
|
||||||
|
<span v-if="getDueDateStatus(selectedChore) === 'due-today'">(Today)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedChore.custom_interval_days" class="meta-item">
|
||||||
|
<span class="label">Custom interval:</span>
|
||||||
|
<span class="value">Every {{ selectedChore.custom_interval_days }} days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedChore.description" class="chore-description-full">
|
||||||
|
<VHeading :level="5">Description</VHeading>
|
||||||
|
<p>{{ selectedChore.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Assignments -->
|
||||||
|
<div class="assignments-section">
|
||||||
|
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
|
||||||
|
<div v-if="loadingAssignments" class="loading-assignments">
|
||||||
|
<VSpinner size="sm" />
|
||||||
|
<span>Loading assignments...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list">
|
||||||
|
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card">
|
||||||
|
<template v-if="editingAssignment?.id === assignment.id">
|
||||||
|
<!-- Inline Editing UI -->
|
||||||
|
<div class="editing-assignment">
|
||||||
|
<VFormField label="Assigned to:">
|
||||||
|
<VSelect v-if="group?.members"
|
||||||
|
:options="group.members.map(m => ({ value: m.id, label: m.email }))"
|
||||||
|
:model-value="editingAssignment.assigned_to_user_id || 0"
|
||||||
|
@update:model-value="val => editingAssignment && (editingAssignment.assigned_to_user_id = val)" />
|
||||||
|
</VFormField>
|
||||||
|
<VFormField label="Due date:">
|
||||||
|
<VInput type="date" :model-value="editingAssignment.due_date ?? ''"
|
||||||
|
@update:model-value="val => editingAssignment && (editingAssignment.due_date = val)" />
|
||||||
|
</VFormField>
|
||||||
|
<div class="editing-actions">
|
||||||
|
<VButton @click="saveAssignmentEdit(assignment.id)" size="sm">{{ t('shared.save') }}</VButton>
|
||||||
|
<VButton @click="cancelAssignmentEdit" variant="neutral" size="sm">{{ t('shared.cancel') }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="assignment-info">
|
||||||
|
<div class="assignment-header">
|
||||||
|
<div class="assigned-user-info">
|
||||||
|
<span class="user-name">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
|
||||||
|
|| 'Unknown User' }}</span>
|
||||||
|
<VBadge v-if="assignment.is_complete" text="Completed" variant="success" />
|
||||||
|
<VBadge v-else-if="isAssignmentOverdue(assignment)" text="Overdue" variant="danger" />
|
||||||
|
</div>
|
||||||
|
<div class="assignment-actions">
|
||||||
|
<VButton v-if="!assignment.is_complete" @click="startAssignmentEdit(assignment)" size="sm"
|
||||||
|
variant="neutral">
|
||||||
|
{{ t('shared.edit') }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="assignment-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Due:</span>
|
||||||
|
<span class="value">{{ formatDate(assignment.due_date) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="assignment.is_complete && assignment.completed_at" class="detail-item">
|
||||||
|
<span class="label">Completed:</span>
|
||||||
|
<span class="value">
|
||||||
|
{{ formatDate(assignment.completed_at) }}
|
||||||
|
({{ formatDistanceToNow(new Date(assignment.completed_at), { addSuffix: true }) }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="no-assignments">{{ t('groupDetailPage.choreDetailModal.noAssignments') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment History -->
|
||||||
|
<div
|
||||||
|
v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)"
|
||||||
|
class="assignment-history-section">
|
||||||
|
<VHeading :level="4">Assignment History</VHeading>
|
||||||
|
<div class="history-timeline">
|
||||||
|
<div v-for="assignment in selectedChore.assignments" :key="`history-${assignment.id}`">
|
||||||
|
<div v-if="assignment.history && assignment.history.length > 0">
|
||||||
|
<h6 class="assignment-history-header">{{ assignment.assigned_user?.email || 'Unknown User' }}</h6>
|
||||||
|
<div v-for="historyEntry in assignment.history" :key="historyEntry.id" class="history-entry">
|
||||||
|
<div class="history-timestamp">{{ format(new Date(historyEntry.timestamp), 'MMM d, yyyy HH:mm') }}
|
||||||
|
</div>
|
||||||
|
<div class="history-event">{{ formatHistoryEntry(historyEntry) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chore History -->
|
||||||
|
<div class="chore-history-section">
|
||||||
|
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
|
||||||
|
<div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline">
|
||||||
|
<div v-for="entry in selectedChore.history" :key="entry.id" class="history-entry">
|
||||||
|
<div class="history-timestamp">{{ format(new Date(entry.timestamp), 'MMM d, yyyy HH:mm') }}</div>
|
||||||
|
<div class="history-event">{{ formatHistoryEntry(entry) }}</div>
|
||||||
|
<div v-if="entry.changed_by_user" class="history-user">by {{ entry.changed_by_user.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="no-history">{{ t('groupDetailPage.choreDetailModal.noHistory') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
|
||||||
|
<!-- Generate Schedule Modal -->
|
||||||
|
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
|
||||||
|
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
|
||||||
|
<VInput type="date" v-model="scheduleForm.start_date" />
|
||||||
|
</VFormField>
|
||||||
|
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
|
||||||
|
<VInput type="date" v-model="scheduleForm.end_date" />
|
||||||
|
</VFormField>
|
||||||
|
<!-- Member selection can be added here if desired -->
|
||||||
|
<template #footer>
|
||||||
|
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
|
||||||
|
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
|
||||||
|
t('groupDetailPage.generateScheduleModal.generateButton') }}</VButton>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed, reactive } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
// import { useRoute } from 'vue-router';
|
// import { useRoute } from 'vue-router';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
||||||
@ -236,8 +477,8 @@ import { useClipboard, useStorage } from '@vueuse/core';
|
|||||||
import ListsPage from './ListsPage.vue'; // Import ListsPage
|
import ListsPage from './ListsPage.vue'; // Import ListsPage
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
import { choreService } from '../services/choreService'
|
import { choreService } from '../services/choreService'
|
||||||
import type { Chore, ChoreFrequency } from '../types/chore'
|
import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore'
|
||||||
import { format } from 'date-fns'
|
import { format, formatDistanceToNow, parseISO, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
|
||||||
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
|
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
|
||||||
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
|
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
@ -255,7 +496,9 @@ import VInput from '@/components/valerie/VInput.vue';
|
|||||||
import VFormField from '@/components/valerie/VFormField.vue';
|
import VFormField from '@/components/valerie/VFormField.vue';
|
||||||
import VIcon from '@/components/valerie/VIcon.vue';
|
import VIcon from '@/components/valerie/VIcon.vue';
|
||||||
import VModal from '@/components/valerie/VModal.vue';
|
import VModal from '@/components/valerie/VModal.vue';
|
||||||
|
import VSelect from '@/components/valerie/VSelect.vue';
|
||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
|
import { groupService } from '../services/groupService'; // New service
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@ -337,6 +580,25 @@ const settleAmount = ref<string>('');
|
|||||||
const settleAmountError = ref<string | null>(null);
|
const settleAmountError = ref<string | null>(null);
|
||||||
const isSettlementLoading = ref(false);
|
const isSettlementLoading = ref(false);
|
||||||
|
|
||||||
|
// New State
|
||||||
|
const showChoreDetailModal = ref(false);
|
||||||
|
const selectedChore = ref<Chore | null>(null);
|
||||||
|
const editingAssignment = ref<Partial<ChoreAssignment> | null>(null);
|
||||||
|
|
||||||
|
const showGenerateScheduleModal = ref(false);
|
||||||
|
const scheduleForm = reactive({
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
member_ids: []
|
||||||
|
});
|
||||||
|
const generatingSchedule = ref(false);
|
||||||
|
|
||||||
|
const groupChoreHistory = ref<ChoreHistory[]>([]);
|
||||||
|
const groupHistoryLoading = ref(false);
|
||||||
|
|
||||||
|
const loadingAssignments = ref(false);
|
||||||
|
const selectedChoreAssignments = ref<ChoreAssignment[]>([]);
|
||||||
|
|
||||||
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
|
const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string => {
|
||||||
if (err && typeof err === 'object') {
|
if (err && typeof err === 'object') {
|
||||||
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
|
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
|
||||||
@ -530,6 +792,30 @@ const formatDate = (date: string) => {
|
|||||||
return format(new Date(date), 'MMM d, yyyy')
|
return format(new Date(date), 'MMM d, yyyy')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDueDateStatus = (chore: Chore) => {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChoreStatusInfo = (chore: Chore) => {
|
||||||
|
const currentAssignment = chore.assignments && chore.assignments.length > 0 ? chore.assignments[0] : null;
|
||||||
|
const isCompleted = currentAssignment?.is_complete ?? false;
|
||||||
|
const assignedUser = currentAssignment?.assigned_user;
|
||||||
|
const dueDateStatus = getDueDateStatus(chore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentAssignment,
|
||||||
|
isCompleted,
|
||||||
|
assignedUser,
|
||||||
|
dueDateStatus,
|
||||||
|
assignedUserName: assignedUser?.name || assignedUser?.email || 'Unassigned'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const formatFrequency = (frequency: ChoreFrequency) => {
|
const formatFrequency = (frequency: ChoreFrequency) => {
|
||||||
const options: Record<ChoreFrequency, string> = {
|
const options: Record<ChoreFrequency, string> = {
|
||||||
one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys
|
one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys
|
||||||
@ -557,7 +843,7 @@ const loadRecentExpenses = async () => {
|
|||||||
if (!groupId.value) return
|
if (!groupId.value) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value}&limit=5&detailed=true`
|
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value || ''}&limit=5&detailed=true`
|
||||||
)
|
)
|
||||||
recentExpenses.value = response.data
|
recentExpenses.value = response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -742,10 +1028,161 @@ const toggleInviteUI = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openChoreDetailModal = async (chore: Chore) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally lazy load history if not already loaded with the chore
|
||||||
|
if (!chore.history || chore.history.length === 0) {
|
||||||
|
try {
|
||||||
|
const history = await choreService.getChoreHistory(chore.id);
|
||||||
|
selectedChore.value = {
|
||||||
|
...selectedChore.value,
|
||||||
|
history: history
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chore history:', error);
|
||||||
|
notificationStore.addNotification({
|
||||||
|
message: 'Failed to load chore history.',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAssignmentEdit = (assignment: ChoreAssignment) => {
|
||||||
|
editingAssignment.value = { ...assignment, due_date: format(new Date(assignment.due_date), 'yyyy-MM-dd') };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAssignmentEdit = () => {
|
||||||
|
editingAssignment.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAssignmentEdit = async (assignmentId: number) => {
|
||||||
|
if (!editingAssignment.value || !editingAssignment.value.due_date) {
|
||||||
|
notificationStore.addNotification({ message: 'Due date is required.', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updatedAssignment = await choreService.updateAssignment(assignmentId, {
|
||||||
|
due_date: editingAssignment.value.due_date,
|
||||||
|
assigned_to_user_id: editingAssignment.value.assigned_to_user_id
|
||||||
|
});
|
||||||
|
// Update local state
|
||||||
|
loadUpcomingChores(); // Re-fetch all chores to get updates
|
||||||
|
cancelAssignmentEdit();
|
||||||
|
notificationStore.addNotification({ message: 'Assignment updated', type: 'success' });
|
||||||
|
} catch (error) {
|
||||||
|
notificationStore.addNotification({ message: 'Failed to update assignment', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateSchedule = async () => {
|
||||||
|
generatingSchedule.value = true;
|
||||||
|
try {
|
||||||
|
await groupService.generateSchedule(String(groupId.value), scheduleForm);
|
||||||
|
notificationStore.addNotification({ message: 'Schedule generated successfully', type: 'success' });
|
||||||
|
showGenerateScheduleModal.value = false;
|
||||||
|
loadUpcomingChores(); // Refresh the chore list
|
||||||
|
} catch (error) {
|
||||||
|
notificationStore.addNotification({ message: 'Failed to generate schedule', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
generatingSchedule.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadGroupChoreHistory = async () => {
|
||||||
|
if (!groupId.value) return;
|
||||||
|
groupHistoryLoading.value = true;
|
||||||
|
try {
|
||||||
|
groupChoreHistory.value = await groupService.getGroupChoreHistory(String(groupId.value));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load group chore history", err);
|
||||||
|
notificationStore.addNotification({ message: 'Could not load group activity.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
groupHistoryLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatHistoryEntry = (entry: ChoreHistory | ChoreAssignmentHistory): string => {
|
||||||
|
const user = entry.changed_by_user?.email || 'System';
|
||||||
|
const eventType = entry.event_type.toLowerCase().replace(/_/g, ' ');
|
||||||
|
|
||||||
|
let action = '';
|
||||||
|
switch (entry.event_type) {
|
||||||
|
case 'created':
|
||||||
|
action = 'created this chore';
|
||||||
|
break;
|
||||||
|
case 'updated':
|
||||||
|
action = 'updated this chore';
|
||||||
|
break;
|
||||||
|
case 'completed':
|
||||||
|
action = 'completed the assignment';
|
||||||
|
break;
|
||||||
|
case 'reopened':
|
||||||
|
action = 'reopened the assignment';
|
||||||
|
break;
|
||||||
|
case 'assigned':
|
||||||
|
action = 'was assigned to this chore';
|
||||||
|
break;
|
||||||
|
case 'unassigned':
|
||||||
|
action = 'was unassigned from this chore';
|
||||||
|
break;
|
||||||
|
case 'reassigned':
|
||||||
|
action = 'was reassigned this chore';
|
||||||
|
break;
|
||||||
|
case 'due_date_changed':
|
||||||
|
action = 'changed the due date';
|
||||||
|
break;
|
||||||
|
case 'deleted':
|
||||||
|
action = 'deleted this chore';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
action = eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
let details = '';
|
||||||
|
if (entry.event_data) {
|
||||||
|
const changes = Object.entries(entry.event_data).map(([key, value]) => {
|
||||||
|
if (typeof value === 'object' && value !== null && 'old' in value && 'new' in value) {
|
||||||
|
const fieldName = key.replace(/_/g, ' ');
|
||||||
|
return `${fieldName}: "${value.old}" → "${value.new}"`;
|
||||||
|
}
|
||||||
|
return `${key}: ${JSON.stringify(value)}`;
|
||||||
|
});
|
||||||
|
if (changes.length > 0) {
|
||||||
|
details = ` (${changes.join(', ')})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${user} ${action}${details}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAssignmentOverdue = (assignment: ChoreAssignment): boolean => {
|
||||||
|
const dueDate = new Date(assignment.due_date);
|
||||||
|
const today = startOfDay(new Date());
|
||||||
|
return dueDate < today;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchGroupDetails();
|
fetchGroupDetails();
|
||||||
loadUpcomingChores();
|
loadUpcomingChores();
|
||||||
loadRecentExpenses();
|
loadRecentExpenses();
|
||||||
|
loadGroupChoreHistory();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -1358,4 +1795,379 @@ onMounted(() => {
|
|||||||
.neo-settlement-activities li {
|
.neo-settlement-activities li {
|
||||||
margin-top: 0.2em;
|
margin-top: 0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced Chores List Styles */
|
||||||
|
.enhanced-chores-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-chore-item {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 2px solid #111;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-chore-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-chore-item.status-overdue {
|
||||||
|
border-left: 6px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-chore-item.status-due-today {
|
||||||
|
border-left: 6px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-chore-item.completed {
|
||||||
|
opacity: 0.8;
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-main-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-icon-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-status-indicator {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-status-indicator.overdue {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-status-indicator.due-today {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-status-indicator.completed {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-text-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-chore-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-chore-name.completed {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-due-info,
|
||||||
|
.chore-assignment-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-label,
|
||||||
|
.assignment-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date.overdue {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date.due-today {
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-indicator,
|
||||||
|
.overdue-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-description {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-info {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chore Detail Modal Styles */
|
||||||
|
.chore-detail-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-overview-section {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-status-summary {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-meta-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .value {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .value.overdue {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-description-full {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-description-full p {
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignments-section,
|
||||||
|
.assignment-history-section,
|
||||||
|
.chore-history-section {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignments-section:last-child,
|
||||||
|
.assignment-history-section:last-child,
|
||||||
|
.chore-history-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-card {
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-assignment {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assigned-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-assignments,
|
||||||
|
.no-history {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-history-header {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-timestamp {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-event {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-user {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chore-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chore-meta-info {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-assignments {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -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: {
|
||||||
|
@ -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