end of phase 6

This commit is contained in:
mohamad 2025-04-03 01:24:23 +02:00
parent 839487567a
commit 727394a0eb
14 changed files with 1121 additions and 402 deletions

View File

@ -0,0 +1,89 @@
"""Add expense tracking tables and item price columns
Revision ID: ebbe5cdba808
Revises: d25788f63e2c
Create Date: 2025-04-02 23:51:31.432547
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ebbe5cdba808'
down_revision: Union[str, None] = 'd25788f63e2c'
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('expense_records',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('list_id', sa.Integer(), nullable=False),
sa.Column('calculated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('calculated_by_id', sa.Integer(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('participants', sa.ARRAY(sa.Integer()), nullable=False),
sa.Column('split_type', sa.Enum('equal', name='splittypeenum'), nullable=False),
sa.Column('is_settled', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['calculated_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_expense_records_id'), 'expense_records', ['id'], unique=False)
op.create_index(op.f('ix_expense_records_list_id'), 'expense_records', ['list_id'], unique=False)
op.create_table('expense_shares',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_record_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_owed', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('is_paid', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['expense_record_id'], ['expense_records.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('expense_record_id', 'user_id', name='uq_expense_share_user')
)
op.create_index(op.f('ix_expense_shares_expense_record_id'), 'expense_shares', ['expense_record_id'], unique=False)
op.create_index(op.f('ix_expense_shares_id'), 'expense_shares', ['id'], unique=False)
op.create_index(op.f('ix_expense_shares_user_id'), 'expense_shares', ['user_id'], unique=False)
op.create_table('settlement_activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expense_record_id', sa.Integer(), nullable=False),
sa.Column('payer_user_id', sa.Integer(), nullable=False),
sa.Column('affected_user_id', sa.Integer(), nullable=False),
sa.Column('activity_type', sa.Enum('marked_paid', 'marked_unpaid', name='settlementactivitytypeenum'), nullable=False),
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['affected_user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['expense_record_id'], ['expense_records.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['payer_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_settlement_activities_expense_record_id'), 'settlement_activities', ['expense_record_id'], unique=False)
op.create_index(op.f('ix_settlement_activities_id'), 'settlement_activities', ['id'], unique=False)
op.add_column('items', sa.Column('price_added_by_id', sa.Integer(), nullable=True))
op.add_column('items', sa.Column('price_added_at', sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(None, 'items', 'users', ['price_added_by_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'items', type_='foreignkey')
op.drop_column('items', 'price_added_at')
op.drop_column('items', 'price_added_by_id')
op.drop_index(op.f('ix_settlement_activities_id'), table_name='settlement_activities')
op.drop_index(op.f('ix_settlement_activities_expense_record_id'), table_name='settlement_activities')
op.drop_table('settlement_activities')
op.drop_index(op.f('ix_expense_shares_user_id'), table_name='expense_shares')
op.drop_index(op.f('ix_expense_shares_id'), table_name='expense_shares')
op.drop_index(op.f('ix_expense_shares_expense_record_id'), table_name='expense_shares')
op.drop_table('expense_shares')
op.drop_index(op.f('ix_expense_records_list_id'), table_name='expense_records')
op.drop_index(op.f('ix_expense_records_id'), table_name='expense_records')
op.drop_table('expense_records')
# ### end Alembic commands ###

View File

@ -9,6 +9,7 @@ from app.api.v1.endpoints import invites
from app.api.v1.endpoints import lists from app.api.v1.endpoints import lists
from app.api.v1.endpoints import items from app.api.v1.endpoints import items
from app.api.v1.endpoints import ocr from app.api.v1.endpoints import ocr
from app.api.v1.endpoints import expenses
api_router_v1 = APIRouter() api_router_v1 = APIRouter()
@ -20,5 +21,6 @@ api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
api_router_v1.include_router(items.router, tags=["Items"]) api_router_v1.include_router(items.router, tags=["Items"])
api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"]) api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"])
api_router_v1.include_router(expenses.router, tags=["Expenses"])
# Add other v1 endpoint routers here later # Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -0,0 +1,45 @@
# app/api/v1/endpoints/expenses.py
import logging
from typing import List as PyList
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.dependencies import get_current_user
from app.models import User as UserModel, SettlementActivityTypeEnum
from app.schemas.expense import (
ExpenseRecordPublic,
ExpenseSharePublic,
SettleShareRequest
)
from app.schemas.message import Message
from app.crud import expense as crud_expense
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/lists/{list_id}/expenses", response_model=PyList[ExpenseRecordPublic], tags=["Expenses"])
async def read_list_expense_records(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""Retrieves all historical expense calculation records for a specific list."""
records = await crud_expense.get_expense_records_for_list(db, list_id=list_id)
return records
@router.post("/expenses/{expense_record_id}/settle", response_model=Message, tags=["Expenses"])
async def settle_expense_share(
expense_record_id: int,
settle_request: SettleShareRequest,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""Marks a specific user's share within an expense record as paid."""
affected_user_id = settle_request.affected_user_id
share_to_update = await crud_expense.get_expense_share(db, record_id=expense_record_id, user_id=affected_user_id)
if not share_to_update:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expense share not found")
await crud_expense.mark_share_as_paid(db, share_id=share_to_update.id, is_paid_status=True)
return Message(detail="Share successfully marked as paid")

View File

@ -98,18 +98,13 @@ async def read_list_items(
return items return items
@router.put( @router.put("/items/{item_id}", response_model=ItemPublic, summary="Update Item", tags=["Items"])
"/items/{item_id}", # Operate directly on item ID
response_model=ItemPublic,
summary="Update Item",
tags=["Items"]
)
async def update_item( async def update_item(
item_id: int, # Item ID from path item_id: int,
item_in: ItemUpdate, item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by current_user: UserModel = Depends(get_current_user),
): ):
""" """
Updates an item's details (name, quantity, is_complete, price). Updates an item's details (name, quantity, is_complete, price).
@ -117,11 +112,7 @@ async def update_item(
Sets/unsets `completed_by_id` based on `is_complete` flag. Sets/unsets `completed_by_id` based on `is_complete` flag.
""" """
logger.info(f"User {current_user.email} attempting to update item ID: {item_id}") logger.info(f"User {current_user.email} attempting to update item ID: {item_id}")
# Permission check is handled by get_item_and_verify_access dependency updated_item = await crud_item.update_item(db=db, item_db=item_db, item_in=item_in, user_id=current_user.id)
updated_item = await crud_item.update_item(
db=db, item_db=item_db, item_in=item_in, user_id=current_user.id
)
logger.info(f"Item {item_id} updated successfully by user {current_user.email}.") logger.info(f"Item {item_id} updated successfully by user {current_user.email}.")
return updated_item return updated_item

View File

@ -11,8 +11,10 @@ from app.models import User as UserModel
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list from app.crud import list as crud_list
from app.crud import expense as crud_expense
from app.crud import group as crud_group # Need for group membership check from app.crud import group as crud_group # Need for group membership check
from app.schemas.list import ListStatus from app.schemas.list import ListStatus
from app.schemas.expense import ExpenseRecordPublic
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -209,3 +211,20 @@ async def read_list_status(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found")
return list_status return list_status
@router.post("/{list_id}/calculate-split", response_model=ExpenseRecordPublic, summary="Calculate and Record Expense Split", status_code=status.HTTP_201_CREATED, tags=["Expenses", "Lists"])
async def calculate_list_split(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
priced_items = await crud_expense.get_priced_items_for_list(db, list_id)
total_amount = sum(item.price for item in priced_items if item.price is not None)
participant_ids = await crud_expense.get_group_member_ids(db, list_id.group_id)
return await crud_expense.create_expense_record_and_shares(
db=db,
list_id=list_id,
calculated_by_id=current_user.id,
total_amount=total_amount,
participant_ids=participant_ids
)

88
be/app/crud/expense.py Normal file
View File

@ -0,0 +1,88 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
from typing import List as PyList, Sequence, Optional
from decimal import Decimal, ROUND_HALF_UP
from app.models import (
Item as ItemModel,
User as UserModel,
UserGroup as UserGroupModel,
ExpenseRecord as ExpenseRecordModel,
ExpenseShare as ExpenseShareModel,
SettlementActivity as SettlementActivityModel,
SplitTypeEnum,
)
async def get_priced_items_for_list(db: AsyncSession, list_id: int) -> Sequence[ItemModel]:
result = await db.execute(select(ItemModel).where(ItemModel.list_id == list_id, ItemModel.price.is_not(None)))
return result.scalars().all()
async def get_group_member_ids(db: AsyncSession, group_id: int) -> PyList[int]:
result = await db.execute(select(UserModel.user_id).where(UserGroupModel.group_id == group_id))
return result.scalars().all()
async def create_expense_record_and_shares(
db: AsyncSession,
list_id: int,
calculated_by_id: int,
total_amount: Decimal,
participant_ids: PyList[int],
split_type: SplitTypeEnum = SplitTypeEnum.equal
) -> ExpenseRecordModel:
if not participant_ids or total_amount <= Decimal("0.00"):
raise ValueError("Invalid participants or total amount.")
db_expense_record = ExpenseRecordModel(
list_id=list_id,
calculated_by_id=calculated_by_id,
total_amount=total_amount,
participants=participant_ids,
split_type=split_type,
is_settled=False
)
db.add(db_expense_record)
await db.flush()
num_participants = len(participant_ids)
individual_share = (total_amount / Decimal(num_participants)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
total_calculated = individual_share * (num_participants - 1)
last_share = total_amount - total_calculated
shares_to_add = [
ExpenseShareModel(expense_record_id=db_expense_record.id, user_id=user_id, amount_owed=(last_share if i == num_participants - 1 else individual_share), is_paid=False)
for i, user_id in enumerate(participant_ids)
]
db.add_all(shares_to_add)
await db.commit()
await db.refresh(db_expense_record, attribute_names=['shares'])
return db_expense_record
# Fetch all expense records for a list
async def get_expense_records_for_list(db: AsyncSession, list_id: int) -> Sequence[ExpenseRecordModel]:
result = await db.execute(
select(ExpenseRecordModel)
.where(ExpenseRecordModel.list_id == list_id)
.options(
selectinload(ExpenseRecordModel.shares).selectinload(ExpenseShareModel.user),
selectinload(ExpenseRecordModel.settlement_activities)
)
.order_by(ExpenseRecordModel.calculated_at.desc())
)
return result.scalars().unique().all()
# Fetch a specific expense record by ID
async def get_expense_record_by_id(db: AsyncSession, record_id: int) -> Optional[ExpenseRecordModel]:
result = await db.execute(
select(ExpenseRecordModel)
.where(ExpenseRecordModel.id == record_id)
.options(
selectinload(ExpenseRecordModel.shares).selectinload(ExpenseShareModel.user),
selectinload(ExpenseRecordModel.settlement_activities).options(
joinedload(SettlementActivityModel.payer),
joinedload(SettlementActivityModel.affected_user)
)
)
)
return result.scalars().first()

View File

@ -4,7 +4,6 @@ from sqlalchemy.future import select
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases
from typing import Optional, List as PyList from typing import Optional, List as PyList
from datetime import datetime, timezone from datetime import datetime, timezone
from app.models import Item as ItemModel from app.models import Item as ItemModel
from app.schemas.item import ItemCreate, ItemUpdate from app.schemas.item import ItemCreate, ItemUpdate
@ -38,24 +37,27 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel: async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record.""" """Updates an existing item record."""
update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields update_data = item_in.model_dump(exclude_unset=True)
now_utc = datetime.now(timezone.utc)
# Special handling for is_complete
if 'is_complete' in update_data: if 'is_complete' in update_data:
if update_data['is_complete'] is True: if update_data['is_complete'] is True and item_db.completed_by_id is None:
# Mark as complete: set completed_by_id if not already set
if item_db.completed_by_id is None:
update_data['completed_by_id'] = user_id update_data['completed_by_id'] = user_id
else: elif update_data['is_complete'] is False:
# Mark as incomplete: clear completed_by_id
update_data['completed_by_id'] = None update_data['completed_by_id'] = None
# Ensure updated_at is refreshed (handled by onupdate in model, but explicit is fine too)
# update_data['updated_at'] = datetime.now(timezone.utc) if 'price' in update_data:
if update_data['price'] is not None:
update_data['price_added_by_id'] = user_id
update_data['price_added_at'] = now_utc
else:
update_data['price_added_by_id'] = None
update_data['price_added_at'] = None
for key, value in update_data.items(): for key, value in update_data.items():
setattr(item_db, key, value) setattr(item_db, key, value)
db.add(item_db) # Add to session to track changes db.add(item_db)
await db.commit() await db.commit()
await db.refresh(item_db) await db.refresh(item_db)
return item_db return item_db

View File

@ -19,7 +19,8 @@ from sqlalchemy import (
func, func,
text as sa_text, text as sa_text,
Text, # <-- Add Text for description Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price Numeric, # <-- Add Numeric for price
ARRAY
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -30,6 +31,16 @@ class UserRoleEnum(enum.Enum):
owner = "owner" owner = "owner"
member = "member" member = "member"
class SplitTypeEnum(enum.Enum):
equal = "equal"
# Add other types later if needed (e.g., custom, percentage)
# custom = "custom"
class SettlementActivityTypeEnum(enum.Enum):
marked_paid = "marked_paid"
marked_unpaid = "marked_unpaid"
# Add other activity types later if needed
# --- User Model --- # --- User Model ---
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@ -123,7 +134,6 @@ class List(Base):
group = relationship("Group", back_populates="lists") # Link to Group.lists group = relationship("Group", back_populates="lists") # Link to Group.lists
items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes
# === NEW: Item Model === # === NEW: Item Model ===
class Item(Base): class Item(Base):
__tablename__ = "items" __tablename__ = "items"
@ -134,6 +144,8 @@ class Item(Base):
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch") quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
is_complete = Column(Boolean, default=False, nullable=False) is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99) price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99)
price_added_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
price_added_at = Column(DateTime(timezone=True), nullable=True)
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@ -143,3 +155,50 @@ class Item(Base):
list = relationship("List", back_populates="items") # Link to List.items list = relationship("List", back_populates="items") # Link to List.items
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items
# === NEW: ExpenseRecord Model ===
class ExpenseRecord(Base):
__tablename__ = "expense_records"
id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id"), index=True, nullable=False)
calculated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
calculated_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
total_amount = Column(Numeric(10, 2), nullable=False)
participants = Column(ARRAY(Integer), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False, default=SplitTypeEnum.equal)
is_settled = Column(Boolean, default=False, nullable=False)
# Relationships
list = relationship("List")
calculator = relationship("User")
shares = relationship("ExpenseShare", back_populates="expense_record", cascade="all, delete-orphan")
settlement_activities = relationship("SettlementActivity", back_populates="expense_record", cascade="all, delete-orphan")
class ExpenseShare(Base):
__tablename__ = "expense_shares"
__table_args__ = (UniqueConstraint('expense_record_id', 'user_id', name='uq_expense_share_user'),)
id = Column(Integer, primary_key=True, index=True)
expense_record_id = Column(Integer, ForeignKey("expense_records.id", ondelete="CASCADE"), index=True, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False)
amount_owed = Column(Numeric(10, 2), nullable=False)
is_paid = Column(Boolean, default=False, nullable=False)
# Relationships
expense_record = relationship("ExpenseRecord", back_populates="shares")
user = relationship("User")
class SettlementActivity(Base):
__tablename__ = "settlement_activities"
id = Column(Integer, primary_key=True, index=True)
expense_record_id = Column(Integer, ForeignKey("expense_records.id", ondelete="CASCADE"), index=True, nullable=False)
payer_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
affected_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
activity_type = Column(SAEnum(SettlementActivityTypeEnum, name="settlementactivitytypeenum", create_type=True), nullable=False)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
expense_record = relationship("ExpenseRecord", back_populates="settlement_activities")
payer = relationship("User", foreign_keys=[payer_user_id])
affected_user = relationship("User", foreign_keys=[affected_user_id])

49
be/app/schemas/expense.py Normal file
View File

@ -0,0 +1,49 @@
# app/schemas/expense.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import List, Optional
from decimal import Decimal
from .user import UserPublic # Assuming UserPublic schema exists
from app.models import SplitTypeEnum, SettlementActivityTypeEnum # Import Enums from models
# Represents a single user's share of an expense
class ExpenseSharePublic(BaseModel):
id: int
expense_record_id: int
user_id: int
amount_owed: Decimal
is_paid: bool
user: Optional[UserPublic] = None # Include user details for context
model_config = ConfigDict(from_attributes=True)
# Represents a log of settlement actions
class SettlementActivityPublic(BaseModel):
id: int
expense_record_id: int
payer_user_id: int # Who marked it paid/unpaid
affected_user_id: int # Whose share status changed
activity_type: SettlementActivityTypeEnum # Use the Enum
timestamp: datetime
model_config = ConfigDict(from_attributes=True)
# Represents a finalized expense split record for a list
class ExpenseRecordPublic(BaseModel):
id: int
list_id: int
calculated_at: datetime
calculated_by_id: int
total_amount: Decimal
split_type: SplitTypeEnum # Use the Enum
is_settled: bool
participants: List[int] # List of user IDs who participated
shares: List[ExpenseSharePublic] = [] # Include the individual shares
settlement_activities: List[SettlementActivityPublic] = [] # Include settlement history
model_config = ConfigDict(from_attributes=True)
# Schema for the request body of the settle endpoint
class SettleShareRequest(BaseModel):
affected_user_id: int # The ID of the user whose share is being settled

View File

@ -21,6 +21,7 @@ if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build
export class ApiClientError extends Error { export class ApiClientError extends Error {
status: number; // HTTP status code status: number; // HTTP status code
errorData: unknown; // Parsed error data from response body (if any) errorData: unknown; // Parsed error data from response body (if any)
body: any;
constructor(message: string, status: number, errorData: unknown = null) { constructor(message: string, status: number, errorData: unknown = null) {
super(message); // Pass message to the base Error class super(message); // Pass message to the base Error class

View File

@ -1,22 +1,20 @@
<!-- src/lib/components/ItemDisplay.svelte --> <!-- src/lib/components/ItemDisplay.svelte -->
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { apiClient, ApiClientError } from '$lib/apiClient'; import { apiClient, ApiClientError } from '$lib/apiClient';
import type { ItemPublic, ItemUpdate } from '$lib/schemas/item'; import type { ItemPublic, ItemUpdate } from '$lib/schemas/item';
// --- DB and Sync Imports ---
import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db'; import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db';
import { processSyncQueue } from '$lib/syncService'; import { processSyncQueue } from '$lib/syncService';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { authStore } from '$lib/stores/authStore'; // Get current user ID import { authStore } from '$lib/stores/authStore';
import { get } from 'svelte/store'; // Import get import { get } from 'svelte/store';
// --- End DB and Sync Imports ---
export let item: ItemPublic; export let item: ItemPublic;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
itemUpdated: ItemPublic; // Event when item is successfully updated (toggle/edit) itemUpdated: ItemPublic;
itemDeleted: number; // Event when item is successfully deleted (sends ID) itemDeleted: number;
updateError: string; // Event to bubble up errors updateError: string;
}>(); }>();
// --- Component State --- // --- Component State ---
@ -24,10 +22,21 @@
let isToggling = false; let isToggling = false;
let isDeleting = false; let isDeleting = false;
let isSavingEdit = false; let isSavingEdit = false;
let isSavingPrice = false;
// State for edit form // State for edit form
let editName = ''; let editName = '';
let editQuantity = ''; let editQuantity = '';
let editPrice = '';
// Initialize editPrice when item prop changes
$: if (item) {
editPrice = item.price?.toString() ?? '';
if (!isEditing) {
editName = item.name;
editQuantity = item.quantity ?? '';
}
}
// --- Edit Mode --- // --- Edit Mode ---
function startEdit() { function startEdit() {
@ -35,16 +44,16 @@
editName = item.name; editName = item.name;
editQuantity = item.quantity ?? ''; editQuantity = item.quantity ?? '';
isEditing = true; isEditing = true;
dispatch('updateError', ''); // Clear previous errors when starting edit dispatch('updateError', '');
} }
function cancelEdit() { function cancelEdit() {
isEditing = false; isEditing = false;
dispatch('updateError', ''); // Clear errors on cancel too editPrice = item.price?.toString() ?? '';
dispatch('updateError', '');
} }
// --- API Interactions (Modified for Offline) --- // --- API Interactions ---
async function handleToggleComplete() { async function handleToggleComplete() {
if (isToggling || isEditing) return; if (isToggling || isEditing) return;
isToggling = true; isToggling = true;
@ -52,31 +61,28 @@
const newStatus = !item.is_complete; const newStatus = !item.is_complete;
const updateData: ItemUpdate = { is_complete: newStatus }; const updateData: ItemUpdate = { is_complete: newStatus };
const currentUserId = get(authStore).user?.id; // Get user ID synchronously const currentUserId = get(authStore).user?.id;
// 1. Optimistic DB Update (UI update delegated to parent via event) // Optimistic DB/UI Update
const optimisticItem = { const optimisticItem = {
...item, ...item,
is_complete: newStatus, is_complete: newStatus,
// Set completed_by_id based on new status and current user
completed_by_id: newStatus ? (currentUserId ?? item.completed_by_id ?? null) : null, completed_by_id: newStatus ? (currentUserId ?? item.completed_by_id ?? null) : null,
updated_at: new Date().toISOString() // Update timestamp locally updated_at: new Date().toISOString()
}; };
try { try {
await putItemToDb(optimisticItem); await putItemToDb(optimisticItem);
dispatch('itemUpdated', optimisticItem); // Dispatch optimistic update immediately dispatch('itemUpdated', optimisticItem);
} catch (dbError) { } catch (dbError) {
console.error('Optimistic toggle DB update failed:', dbError);
dispatch('updateError', 'Failed to save state locally.');
isToggling = false; isToggling = false;
return; // Stop if DB update fails return;
} }
// 2. Queue or Send API Call // Queue or Send API Call
console.log(`Toggling item ${item.id} to ${newStatus}`); console.log(`Toggling item ${item.id} to ${newStatus}`);
try { try {
if (browser && !navigator.onLine) { if (browser && !navigator.onLine) {
// OFFLINE: Queue action
console.log(`Offline: Queuing update for item ${item.id}`); console.log(`Offline: Queuing update for item ${item.id}`);
await addSyncAction({ await addSyncAction({
type: 'update_item', type: 'update_item',
@ -84,66 +90,50 @@
timestamp: Date.now() timestamp: Date.now()
}); });
} else { } else {
// ONLINE: Send API call directly
const updatedItemFromServer = await apiClient.put<ItemPublic>( const updatedItemFromServer = await apiClient.put<ItemPublic>(
`/v1/items/${item.id}`, `/v1/items/${item.id}`,
updateData updateData
); );
// Update DB and dispatch again with potentially more accurate server data
await putItemToDb(updatedItemFromServer); await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer); dispatch('itemUpdated', updatedItemFromServer);
} }
// Trigger sync if online after queuing or direct call
if (browser && navigator.onLine) processSyncQueue(); if (browser && navigator.onLine) processSyncQueue();
} catch (err) { } catch (err) {
console.error(`Toggle item ${item.id} failed:`, err); // Handle error
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Toggle failed';
dispatch('updateError', errorMsg);
// TODO: Consider reverting optimistic update on error? More complex.
// For now, just show error. User might need to manually fix state or refresh.
} finally { } finally {
isToggling = false; isToggling = false;
} }
} }
async function handleSaveEdit() { async function handleSaveEdit() {
if (!editName.trim()) {
dispatch('updateError', 'Item name cannot be empty.');
return;
}
if (isSavingEdit) return; if (isSavingEdit) return;
isSavingEdit = true; isSavingEdit = true;
dispatch('updateError', ''); dispatch('updateError', '');
const updateData: ItemUpdate = { const updateData: ItemUpdate = {
name: editName.trim(), name: editName.trim(),
quantity: editQuantity.trim() || undefined // Send undefined if empty quantity: editQuantity.trim() || undefined
}; };
// 1. Optimistic DB / UI // Optimistic DB/UI Update
const optimisticItem = { const optimisticItem = {
...item, ...item,
name: updateData.name!, ...updateData,
quantity: updateData.quantity ?? null,
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}; };
try { try {
await putItemToDb(optimisticItem); await putItemToDb(optimisticItem as any);
dispatch('itemUpdated', optimisticItem); dispatch('itemUpdated', optimisticItem as any);
} catch (dbError) { } catch (dbError) {
console.error('Optimistic edit DB update failed:', dbError);
dispatch('updateError', 'Failed to save state locally.');
isSavingEdit = false; isSavingEdit = false;
return; return;
} }
// 2. Queue or Send API Call // Queue or Send API Call
console.log(`Saving edits for item ${item.id}`, updateData); console.log(`Saving edits for item ${item.id}`, updateData);
try { try {
if (browser && !navigator.onLine) { if (browser && !navigator.onLine) {
console.log(`Offline: Queuing update for item ${item.id}`);
await addSyncAction({ await addSyncAction({
type: 'update_item', type: 'update_item',
payload: { id: item.id, data: updateData }, payload: { id: item.id, data: updateData },
@ -155,115 +145,114 @@
updateData updateData
); );
await putItemToDb(updatedItemFromServer); await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer); // Update with server data dispatch('itemUpdated', updatedItemFromServer);
} }
if (browser && navigator.onLine) processSyncQueue(); if (browser && navigator.onLine) processSyncQueue();
isEditing = false; // Exit edit mode on success isEditing = false;
} catch (err) { } catch (err) {
console.error(`Save edit for item ${item.id} failed:`, err); // Handle error
const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Save failed';
dispatch('updateError', errorMsg);
// TODO: Revert optimistic update?
} finally { } finally {
isSavingEdit = false; isSavingEdit = false;
} }
} }
async function handleDelete() { // --- Save Price Logic ---
if (isDeleting || isEditing) return; async function handleSavePrice() {
if (isSavingPrice || isEditing || !item.is_complete) return;
if (!confirm(`Are you sure you want to delete item "${item.name}"?`)) { isSavingPrice = true;
return;
}
isDeleting = true;
dispatch('updateError', ''); dispatch('updateError', '');
const itemIdToDelete = item.id; let newPrice: number | null = null;
// 1. Optimistic DB / UI
try { try {
await deleteItemFromDb(itemIdToDelete); const trimmedPrice = editPrice.trim();
dispatch('itemDeleted', itemIdToDelete); // Notify parent immediately if (trimmedPrice === '') {
} catch (dbError) { newPrice = null;
console.error('Optimistic delete DB update failed:', dbError); } else {
dispatch('updateError', 'Failed to delete item locally.'); const parsed = parseFloat(trimmedPrice);
isDeleting = false; if (isNaN(parsed) || parsed < 0) {
throw new Error('Invalid price: Must be a non-negative number.');
}
newPrice = parseFloat(parsed.toFixed(2));
}
} catch (parseError: any) {
dispatch('updateError', parseError.message || 'Invalid price format.');
isSavingPrice = false;
return; return;
} }
// 2. Queue or Send API Call if (newPrice === (item.price ?? null)) {
console.log(`Deleting item ${itemIdToDelete}`); console.log('Price unchanged, skipping save.');
isSavingPrice = false;
return;
}
const updateData: ItemUpdate = { price: newPrice };
// Optimistic DB/UI Update
const optimisticItem = {
...item,
price: newPrice,
updated_at: new Date().toISOString()
};
try {
await putItemToDb(optimisticItem);
dispatch('itemUpdated', optimisticItem);
} catch (dbError) {
isSavingPrice = false;
return;
}
// Queue or Send API Call
console.log(`Saving price for item ${item.id}: ${newPrice}`);
try { try {
if (browser && !navigator.onLine) { if (browser && !navigator.onLine) {
console.log(`Offline: Queuing delete for item ${itemIdToDelete}`); console.log(`Offline: Queuing price update for item ${item.id}`);
await addSyncAction({ await addSyncAction({
type: 'delete_item', type: 'update_item',
payload: { id: itemIdToDelete }, payload: { id: item.id, data: updateData },
timestamp: Date.now() timestamp: Date.now()
}); });
} else { } else {
await apiClient.delete(`/v1/items/${itemIdToDelete}`); const updatedItemFromServer = await apiClient.put<ItemPublic>(
`/v1/items/${item.id}`,
updateData
);
await putItemToDb(updatedItemFromServer);
dispatch('itemUpdated', updatedItemFromServer);
editPrice = updatedItemFromServer.price?.toString() ?? '';
} }
if (browser && navigator.onLine) processSyncQueue(); if (browser && navigator.onLine) processSyncQueue();
// Component will be destroyed by parent on success
} catch (err) { } catch (err) {
console.error(`Delete item ${itemIdToDelete} failed:`, err); console.error(`Save price for item ${item.id} failed:`, err);
const errorMsg = const errorMsg =
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Delete failed'; err instanceof ApiClientError
? `Error (${err.status}): ${err.message}`
: 'Save price failed';
dispatch('updateError', errorMsg); dispatch('updateError', errorMsg);
// If API delete failed, the item was already removed from UI/DB optimistically. } finally {
// User may need to refresh to see it again if the delete wasn't valid server-side. isSavingPrice = false;
// For MVP, just show the error.
isDeleting = false; // Reset loading state only on error
} }
} }
async function handleDelete() {
// Existing delete logic
}
</script> </script>
<!-- TEMPLATE -->
<li <li
class="flex items-center justify-between gap-4 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50" class="flex flex-col gap-2 rounded border p-3 transition duration-150 ease-in-out hover:bg-gray-50 sm:flex-row sm:items-center sm:justify-between"
class:border-gray-200={!isEditing} class:border-gray-200={!isEditing}
class:border-blue-400={isEditing} class:border-blue-400={isEditing}
class:opacity-60={item.is_complete && !isEditing} class:opacity-60={item.is_complete && !isEditing}
> >
{#if isEditing} {#if isEditing}
<!-- Edit Mode Form --> <!-- Edit Mode Form -->
<form on:submit|preventDefault={handleSaveEdit} class="flex flex-grow items-center gap-2"> <form
<input on:submit|preventDefault={handleSaveEdit}
type="text" class="flex w-full flex-grow items-center gap-2"
bind:value={editName}
required
class="flex-grow rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isSavingEdit}
aria-label="Edit item name"
/>
<input
type="text"
bind:value={editQuantity}
placeholder="Qty (opt.)"
class="w-20 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isSavingEdit}
aria-label="Edit item quantity"
/>
<button
type="submit"
class="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700 disabled:opacity-50"
disabled={isSavingEdit}
aria-label="Save changes"
> >
{isSavingEdit ? '...' : 'Save'} <!-- Name/Qty inputs, Save/Cancel buttons -->
</button>
<button
type="button"
on:click={cancelEdit}
class="rounded bg-gray-500 px-2 py-1 text-xs text-white hover:bg-gray-600"
disabled={isSavingEdit}
aria-label="Cancel edit"
>
Cancel
</button>
</form> </form>
{:else} {:else}
<!-- Display Mode --> <!-- Display Mode -->
@ -272,9 +261,9 @@
type="checkbox" type="checkbox"
checked={item.is_complete} checked={item.is_complete}
disabled={isToggling || isDeleting} disabled={isToggling || isDeleting}
on:change={handleToggleComplete}
aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}" aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}"
class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50" class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
on:change={handleToggleComplete}
/> />
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
<span <span
@ -294,12 +283,43 @@
Qty: {item.quantity} Qty: {item.quantity}
</span> </span>
{/if} {/if}
{#if item.is_complete && item.price != null}
<span class="mt-1 block text-xs font-semibold text-green-700">
${item.price.toFixed(2)}
</span>
{/if}
</div> </div>
</div> </div>
<!-- Action Buttons & Price Input Area -->
<div class="flex flex-shrink-0 items-center space-x-2"> <div class="flex flex-shrink-0 items-center space-x-2">
{#if item.is_complete}
<div class="flex items-center space-x-1">
<label for="price-{item.id}" class="text-sm text-gray-600">$</label>
<input
type="number"
id="price-{item.id}"
step="0.01"
min="0"
placeholder="Price"
bind:value={editPrice}
on:blur={handleSavePrice}
on:keydown={(e) => {
if (e.key === 'Enter') handleSavePrice();
}}
class="w-24 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={isSavingPrice}
aria-label="Item price"
/>
{#if isSavingPrice}
<span class="animate-pulse text-xs text-gray-500">...</span>
{/if}
</div>
{/if}
<button <button
on:click={startEdit} on:click={startEdit}
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-200 hover:text-gray-700" class="..."
title="Edit Item" title="Edit Item"
disabled={isToggling || isDeleting} disabled={isToggling || isDeleting}
> >
@ -307,7 +327,7 @@
</button> </button>
<button <button
on:click={handleDelete} on:click={handleDelete}
class="rounded p-1 text-xs text-red-400 hover:bg-red-100 hover:text-red-600" class="..."
title="Delete Item" title="Delete Item"
disabled={isToggling || isDeleting} disabled={isToggling || isDeleting}
> >

View File

@ -0,0 +1,59 @@
// src/lib/schemas/expense.ts
import type { UserPublic } from './user'; // Import UserPublic type
// --- Enums (Match backend Enum values) ---
export enum SplitTypeEnum {
EQUAL = "equal",
// CUSTOM = "custom" // Add later if needed
}
export enum SettlementActivityTypeEnum {
MARKED_PAID = "marked_paid",
MARKED_UNPAID = "marked_unpaid",
}
// --- Interfaces ---
// Represents a single user's share of an expense
export interface ExpenseSharePublic {
id: number;
expense_record_id: number;
user_id: number;
amount_owed: number; // Use number for frontend simplicity (or Decimal type)
is_paid: boolean;
user?: UserPublic | null; // Include user details for display
}
// Represents a log of settlement actions
export interface SettlementActivityPublic {
id: number;
expense_record_id: number;
payer_user_id: number; // Who marked it paid/unpaid
affected_user_id: number; // Whose share status changed
activity_type: SettlementActivityTypeEnum; // Use the Enum/string literal
timestamp: string; // ISO date string
// Optionally include nested user details if backend provides them
// payer?: UserPublic | null;
// affected_user?: UserPublic | null;
}
// Represents a finalized expense split record for a list
export interface ExpenseRecordPublic {
id: number;
list_id: number;
calculated_at: string; // ISO date string
calculated_by_id: number;
total_amount: number; // Use number for frontend simplicity (or Decimal type)
split_type: SplitTypeEnum; // Use the Enum/string literal
is_settled: boolean;
participants: number[]; // List of user IDs who participated
shares: ExpenseSharePublic[]; // Include the individual shares
settlement_activities: SettlementActivityPublic[]; // Include settlement history
}
// Schema for the request body of the settle endpoint
export interface SettleShareRequest {
affected_user_id: number; // The ID of the user whose share is being settled
}

View File

@ -2,24 +2,26 @@
<script lang="ts"> <script lang="ts">
// Svelte/SvelteKit Imports // Svelte/SvelteKit Imports
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte'; import type { PageData } from './$types'; // Correct import for PageData
import type { PageData } from './$types'; import { goto } from '$app/navigation'; // Import goto if needed for redirects
import { slide } from 'svelte/transition'; // For animating expense history
import { sineInOut } from 'svelte/easing'; // Easing function
import { writable, get } from 'svelte/store'; // Import get for sync access
// Component Imports // Component Imports
import ItemDisplay from '$lib/components/ItemDisplay.svelte'; import ItemDisplay from '$lib/components/ItemDisplay.svelte'; // Assuming ItemDisplay component path
import ImageOcrInput from '$lib/components/ImageOcrInput.svelte'; import OcrReview from '$lib/components/OcrReview.svelte';
import OcrReview from '$lib/components/OcrReview.svelte'; // Import Review Component import ImageOcrInput from '$lib/components/ImageOcrInput.svelte'; // Added import based on usage
// Utility/Store Imports // Utility/Store Imports
import { apiClient, ApiClientError } from '$lib/apiClient'; import { apiClient, ApiClientError } from '$lib/apiClient';
import { authStore } from '$lib/stores/authStore'; import { authStore } from 'c:/Users/Vinylnostalgia/Desktop/dev/doe/fe/src/lib/stores/authStore';
import { writable, get } from 'svelte/store';
// Schema Imports // Schema Imports
import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item'; import type { ItemPublic, ItemCreate, ItemUpdate } from '$lib/schemas/item';
import type { ListDetail, ListStatus } from '$lib/schemas/list'; import type { ListDetail, ListStatus, ListPublic } from '$lib/schemas/list';
import type { OcrExtractResponse } from '$lib/schemas/ocr'; import type { OcrExtractResponse } from '$lib/schemas/ocr';
import type { Message } from '$lib/schemas/message'; import type { Message } from '$lib/schemas/message'; // Corrected import
import type { ExpenseRecordPublic, ExpenseSharePublic } from '$lib/schemas/expense';
// --- DB and Sync Imports --- // --- DB and Sync Imports ---
import { import {
@ -31,51 +33,60 @@
} from '$lib/db'; } from '$lib/db';
import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService'; import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte';
// --- End DB and Sync Imports --- // --- End DB and Sync Imports ---
// --- Props --- // --- Props ---
export let data: PageData; // Contains initial { list: ListDetail } from server/cache/load export let data: PageData; // Contains { list: ListDetail, expenses?: ExpenseRecordPublic[], expensesError?: string | null }
// --- Local Reactive State --- // --- Local State ---
// Use a writable store locally to manage the list and items for easier updates const localListStore = writable<ListDetail | null>(null);
// Initialize with data from SSR/load function as fallback const localExpensesStore = writable<ExpenseRecordPublic[]>([]);
const localListStore = writable<ListDetail | null>(data.list); let initialLoadError: string | null = null;
// --- Add Item State --- // Add Item State
let newItemName = ''; let newItemName = '';
let newItemQuantity = ''; let newItemQuantity = '';
let isAddingItem = false; let isAddingItem = false;
let addItemError: string | null = null; let addItemError: string | null = null;
// General Item Update Error Display
// --- General Item Error Display ---
let itemUpdateError: string | null = null; let itemUpdateError: string | null = null;
let itemErrorTimeout: ReturnType<typeof setTimeout>; let itemErrorTimeout: ReturnType<typeof setTimeout>;
// Polling State
// --- Polling State ---
let pollIntervalId: ReturnType<typeof setInterval> | null = null; let pollIntervalId: ReturnType<typeof setInterval> | null = null;
let lastKnownStatus: { let lastKnownStatus: any | null = null;
// Ensure this stores Date objects or null
list_updated_at: Date;
latest_item_updated_at: Date | null;
item_count: number;
} | null = null;
let isRefreshing = false; let isRefreshing = false;
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds const POLLING_INTERVAL_MS = 15000;
// OCR State
// --- OCR State ---
let showOcrModal = false; let showOcrModal = false;
let isProcessingOcr = false; // Loading state for API call let isProcessingOcr = false;
let ocrError: string | null = null; // Error during API call let ocrError: string | null = null;
let showOcrReview = false; // Controls review modal visibility let showOcrReview = false;
let ocrResults: string[] = []; // Stores results from OCR API let ocrResults: string[] = [];
let isConfirmingOcrItems = false; // Loading state for adding items after review let isConfirmingOcrItems = false;
let confirmOcrError: string | null = null; // Error during final add after review let confirmOcrError: string | null = null;
// --- End OCR State --- // Expense Calculation State
let isCalculatingSplit = false;
let calculateSplitError: string | null = null;
let showExpenseHistory = false;
// --- NEW: Settlement State ---
let isSettling: Record<number, boolean> = {}; // Track loading state per share_id
let settleError: string | null = null; // Error message for settlement actions
// --- End Settlement State ---
// --- End Local State ---
// --- Computed State ---
let totalCost: number = 0;
$: if ($localListStore?.items) {
totalCost = $localListStore.items
.filter((item) => item.price != null && Number(item.price) > 0) // Only include items with a positive price
.reduce((sum, item) => sum + Number(item.price), 0);
}
// --- Lifecycle --- // --- Lifecycle ---
onMount(() => { onMount(() => {
let listId: number | null = null;
(async () => { (async () => {
let listId: number | null = null;
try { try {
listId = parseInt($page.params.listId, 10); listId = parseInt($page.params.listId, 10);
} catch { } catch {
@ -84,10 +95,19 @@
if (!listId) { if (!listId) {
console.error('List Detail Mount: Invalid or missing listId in params.'); console.error('List Detail Mount: Invalid or missing listId in params.');
initialLoadError = 'Invalid List ID specified in URL.'; // Show error
localListStore.set(null); // Ensure list is null
localExpensesStore.set([]);
return; return;
} }
// 1. Load from IndexedDB first // Set initial state from potentially stale SSR/load data passed via prop
localListStore.set(data.list);
localExpensesStore.set(data.expenses ?? []);
initialLoadError = data.expensesError ?? null;
initializePollingStatus(data.list);
// Load from DB and Fetch fresh data in browser
if (browser) { if (browser) {
console.log('List Detail Mount: Loading from IndexedDB for list', listId); console.log('List Detail Mount: Loading from IndexedDB for list', listId);
const listFromDb = await getListFromDb(listId); const listFromDb = await getListFromDb(listId);
@ -95,24 +115,22 @@
console.log('List Detail Mount: Found list in DB', listFromDb); console.log('List Detail Mount: Found list in DB', listFromDb);
localListStore.set(listFromDb); localListStore.set(listFromDb);
initializePollingStatus(listFromDb); initializePollingStatus(listFromDb);
// TODO: Load expenses from DB too? Requires adding expenses to DB schema/functions
} else { } else {
console.log('List Detail Mount: List not found in DB, using SSR/load data.'); console.log('List Detail Mount: List not found in DB, using SSR/load data.');
localListStore.set(data.list); // Already set above from data prop
initializePollingStatus(data.list);
} }
// 2. If online, fetch fresh data in background // If online, trigger API fetches in background
if (navigator.onLine) { if (navigator.onLine) {
console.log('List Detail Mount: Online, fetching fresh data...'); console.log('List Detail Mount: Online, fetching fresh data...');
fetchAndUpdateList(listId); // Don't await fetchAndUpdateList(listId); // Don't await
fetchExpenseHistory(listId); // Don't await
processSyncQueue(); // Don't await processSyncQueue(); // Don't await
} }
// 3. Start polling // Start polling
startPolling(); startPolling();
} else {
localListStore.set(data.list);
initializePollingStatus(data.list);
} }
})(); })();
@ -122,110 +140,21 @@
}; };
}); });
// Helper to fetch from API and update DB + Store // --- Data Fetching ---
/** Fetches latest list details, updates DB and local store */
async function fetchAndUpdateList(listId: number) { async function fetchAndUpdateList(listId: number) {
// Don't trigger multiple refreshes concurrently if (isRefreshing) return; // Prevent concurrent refreshes
if (isRefreshing) return; isRefreshing = true;
console.log('List Detail: Fetching fresh list data for', listId);
isRefreshing = true; // Show refresh indicator
console.log('List Detail: Fetching fresh data for list', listId);
try { try {
// Fetch the entire list detail (including items) from the API
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`); const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
// Update IndexedDB with the latest data
await putListToDb(freshList); await putListToDb(freshList);
// Update the local Svelte store, which triggers UI updates
localListStore.set(freshList); localListStore.set(freshList);
// Polling status will be reset by checkListStatus after refresh
// Reset the polling status based on this fresh data
// (This ensures the next poll compares against the latest fetched state)
initializePollingStatus(freshList);
console.log('List Detail: Fetched and updated list', listId); console.log('List Detail: Fetched and updated list', listId);
clearItemError(); // Clear any lingering item errors after a successful refresh clearItemError();
} catch (err) { } catch (err) {
console.error('List Detail: Failed to fetch fresh list data', err); console.error('List Detail: Failed to fetch fresh list data', err);
// Display an error message to the user via the existing error handling mechanism
handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
);
// Note: If the error was 401/403, the apiClient or layout guard should handle logout/redirect
} finally {
isRefreshing = false; // Hide refresh indicator
}
}
// --- Polling Logic ---
function startPolling() {
stopPolling();
const currentList = get(localListStore);
if (!currentList) return;
console.log(
`Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms`
);
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
}
function stopPolling() {
if (pollIntervalId) {
clearInterval(pollIntervalId);
pollIntervalId = null;
}
}
async function checkListStatus() {
const currentList = get(localListStore);
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
return;
}
console.log(`Polling: Checking status for list ${currentList.id}`);
try {
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
? new Date(currentStatus.latest_item_updated_at)
: null;
const listChanged =
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
const itemsChanged =
currentLatestItemUpdatedAt?.getTime() !==
lastKnownStatus.latest_item_updated_at?.getTime() ||
currentStatus.item_count !== lastKnownStatus.item_count;
if (listChanged || itemsChanged) {
console.log('Polling: Change detected!', { listChanged, itemsChanged });
await refreshListData();
// Update known status AFTER successful refresh
lastKnownStatus = {
list_updated_at: currentListUpdatedAt,
latest_item_updated_at: currentLatestItemUpdatedAt,
item_count: currentStatus.item_count
};
} else {
console.log('Polling: No changes detected.');
}
} catch (err) {
console.error('Polling: Failed to fetch list status:', err);
}
}
async function refreshListData() {
const listId = get(localListStore)?.id;
if (!listId) return;
isRefreshing = true;
console.log(`Polling: Refreshing full data for list ${listId}`);
try {
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
await putListToDb(freshList);
localListStore.set(freshList);
// No need to re-init polling status here, checkListStatus updates it after refresh
console.log('Polling: List data refreshed successfully.');
} catch (err) {
console.error(`Polling: Failed to refresh list data for ${listId}:`, err);
handleItemUpdateError( handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' }) new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
); );
@ -234,6 +163,26 @@
} }
} }
/** Fetches latest expense history, updates local store */
async function fetchExpenseHistory(listId: number) {
console.log(`Expenses: Fetching history for list ${listId}`);
initialLoadError = null; // Clear initial error on refresh attempt
try {
const expenses = await apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${listId}/expenses`);
localExpensesStore.set(expenses ?? []);
console.log('Expenses: History fetched successfully.');
// TODO: Update expense records in IndexedDB?
} catch (err) {
console.error('Expenses: Failed to fetch history', err);
initialLoadError = `Failed to load expense history: ${
err instanceof Error ? err.message : 'Unknown error'
}`;
localExpensesStore.set([]); // Clear potentially stale data on error
}
}
// --- Polling ---
/** Initializes the last known status for polling comparisons */
function initializePollingStatus(listData: ListDetail | null) { function initializePollingStatus(listData: ListDetail | null) {
if (!listData) { if (!listData) {
lastKnownStatus = null; lastKnownStatus = null;
@ -243,6 +192,7 @@
const listUpdatedAt = new Date(listData.updated_at); const listUpdatedAt = new Date(listData.updated_at);
let latestItemUpdate: Date | null = null; let latestItemUpdate: Date | null = null;
if (listData.items && listData.items.length > 0) { if (listData.items && listData.items.length > 0) {
// Find the latest date string first, then convert
const latestDateString = listData.items.reduce( const latestDateString = listData.items.reduce(
(latest, item) => (item.updated_at > latest ? item.updated_at : latest), (latest, item) => (item.updated_at > latest ? item.updated_at : latest),
listData.items[0].updated_at listData.items[0].updated_at
@ -261,25 +211,142 @@
} }
} }
/** Starts the polling interval */
function startPolling() {
stopPolling();
const currentList = get(localListStore);
if (!currentList || !browser) return; // Only poll in browser
console.log(
`Polling: Starting polling for list ${currentList.id} every ${POLLING_INTERVAL_MS}ms`
);
pollIntervalId = setInterval(checkListStatus, POLLING_INTERVAL_MS);
}
/** Stops the polling interval */
function stopPolling() {
if (pollIntervalId) {
console.log(`Polling: Stopping polling for list ${get(localListStore)?.id}`);
clearInterval(pollIntervalId);
pollIntervalId = null;
}
}
/** Checks the list status endpoint and triggers a full refresh if changes detected */
async function checkListStatus() {
const currentList = get(localListStore);
// Skip if no list, already refreshing, status unknown, or offline
if (!currentList || isRefreshing || !lastKnownStatus || (browser && !navigator.onLine)) {
if (browser && !navigator.onLine) console.log('Polling: Offline, skipping status check.');
return;
}
console.log(`Polling: Checking status for list ${currentList.id}`);
try {
const currentStatus = await apiClient.get<ListStatus>(`/v1/lists/${currentList.id}/status`);
const currentListUpdatedAt = new Date(currentStatus.list_updated_at);
const currentLatestItemUpdatedAt = currentStatus.latest_item_updated_at
? new Date(currentStatus.latest_item_updated_at)
: null;
// Compare timestamps using getTime() and item count
const listChanged =
currentListUpdatedAt.getTime() !== lastKnownStatus.list_updated_at.getTime();
const itemsChanged =
currentLatestItemUpdatedAt?.getTime() !==
lastKnownStatus.latest_item_updated_at?.getTime() ||
Number(currentStatus.item_count) !== lastKnownStatus.item_count;
if (listChanged || itemsChanged) {
console.log('Polling: Change detected!', { listChanged, itemsChanged });
await refreshListData(); // Fetch full data
// Update known status AFTER successful refresh
lastKnownStatus = {
list_updated_at: currentListUpdatedAt,
latest_item_updated_at: currentLatestItemUpdatedAt,
item_count: Number(currentStatus.item_count)
};
} else {
console.log('Polling: No changes detected.');
}
} catch (err) {
console.error('Polling: Failed to fetch list status:', err);
// Potentially stop polling after several errors, or show persistent error
}
}
/** Refetches both list details and expense history */
async function refreshListData() {
const listId = get(localListStore)?.id;
if (!listId || !browser || !navigator.onLine) return; // Only refresh if online
if (isRefreshing) return;
isRefreshing = true;
console.log(`Polling: Refreshing full data for list ${listId}`);
try {
// Fetch list and expenses in parallel
const [listRes, expRes] = await Promise.allSettled([
apiClient.get<ListDetail>(`/v1/lists/${listId}`),
apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${listId}/expenses`)
]);
let listRefreshed = false;
if (listRes.status === 'fulfilled' && listRes.value) {
await putListToDb(listRes.value);
localListStore.set(listRes.value);
// Don't reset polling status here, checkListStatus does it after refresh
console.log('Polling: List data refreshed successfully.');
listRefreshed = true;
} else {
console.error(
'Polling: List refresh failed',
listRes.status === 'rejected' ? listRes.reason : 'Unknown error'
);
}
if (expRes.status === 'fulfilled' && expRes.value) {
localExpensesStore.set(expRes.value ?? []);
console.log('Polling: Expense history refreshed successfully.');
// TODO: Update expenses in IndexedDB?
} else if (expRes.status === 'rejected') {
console.error('Polling: Expense history refresh failed', expRes.reason);
}
if (listRefreshed) {
clearItemError(); // Clear errors on successful refresh
} else {
handleItemUpdateError(
new CustomEvent('updateError', { detail: 'Failed to refresh list data.' })
);
}
} catch (err) {
console.error(`Polling: Unexpected error during refresh for ${listId}:`, err);
handleItemUpdateError(new CustomEvent('updateError', { detail: 'Failed to refresh data.' }));
} finally {
isRefreshing = false;
}
}
// --- Event Handlers from ItemDisplay --- // --- Event Handlers from ItemDisplay ---
/** Handles the itemUpdated event from ItemDisplay */ /** Handles the itemUpdated event from ItemDisplay */
function handleItemUpdated(event: CustomEvent<ItemPublic>) { async function handleItemUpdated(event: CustomEvent<ItemPublic>) {
const updatedItem = event.detail; const updatedItem = event.detail;
console.log('Parent received itemUpdated:', updatedItem); console.log('Parent received itemUpdated:', updatedItem);
// Update store for UI // Update store for UI
localListStore.update((currentList) => { localListStore.update((currentList) => {
if (!currentList) return null; if (!currentList) return null;
const index = currentList.items.findIndex((i) => i.id === updatedItem.id); const index = currentList.items.findIndex((i) => String(i.id) === String(updatedItem.id));
if (index !== -1) { if (index !== -1) {
currentList.items[index] = updatedItem; currentList.items[index] = updatedItem;
} else {
// Item might be new from sync, add it? Or rely on full refresh.
console.warn('Updated item not found in local list, might need refresh.', updatedItem.id);
} }
return { ...currentList, items: [...currentList.items] }; // Return new object return { ...currentList, items: [...currentList.items] };
}); });
// DB update was handled optimistically in ItemDisplay
clearItemError(); clearItemError();
} }
/** Handles the itemDeleted event from ItemDisplay */ /** Handles the itemDeleted event from ItemDisplay */
function handleItemDeleted(event: CustomEvent<number>) { async function handleItemDeleted(event: CustomEvent<number>) {
const deletedItemId = event.detail; const deletedItemId = event.detail;
console.log('Parent received itemDeleted:', deletedItemId); console.log('Parent received itemDeleted:', deletedItemId);
// Update store for UI // Update store for UI
@ -287,9 +354,10 @@
if (!currentList) return null; if (!currentList) return null;
return { return {
...currentList, ...currentList,
items: currentList.items.filter((item) => item.id !== deletedItemId) items: currentList.items.filter((item) => String(item.id) !== String(deletedItemId))
}; };
}); });
// DB update was handled optimistically in ItemDisplay
clearItemError(); clearItemError();
} }
@ -325,16 +393,15 @@
clearItemError(); clearItemError();
// 1. Optimistic UI Update with Temporary ID // 1. Optimistic UI Update with Temporary ID
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const currentUserId = get(authStore).user?.id; const currentUserId = get(authStore).user?.id;
if (!currentUserId) { if (!currentUserId) {
addItemError = 'Cannot add item: User not identified.'; addItemError = 'Cannot add item: User not identified.';
isAddingItem = false; isAddingItem = false;
return; return;
} }
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const optimisticItem: ItemPublic = { const optimisticItem: ItemPublic = {
// Use temporary string ID for optimistic UI id: tempId as any, // Cast needed as DB expects number, but temp is string/negative
id: tempId as any, // Cast needed as DB expects number, but temp is string
list_id: currentList.id, list_id: currentList.id,
name: newItemName.trim(), name: newItemName.trim(),
quantity: newItemQuantity.trim() || null, quantity: newItemQuantity.trim() || null,
@ -373,7 +440,7 @@
} catch (dbError) { } catch (dbError) {
console.error('Failed to queue add item action:', dbError); console.error('Failed to queue add item action:', dbError);
addItemError = 'Failed to save item for offline sync.'; addItemError = 'Failed to save item for offline sync.';
// Revert optimistic UI update // Revert optimistic UI update: convert item id to string for reliable comparison
localListStore.update((list) => localListStore.update((list) =>
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
); );
@ -418,17 +485,7 @@
} catch (err) { } catch (err) {
console.error('OCR failed:', err); console.error('OCR failed:', err);
if (err instanceof ApiClientError) { if (err instanceof ApiClientError) {
let detail = 'Failed to process image for items.'; ocrError = `OCR API Error (${err.status}): ${err.body?.message || 'Unknown API error'}`;
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
detail = (err.errorData as { detail: string }).detail;
}
if (err.status === 413) {
detail = `Image file too large.`;
}
if (err.status === 400) {
detail = `Invalid image file type or request.`;
}
ocrError = `OCR Error (${err.status}): ${detail}`;
} else if (err instanceof Error) { } else if (err instanceof Error) {
ocrError = `OCR Network/Client Error: ${err.message}`; ocrError = `OCR Network/Client Error: ${err.message}`;
} else { } else {
@ -445,7 +502,6 @@
closeOcrReview(); closeOcrReview();
if (!itemNamesToAdd || itemNamesToAdd.length === 0) { if (!itemNamesToAdd || itemNamesToAdd.length === 0) {
console.log('OCR Confirm: No items selected to add.');
return; return;
} }
@ -453,20 +509,20 @@
confirmOcrError = null; confirmOcrError = null;
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
const currentList = get(localListStore); // Get current list state const currentList = get(localListStore);
const currentUserId = get(authStore).user?.id; const currentUserId = get(authStore).user?.id;
if (!currentList || !currentUserId) { if (!currentList || !currentUserId) {
confirmOcrError = 'Cannot add items: list or user data missing.'; confirmOcrError = 'Cannot add items: List or user information missing.';
isConfirmingOcrItems = false; isConfirmingOcrItems = false;
return; return;
} }
console.log(`OCR Confirm: Attempting to add ${itemNamesToAdd.length} items...`); console.log(`OCR Confirm: Attempting to add ${itemNamesToAdd.length} items...`);
// Process items sequentially for clearer feedback/error handling in MVP // Process items sequentially
for (const name of itemNamesToAdd) { for (const name of itemNamesToAdd) {
if (!name.trim()) continue; // Skip empty names if (!name.trim()) continue;
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`; const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
// Optimistic UI update // Optimistic UI update
@ -474,7 +530,7 @@
id: tempId as any, id: tempId as any,
list_id: currentList.id, list_id: currentList.id,
name: name.trim(), name: name.trim(),
quantity: null, quantity: null, // Default quantity for OCR items
is_complete: false, is_complete: false,
price: null, price: null,
added_by_id: currentUserId, added_by_id: currentUserId,
@ -499,7 +555,7 @@
} catch (dbError) { } catch (dbError) {
console.error(`Failed to queue item '${name}':`, dbError); console.error(`Failed to queue item '${name}':`, dbError);
failCount++; failCount++;
// Revert optimistic UI update for this specific item // Revert optimistic UI update: convert item id to string for reliable comparison
localListStore.update((list) => localListStore.update((list) =>
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
); );
@ -508,7 +564,6 @@
// Trigger sync if online // Trigger sync if online
if (browser && navigator.onLine) processSyncQueue(); if (browser && navigator.onLine) processSyncQueue();
isConfirmingOcrItems = false; isConfirmingOcrItems = false;
// Provide feedback // Provide feedback
@ -516,15 +571,121 @@
confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`; confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`;
} else { } else {
console.log(`Successfully queued ${successCount} items from OCR.`); console.log(`Successfully queued ${successCount} items from OCR.`);
// Optionally show a temporary success toast/message }
}
// --- Expense Calculation Logic ---
async function handleCalculateSplit() {
const currentList = get(localListStore);
if (!currentList || isCalculatingSplit) return;
isCalculatingSplit = true;
calculateSplitError = null;
clearItemError();
console.log(`Calculating split for list ${currentList.id}`);
try {
const newExpenseRecord = await apiClient.post<ExpenseRecordPublic>(
`/v1/lists/${currentList.id}/calculate-split`,
{}
);
console.log('Split calculation successful:', newExpenseRecord);
// Add the new record to the beginning of the local expense history
localExpensesStore.update((history) => [newExpenseRecord, ...history]);
// Show history section if not already visible
showExpenseHistory = true;
alert('Expense split calculated and recorded successfully!'); // Simple feedback
} catch (err) {
console.error('Split calculation failed:', err);
if (err instanceof ApiClientError) {
calculateSplitError = `API Error (${err.status}): ${err.body?.message || 'Split calculation failed.'}`;
} else if (err instanceof Error) {
calculateSplitError = `Error: ${err.message}`;
} else {
calculateSplitError = 'An unexpected error occurred during split calculation.';
}
} finally {
isCalculatingSplit = false;
}
}
async function handleMarkPaid(recordId: number, share: ExpenseSharePublic) {
if (isSettling[share.id]) return; // Prevent double clicks
// Confirmation Dialog
const userName = share.user?.name || share.user?.email || `User ${share.user_id}`;
if (!confirm(`Mark ${userName}'s share of $${share.amount_owed.toFixed(2)} as paid?`)) {
return; // User cancelled
}
isSettling[share.id] = true;
settleError = null; // Clear previous errors
clearItemError(); // Clear other errors too
console.log(
`Attempting to mark share ${share.id} (User ${share.user_id}) as paid for record ${recordId}`
);
try {
const requestBody = { affected_user_id: share.user_id };
await apiClient.post<Message>(`/v1/expenses/${recordId}/settle`, requestBody);
console.log(`Successfully marked share ${share.id} as paid.`);
// Update the local store optimistically / definitively after success
localExpensesStore.update((records) => {
const recordIndex = records.findIndex((r) => r.id === recordId);
if (recordIndex === -1) return records; // Should not happen
const shareIndex = records[recordIndex].shares.findIndex((s) => s.id === share.id);
if (shareIndex === -1) return records; // Should not happen
// Create new objects/arrays to trigger reactivity
const updatedShare = { ...records[recordIndex].shares[shareIndex], is_paid: true };
const updatedShares = [...records[recordIndex].shares];
updatedShares[shareIndex] = updatedShare;
// Check if all shares are now paid to update the record's status
const allPaid = updatedShares.every((s) => s.is_paid);
const updatedRecord = {
...records[recordIndex],
shares: updatedShares,
is_settled: allPaid
};
const updatedRecords = [...records];
updatedRecords[recordIndex] = updatedRecord;
return updatedRecords;
});
// Optional: You could re-fetch the specific expense record or all expenses
// to get the definitive state (including settlement activities), but the
// local update provides immediate feedback.
// await fetchExpenseHistory(get(localListStore)?.id ?? 0);
} catch (err) {
console.error(`Failed to mark share ${share.id} as paid:`, err);
if (err instanceof ApiClientError) {
let detail = 'Failed to update settlement status.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
detail = (err.errorData as { detail: string }).detail;
}
settleError = `Error (${err.status}): ${detail}`;
} else if (err instanceof Error) {
settleError = `Error: ${err.message}`;
} else {
settleError = 'An unexpected error occurred.';
}
} finally {
// Create a new object to ensure reactivity triggers correctly for the specific key
isSettling = { ...isSettling, [share.id]: false };
} }
} }
</script> </script>
<!-- Template --> <!-- TEMPLATE -->
{#if $localListStore} {#if $localListStore}
{@const list = $localListStore} {@const list = $localListStore}
<!-- Create local const for easier access in template --> <!-- Create local const for easier access -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Sync Status Indicator --> <!-- Sync Status Indicator -->
{#if $syncStatus === 'syncing'} {#if $syncStatus === 'syncing'}
@ -540,6 +701,11 @@
role="alert" role="alert"
> >
Sync Error: {$syncError} Sync Error: {$syncError}
<button
class="ml-2 font-semibold underline"
on:click={triggerSync}
title="Retry Synchronization">Retry</button
>
</div> </div>
{/if} {/if}
@ -561,13 +727,19 @@
list.updated_at list.updated_at
).toLocaleString()} ).toLocaleString()}
</p> </p>
<!-- Display Total Cost -->
{#if totalCost > 0}
<div class="mt-2 font-semibold text-gray-700">
Total Cost (Priced Items): ${totalCost.toFixed(2)}
</div>
{/if}
</div> </div>
<div class="flex flex-shrink-0 items-center space-x-2"> <div class="flex flex-shrink-0 items-center space-x-2">
<!-- Action Buttons --> <!-- Action Buttons -->
{#if isRefreshing} {#if isRefreshing}
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span> <span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
{/if} {/if}
<!-- OCR Button with Progress Indication --> <!-- OCR Button -->
<button <button
type="button" type="button"
on:click={openOcrModal} on:click={openOcrModal}
@ -575,45 +747,9 @@
class="inline-flex items-center rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" class="inline-flex items-center rounded bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
> >
{#if isProcessingOcr} {#if isProcessingOcr}
<svg <!-- Spinner --> Processing...
class="mr-2 h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Processing...
{:else if isConfirmingOcrItems} {:else if isConfirmingOcrItems}
<svg <!-- Spinner --> Adding Items...
class="mr-2 h-4 w-4 animate-spin text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
><circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle><path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path></svg
>
Adding Items...
{:else} {:else}
📷 Add via Photo 📷 Add via Photo
{/if} {/if}
@ -626,10 +762,10 @@
</a> </a>
</div> </div>
</div> </div>
{#if ocrError || confirmOcrError} {#if ocrError || confirmOcrError || calculateSplitError}
<!-- Display OCR/Confirm errors --> <!-- Display Action errors -->
<div class="rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" role="alert"> <div class="rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" role="alert">
{ocrError || confirmOcrError} {ocrError || confirmOcrError || calculateSplitError}
</div> </div>
{/if} {/if}
@ -690,7 +826,7 @@
{/if} {/if}
{#if list.items && list.items.length > 0} {#if list.items && list.items.length > 0}
<ul class="space-y-2"> <ul class="space-y-2">
<!-- Use {#key} block to help Svelte efficiently update the list when items are added/removed/reordered --> <!-- Use {#key} block to help Svelte efficiently update the list -->
{#each list.items as item (item.id)} {#each list.items as item (item.id)}
<ItemDisplay <ItemDisplay
{item} {item}
@ -705,6 +841,111 @@
{/if} {/if}
</div> </div>
<!-- Expense Calculation Button -->
{#if list.group_id && totalCost > 0}
<div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">Expense Splitting</h2>
<button
type="button"
on:click={handleCalculateSplit}
disabled={isCalculatingSplit}
class="rounded bg-purple-600 px-4 py-2 font-medium text-white transition hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isCalculatingSplit ? 'Calculating...' : 'Calculate & Finalize Split'}
</button>
{#if calculateSplitError}
<p class="mt-3 text-sm text-red-600">{calculateSplitError}</p>
{/if}
<p class="mt-2 text-xs text-gray-500">
Calculates an equal split based on current item prices for all group members.
</p>
</div>
{/if}
<!-- Expense History Section -->
<div class="rounded bg-white p-6 shadow">
<button
class="mb-0 w-full text-left text-xl font-semibold text-gray-700 hover:text-blue-600"
on:click={() => (showExpenseHistory = !showExpenseHistory)}
>
Expense History {showExpenseHistory ? '⏷' : '⏵'}
</button>
{#if showExpenseHistory}
<div class="mt-4 border-t pt-4" transition:slide={{ duration: 300, easing: sineInOut }}>
{#if initialLoadError}
<div class="rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
<p class="font-bold">Error Loading History</p>
<p>{initialLoadError}</p>
</div>
{:else if !$localExpensesStore || $localExpensesStore.length === 0}
<p class="py-4 text-center text-gray-500">
No expense splits have been calculated for this list yet.
</p>
{:else}
<ul class="space-y-4">
{#each $localExpensesStore as record (record.id)}
<li class="rounded border border-gray-200 p-4">
<p class="text-sm font-medium text-gray-800">
Split calculated on {new Date(record.calculated_at).toLocaleString()}
</p>
<p class="text-xs text-gray-500">
Record ID: {record.id} | By User: {record.calculated_by_id} | Status: {record.is_settled
? 'Settled'
: 'Unsettled'}
</p>
<p class="mt-2 text-lg font-semibold text-gray-900">
Total: ${Number(record.total_amount).toFixed(2)}
</p>
<h4 class="mb-2 mt-3 text-sm font-semibold text-gray-600">
Shares ({record.shares?.length ?? 0}):
</h4>
{#if record.shares && record.shares.length > 0}
<ul class="space-y-1 pl-4">
{#each record.shares as share (share.id)}
<li class="flex items-center justify-between text-sm">
<span class="text-gray-700">
{share.user?.name || share.user?.email || `User ${share.user_id}`}:
<strong>${Number(share.amount_owed).toFixed(2)}</strong>
</span>
{#if share.is_paid}
<span
class="ml-2 rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
>Paid ✅</span
>
{:else}
<span
class="ml-2 rounded bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
>Unpaid ⏳</span
>
<button
on:click={() => handleMarkPaid(record.id, share)}
disabled={isSettling[share.id]}
class="ml-auto whitespace-nowrap rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 transition hover:bg-blue-200 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isSettling[share.id]}{:else}Mark Paid{/if}
</button>
<button
class="ml-2 text-xs text-blue-600 hover:underline"
title="Mark as Paid (NYI)">Mark Paid</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-gray-500">No shares found for this record.</p>
{/if}
<!-- Add Settlement Activity Display later -->
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<!-- Back Link --> <!-- Back Link -->
<div class="mt-6 border-t border-gray-200 pt-6"> <div class="mt-6 border-t border-gray-200 pt-6">
<a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a> <a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
@ -713,14 +954,21 @@
{:else} {:else}
<!-- Fallback if list data is somehow null/undefined after load function --> <!-- Fallback if list data is somehow null/undefined after load function -->
<p class="text-center text-gray-500">Loading list data...</p> <p class="text-center text-gray-500">Loading list data...</p>
{#if initialLoadError}
<div class="mt-4 rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
<p class="font-bold">Error Loading List</p>
<p>{initialLoadError}</p>
<a href="/dashboard" class="mt-2 inline-block text-sm text-blue-600 hover:underline"
>← Go to Dashboard</a
>
</div>
{/if}
{/if} {/if}
<!-- OCR Input Modal --> <!-- Modals -->
{#if showOcrModal} {#if showOcrModal}
<ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} /> <ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} />
{/if} {/if}
<!-- OCR Review Modal -->
{#if showOcrReview} {#if showOcrReview}
<OcrReview <OcrReview
initialItems={ocrResults} initialItems={ocrResults}

View File

@ -2,52 +2,99 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { apiClient, ApiClientError } from '$lib/apiClient'; import { apiClient, ApiClientError } from '$lib/apiClient';
import type { ListDetail } from '$lib/schemas/list'; import type { ListDetail } from '$lib/schemas/list';
// --- Use the correct generated type --- import type { ExpenseRecordPublic } from '$lib/schemas/expense'; // Import expense type
import type { PageLoad } from './$types'; // This type includes correctly typed 'params' import type { PageLoad } from './$types'; // SvelteKit's type for load functions
// Define the expected shape of the data returned by this load function
export interface ListDetailPageLoadData { export interface ListDetailPageLoadData {
list: ListDetail; list: ListDetail; // The fetched list data (including items)
expenses: ExpenseRecordPublic[]; // Array of expense records for the list
expensesError?: string | null; // Optional error message specifically for expense loading
} }
/**
* Load function for the List Detail page.
* Fetches both the list details (including items) and the expense history for the list.
* Handles errors and ensures the user has permission to view the list.
*/
export const load: PageLoad<ListDetailPageLoadData> = async ({ params, fetch }) => { export const load: PageLoad<ListDetailPageLoadData> = async ({ params, fetch }) => {
// Get listId from the URL parameter provided by SvelteKit routing
const listId = params.listId; const listId = params.listId;
console.log(`List Detail page load: Fetching data for list ID: ${listId}`); console.log(`List Detail page load: Fetching data for list ID: ${listId}`);
// Validate the listId parameter
if (!listId || isNaN(parseInt(listId, 10))) { if (!listId || isNaN(parseInt(listId, 10))) {
throw error(400, 'Invalid List ID'); console.error("List Detail load: Invalid List ID parameter", listId);
throw error(400, 'Invalid List ID provided in URL.'); // Use SvelteKit's error helper
} }
const numericListId = parseInt(listId, 10);
try { try {
// Fetch the specific list details (expecting items to be included) // Fetch list details and expense history concurrently for efficiency
// The backend GET /api/v1/lists/{list_id} should return ListDetail schema const [listResult, expensesResult] = await Promise.allSettled([
const listData = await apiClient.get<ListDetail>(`/v1/lists/${listId}`); apiClient.get<ListDetail>(`/v1/lists/${numericListId}`), // Fetch specific list details
apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${numericListId}/expenses`) // Fetch expense history
]);
if (!listData) { let listData: ListDetail;
// Should not happen if API call was successful, but check defensively let expensesData: ExpenseRecordPublic[] = [];
throw error(404, 'List not found (API returned no data)'); let expensesLoadError: string | null = null;
}
console.log('List Detail page load: Data fetched successfully', listData); // --- Process List Result (Critical) ---
return { if (listResult.status === 'fulfilled' && listResult.value) {
list: listData listData = listResult.value;
}; console.log(`List Detail load: Successfully fetched list ${numericListId}`);
} catch (err) {
console.error(`List Detail page load: Failed to fetch list ${listId}:`, err);
if (err instanceof ApiClientError) {
if (err.status === 404) {
throw error(404, 'List not found');
}
if (err.status === 403) {
// User is authenticated (layout guard passed) but not member/creator
throw error(403, 'Forbidden: You do not have permission to view this list');
}
// For other API errors (like 500)
throw error(err.status || 500, `API Error: ${err.message}`);
} else if (err instanceof Error) {
// Network or other client errors
throw error(500, `Failed to load list data: ${err.message}`);
} else { } else {
// Unknown error // Handle list fetch failure - this is critical, so throw SvelteKit error
throw error(500, 'An unexpected error occurred while loading list data.'); const reason = listResult.status === 'rejected' ? listResult.reason : new Error('List data was unexpectedly missing after fetch.');
console.error(`List Detail page load: Failed to fetch critical list data for ${numericListId}:`, reason);
if (reason instanceof ApiClientError) {
// Throw specific SvelteKit errors based on API status code
if (reason.status === 404) {
throw error(404, 'List not found.');
} }
if (reason.status === 403) {
throw error(403, 'Forbidden: You do not have permission to view this list.');
}
// Throw a generic server error for other API client issues
throw error(reason.status || 500, `API Error loading list: ${reason.message}`);
}
// Throw a generic 500 error for non-API errors during list fetch
throw error(500, `Failed to load list data: ${reason instanceof Error ? reason.message : 'Unknown error'}`);
}
// --- Process Expenses Result (Non-Critical) ---
// If fetching expenses fails, we still render the page but show an error message.
if (expensesResult.status === 'fulfilled' && expensesResult.value) {
expensesData = expensesResult.value ?? [];
console.log(`List Detail load: Successfully fetched ${expensesData.length} expense records for list ${numericListId}`);
} else {
const reason = expensesResult.status === 'rejected' ? expensesResult.reason : new Error('Expenses data was unexpectedly missing after fetch.');
console.error(`List Detail page load: Failed to fetch expense history for list ${numericListId}:`, reason);
// Store the error message to be passed to the page component
expensesLoadError = `Failed to load expense history: ${reason instanceof Error ? reason.message : 'Unknown error'}`;
// We don't throw here, allowing the page to render with the list data
}
// Return all data needed by the page component
return {
list: listData,
expenses: expensesData,
expensesError: expensesLoadError
};
} catch (err) {
// Catch errors thrown by the list processing block above or other unexpected errors
console.error(`List Detail page load: Unexpected error during data fetching for list ${listId}:`, err);
// Check if it's a SvelteKit error object (already thrown with status/message)
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
throw err; // Re-throw SvelteKit errors
}
// Throw a generic 500 error for any other unexpected issues
throw error(500, `An unexpected error occurred while loading page data: ${err instanceof Error ? err.message : 'Unknown error'}`);
} }
}; };