Compare commits
2 Commits
53c7382b88
...
727394a0eb
Author | SHA1 | Date | |
---|---|---|---|
![]() |
727394a0eb | ||
![]() |
839487567a |
@ -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 ###
|
@ -8,6 +8,8 @@ from app.api.v1.endpoints import groups
|
||||
from app.api.v1.endpoints import invites
|
||||
from app.api.v1.endpoints import lists
|
||||
from app.api.v1.endpoints import items
|
||||
from app.api.v1.endpoints import ocr
|
||||
from app.api.v1.endpoints import expenses
|
||||
|
||||
api_router_v1 = APIRouter()
|
||||
|
||||
@ -18,5 +20,7 @@ api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"])
|
||||
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(items.router, tags=["Items"])
|
||||
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
|
||||
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
45
be/app/api/v1/endpoints/expenses.py
Normal file
45
be/app/api/v1/endpoints/expenses.py
Normal 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")
|
@ -98,18 +98,13 @@ async def read_list_items(
|
||||
return items
|
||||
|
||||
|
||||
@router.put(
|
||||
"/items/{item_id}", # Operate directly on item ID
|
||||
response_model=ItemPublic,
|
||||
summary="Update Item",
|
||||
tags=["Items"]
|
||||
)
|
||||
@router.put("/items/{item_id}", response_model=ItemPublic, summary="Update Item", tags=["Items"])
|
||||
async def update_item(
|
||||
item_id: int, # Item ID from path
|
||||
item_id: int,
|
||||
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),
|
||||
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).
|
||||
@ -117,11 +112,7 @@ async def update_item(
|
||||
Sets/unsets `completed_by_id` based on `is_complete` flag.
|
||||
"""
|
||||
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}.")
|
||||
return updated_item
|
||||
|
||||
|
@ -11,8 +11,10 @@ from app.models import User as UserModel
|
||||
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
|
||||
from app.schemas.message import Message # For simple responses
|
||||
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.schemas.list import ListStatus
|
||||
from app.schemas.expense import ExpenseRecordPublic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -208,4 +210,21 @@ async def read_list_status(
|
||||
logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.")
|
||||
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
|
||||
)
|
108
be/app/api/v1/endpoints/ocr.py
Normal file
108
be/app/api/v1/endpoints/ocr.py
Normal file
@ -0,0 +1,108 @@
|
||||
# app/api/v1/endpoints/ocr.py
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from google.api_core import exceptions as google_exceptions # Import Google API exceptions
|
||||
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.models import User as UserModel
|
||||
from app.schemas.ocr import OcrExtractResponse
|
||||
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error # Import helper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Allowed image MIME types
|
||||
ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
MAX_FILE_SIZE_MB = 10 # Set a reasonable max file size
|
||||
|
||||
@router.post(
|
||||
"/extract-items",
|
||||
response_model=OcrExtractResponse,
|
||||
summary="Extract List Items via OCR (Gemini)",
|
||||
tags=["OCR"]
|
||||
)
|
||||
async def ocr_extract_items(
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
# Use File(...) for better metadata handling than UploadFile directly as type hint
|
||||
image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."),
|
||||
):
|
||||
"""
|
||||
Accepts an image upload, sends it to Gemini Flash with a prompt
|
||||
to extract shopping list items, and returns the parsed items.
|
||||
"""
|
||||
# Check if Gemini client initialized correctly
|
||||
if gemini_initialization_error:
|
||||
logger.error("OCR endpoint called but Gemini client failed to initialize.")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"OCR service unavailable: {gemini_initialization_error}"
|
||||
)
|
||||
|
||||
logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.")
|
||||
|
||||
# --- File Validation ---
|
||||
if image_file.content_type not in ALLOWED_IMAGE_TYPES:
|
||||
logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}",
|
||||
)
|
||||
|
||||
# Simple size check (FastAPI/Starlette might handle larger limits via config)
|
||||
# Read content first to get size accurately
|
||||
contents = await image_file.read()
|
||||
if len(contents) > MAX_FILE_SIZE_MB * 1024 * 1024:
|
||||
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB} MB.",
|
||||
)
|
||||
# --- End File Validation ---
|
||||
|
||||
try:
|
||||
# Call the Gemini helper function
|
||||
extracted_items = await extract_items_from_image_gemini(image_bytes=contents)
|
||||
|
||||
logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.")
|
||||
return OcrExtractResponse(extracted_items=extracted_items)
|
||||
|
||||
except ValueError as e:
|
||||
# Handle errors from Gemini processing (blocked, empty response, etc.)
|
||||
logger.warning(f"Gemini processing error for user {current_user.email}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, # Or 400 Bad Request?
|
||||
detail=f"Could not extract items from image: {e}",
|
||||
)
|
||||
except google_exceptions.ResourceExhausted as e:
|
||||
# Specific handling for quota errors
|
||||
logger.error(f"Gemini Quota Exceeded for user {current_user.email}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="OCR service quota exceeded. Please try again later.",
|
||||
)
|
||||
except google_exceptions.GoogleAPIError as e:
|
||||
# Handle other Google API errors (e.g., invalid key, permissions)
|
||||
logger.error(f"Gemini API Error for user {current_user.email}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"OCR service error: {e}",
|
||||
)
|
||||
except RuntimeError as e:
|
||||
# Catch initialization errors from get_gemini_client()
|
||||
logger.error(f"Gemini client runtime error during OCR request: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"OCR service configuration error: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
# Catch any other unexpected errors
|
||||
logger.exception(f"Unexpected error during OCR extraction for user {current_user.email}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="An unexpected error occurred during item extraction.",
|
||||
)
|
||||
finally:
|
||||
# Ensure file handle is closed (UploadFile uses SpooledTemporaryFile)
|
||||
await image_file.close()
|
@ -2,11 +2,14 @@
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str | None = None
|
||||
GEMINI_API_KEY: str | None = None
|
||||
|
||||
# --- JWT Settings ---
|
||||
# Generate a strong secret key using: openssl rand -hex 32
|
||||
@ -34,4 +37,12 @@ if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me":
|
||||
print("*" * 80)
|
||||
# Consider raising an error in a production environment check
|
||||
# if os.getenv("ENVIRONMENT") == "production":
|
||||
# raise ValueError("Default SECRET_KEY is not allowed in production!")
|
||||
# raise ValueError("Default SECRET_KEY is not allowed in production!")
|
||||
|
||||
if settings.GEMINI_API_KEY is None:
|
||||
print.error("CRITICAL: GEMINI_API_KEY environment variable not set. Gemini features will be unavailable.")
|
||||
# You might raise an error here if Gemini is essential for startup
|
||||
# raise ValueError("GEMINI_API_KEY must be set.")
|
||||
else:
|
||||
# Optional: Log partial key for confirmation (avoid logging full key)
|
||||
logger.info(f"GEMINI_API_KEY loaded (starts with: {settings.GEMINI_API_KEY[:4]}...).")
|
154
be/app/core/gemini.py
Normal file
154
be/app/core/gemini.py
Normal file
@ -0,0 +1,154 @@
|
||||
# app/core/gemini.py
|
||||
import logging
|
||||
from typing import List
|
||||
import google.generativeai as genai
|
||||
from google.generativeai.types import HarmCategory, HarmBlockThreshold # For safety settings
|
||||
from google.api_core import exceptions as google_exceptions
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Global variable to hold the initialized model client ---
|
||||
gemini_flash_client = None
|
||||
gemini_initialization_error = None # Store potential init error
|
||||
|
||||
# --- Configure and Initialize ---
|
||||
try:
|
||||
if settings.GEMINI_API_KEY:
|
||||
genai.configure(api_key=settings.GEMINI_API_KEY)
|
||||
# Initialize the specific model we want to use
|
||||
gemini_flash_client = genai.GenerativeModel(
|
||||
model_name="gemini-2.0-flash",
|
||||
# Optional: Add default safety settings
|
||||
# Adjust these based on your expected content and risk tolerance
|
||||
safety_settings={
|
||||
HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
},
|
||||
# Optional: Add default generation config (can be overridden per request)
|
||||
# generation_config=genai.types.GenerationConfig(
|
||||
# # candidate_count=1, # Usually default is 1
|
||||
# # stop_sequences=["\n"],
|
||||
# # max_output_tokens=2048,
|
||||
# # temperature=0.9, # Controls randomness (0=deterministic, >1=more random)
|
||||
# # top_p=1,
|
||||
# # top_k=1
|
||||
# )
|
||||
)
|
||||
logger.info("Gemini AI client initialized successfully for model 'gemini-1.5-flash-latest'.")
|
||||
else:
|
||||
# Store error if API key is missing
|
||||
gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized."
|
||||
logger.error(gemini_initialization_error)
|
||||
|
||||
except Exception as e:
|
||||
# Catch any other unexpected errors during initialization
|
||||
gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}"
|
||||
logger.exception(gemini_initialization_error) # Log full traceback
|
||||
gemini_flash_client = None # Ensure client is None on error
|
||||
|
||||
|
||||
# --- Function to get the client (optional, allows checking error) ---
|
||||
def get_gemini_client():
|
||||
"""
|
||||
Returns the initialized Gemini client instance.
|
||||
Raises an exception if initialization failed.
|
||||
"""
|
||||
if gemini_initialization_error:
|
||||
raise RuntimeError(f"Gemini client could not be initialized: {gemini_initialization_error}")
|
||||
if gemini_flash_client is None:
|
||||
# This case should ideally be covered by the check above, but as a safeguard:
|
||||
raise RuntimeError("Gemini client is not available (unknown initialization issue).")
|
||||
return gemini_flash_client
|
||||
|
||||
# Define the prompt as a constant
|
||||
OCR_ITEM_EXTRACTION_PROMPT = """
|
||||
Extract the shopping list items from this image.
|
||||
List each distinct item on a new line.
|
||||
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
|
||||
Focus only on the names of the products or items to be purchased.
|
||||
If the image does not appear to be a shopping list or receipt, state that clearly.
|
||||
Example output for a grocery list:
|
||||
Milk
|
||||
Eggs
|
||||
Bread
|
||||
Apples
|
||||
Organic Bananas
|
||||
"""
|
||||
|
||||
async def extract_items_from_image_gemini(image_bytes: bytes) -> List[str]:
|
||||
"""
|
||||
Uses Gemini Flash to extract shopping list items from image bytes.
|
||||
|
||||
Args:
|
||||
image_bytes: The image content as bytes.
|
||||
|
||||
Returns:
|
||||
A list of extracted item strings.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the Gemini client is not initialized.
|
||||
google_exceptions.GoogleAPIError: For API call errors (quota, invalid key etc.).
|
||||
ValueError: If the response is blocked or contains no usable text.
|
||||
"""
|
||||
client = get_gemini_client() # Raises RuntimeError if not initialized
|
||||
|
||||
# Prepare image part for multimodal input
|
||||
image_part = {
|
||||
"mime_type": "image/jpeg", # Or image/png, image/webp etc. Adjust if needed or detect mime type
|
||||
"data": image_bytes
|
||||
}
|
||||
|
||||
# Prepare the full prompt content
|
||||
prompt_parts = [
|
||||
OCR_ITEM_EXTRACTION_PROMPT, # Text prompt first
|
||||
image_part # Then the image
|
||||
]
|
||||
|
||||
logger.info("Sending image to Gemini for item extraction...")
|
||||
try:
|
||||
# Make the API call
|
||||
# Use generate_content_async for async FastAPI
|
||||
response = await client.generate_content_async(prompt_parts)
|
||||
|
||||
# --- Process the response ---
|
||||
# Check for safety blocks or lack of content
|
||||
if not response.candidates or not response.candidates[0].content.parts:
|
||||
logger.warning("Gemini response blocked or empty.", extra={"response": response})
|
||||
# Check finish_reason if available
|
||||
finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN'
|
||||
safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A'
|
||||
if finish_reason == 'SAFETY':
|
||||
raise ValueError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}")
|
||||
else:
|
||||
raise ValueError(f"Gemini response was empty or incomplete. Finish Reason: {finish_reason}")
|
||||
|
||||
# Extract text - assumes the first part of the first candidate is the text response
|
||||
raw_text = response.text # response.text is a shortcut for response.candidates[0].content.parts[0].text
|
||||
logger.info("Received raw text from Gemini.")
|
||||
# logger.debug(f"Gemini Raw Text:\n{raw_text}") # Optional: Log full response text
|
||||
|
||||
# Parse the text response
|
||||
items = []
|
||||
for line in raw_text.splitlines(): # Split by newline
|
||||
cleaned_line = line.strip() # Remove leading/trailing whitespace
|
||||
# Basic filtering: ignore empty lines and potential non-item lines
|
||||
if cleaned_line and len(cleaned_line) > 1: # Ignore very short lines too?
|
||||
# Add more sophisticated filtering if needed (e.g., regex, keyword check)
|
||||
items.append(cleaned_line)
|
||||
|
||||
logger.info(f"Extracted {len(items)} potential items.")
|
||||
return items
|
||||
|
||||
except google_exceptions.GoogleAPIError as e:
|
||||
logger.error(f"Gemini API Error: {e}", exc_info=True)
|
||||
# Re-raise specific Google API errors for endpoint to handle (e.g., quota)
|
||||
raise e
|
||||
except Exception as e:
|
||||
# Catch other unexpected errors during generation or processing
|
||||
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)
|
||||
# Wrap in a generic ValueError or re-raise
|
||||
raise ValueError(f"Failed to process image with Gemini: {e}") from e
|
83
be/app/core/test_gemini.py
Normal file
83
be/app/core/test_gemini.py
Normal file
@ -0,0 +1,83 @@
|
||||
# be/tests/core/test_gemini.py
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Store original key if exists, then clear it for testing missing key scenario
|
||||
original_api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if "GEMINI_API_KEY" in os.environ:
|
||||
del os.environ["GEMINI_API_KEY"]
|
||||
|
||||
# --- Test Module Import ---
|
||||
# This forces the module-level initialization code in gemini.py to run
|
||||
# We need to reload modules because settings might have been cached
|
||||
from importlib import reload
|
||||
from app.config import settings as app_settings
|
||||
from app.core import gemini as gemini_core
|
||||
|
||||
# Reload settings first to ensure GEMINI_API_KEY is None initially
|
||||
reload(app_settings)
|
||||
# Reload gemini core to trigger initialization logic with potentially missing key
|
||||
reload(gemini_core)
|
||||
|
||||
|
||||
def test_gemini_initialization_without_key():
|
||||
"""Verify behavior when GEMINI_API_KEY is not set."""
|
||||
# Reload modules again to ensure clean state for this specific test
|
||||
if "GEMINI_API_KEY" in os.environ:
|
||||
del os.environ["GEMINI_API_KEY"]
|
||||
reload(app_settings)
|
||||
reload(gemini_core)
|
||||
|
||||
assert gemini_core.gemini_flash_client is None
|
||||
assert gemini_core.gemini_initialization_error is not None
|
||||
assert "GEMINI_API_KEY not configured" in gemini_core.gemini_initialization_error
|
||||
|
||||
with pytest.raises(RuntimeError, match="GEMINI_API_KEY not configured"):
|
||||
gemini_core.get_gemini_client()
|
||||
|
||||
@patch('google.generativeai.configure')
|
||||
@patch('google.generativeai.GenerativeModel')
|
||||
def test_gemini_initialization_with_key(mock_generative_model: MagicMock, mock_configure: MagicMock):
|
||||
"""Verify initialization logic is called when key is present (using mocks)."""
|
||||
# Set a dummy key in the environment for this test
|
||||
test_key = "TEST_API_KEY_123"
|
||||
os.environ["GEMINI_API_KEY"] = test_key
|
||||
|
||||
# Reload settings and gemini module to pick up the new key
|
||||
reload(app_settings)
|
||||
reload(gemini_core)
|
||||
|
||||
# Assertions
|
||||
mock_configure.assert_called_once_with(api_key=test_key)
|
||||
mock_generative_model.assert_called_once_with(
|
||||
model_name="gemini-1.5-flash-latest",
|
||||
safety_settings=pytest.ANY, # Check safety settings were passed (ANY allows flexibility)
|
||||
# generation_config=pytest.ANY # Check if you added default generation config
|
||||
)
|
||||
assert gemini_core.gemini_flash_client is not None
|
||||
assert gemini_core.gemini_initialization_error is None
|
||||
|
||||
# Test get_gemini_client() success path
|
||||
client = gemini_core.get_gemini_client()
|
||||
assert client is not None # Should return the mocked client instance
|
||||
|
||||
# Clean up environment variable after test
|
||||
if original_api_key:
|
||||
os.environ["GEMINI_API_KEY"] = original_api_key
|
||||
else:
|
||||
if "GEMINI_API_KEY" in os.environ:
|
||||
del os.environ["GEMINI_API_KEY"]
|
||||
# Reload modules one last time to restore state for other tests
|
||||
reload(app_settings)
|
||||
reload(gemini_core)
|
||||
|
||||
# Restore original key after all tests in the module run (if needed)
|
||||
def teardown_module(module):
|
||||
if original_api_key:
|
||||
os.environ["GEMINI_API_KEY"] = original_api_key
|
||||
else:
|
||||
if "GEMINI_API_KEY" in os.environ:
|
||||
del os.environ["GEMINI_API_KEY"]
|
||||
reload(app_settings)
|
||||
reload(gemini_core)
|
88
be/app/crud/expense.py
Normal file
88
be/app/crud/expense.py
Normal 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()
|
@ -4,7 +4,6 @@ from sqlalchemy.future import select
|
||||
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases
|
||||
from typing import Optional, List as PyList
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import Item as ItemModel
|
||||
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:
|
||||
"""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 update_data['is_complete'] is True:
|
||||
# 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
|
||||
else:
|
||||
# Mark as incomplete: clear completed_by_id
|
||||
if update_data['is_complete'] is True and item_db.completed_by_id is None:
|
||||
update_data['completed_by_id'] = user_id
|
||||
elif update_data['is_complete'] is False:
|
||||
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():
|
||||
setattr(item_db, key, value)
|
||||
|
||||
db.add(item_db) # Add to session to track changes
|
||||
db.add(item_db)
|
||||
await db.commit()
|
||||
await db.refresh(item_db)
|
||||
return item_db
|
||||
|
@ -19,7 +19,8 @@ from sqlalchemy import (
|
||||
func,
|
||||
text as sa_text,
|
||||
Text, # <-- Add Text for description
|
||||
Numeric # <-- Add Numeric for price
|
||||
Numeric, # <-- Add Numeric for price
|
||||
ARRAY
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
@ -30,6 +31,16 @@ class UserRoleEnum(enum.Enum):
|
||||
owner = "owner"
|
||||
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 ---
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
@ -123,7 +134,6 @@ class List(Base):
|
||||
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
|
||||
|
||||
|
||||
# === NEW: Item Model ===
|
||||
class Item(Base):
|
||||
__tablename__ = "items"
|
||||
@ -134,6 +144,8 @@ class Item(Base):
|
||||
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
|
||||
is_complete = Column(Boolean, default=False, nullable=False)
|
||||
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
|
||||
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)
|
||||
@ -142,4 +154,51 @@ class Item(Base):
|
||||
# --- Relationships ---
|
||||
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
|
||||
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
49
be/app/schemas/expense.py
Normal 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
|
6
be/app/schemas/ocr.py
Normal file
6
be/app/schemas/ocr.py
Normal file
@ -0,0 +1,6 @@
|
||||
# app/schemas/ocr.py
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
class OcrExtractResponse(BaseModel):
|
||||
extracted_items: List[str] # A list of potential item names
|
@ -8,4 +8,5 @@ pydantic-settings>=2.0.0 # For loading settings from .env
|
||||
python-dotenv>=1.0.0 # To load .env file for scripts/alembic
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-jose[cryptography]>=3.3.0
|
||||
pydantic[email]
|
||||
pydantic[email]
|
||||
google-generativeai>=0.5.0
|
@ -21,6 +21,7 @@ if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build
|
||||
export class ApiClientError extends Error {
|
||||
status: number; // HTTP status code
|
||||
errorData: unknown; // Parsed error data from response body (if any)
|
||||
body: any;
|
||||
|
||||
constructor(message: string, status: number, errorData: unknown = null) {
|
||||
super(message); // Pass message to the base Error class
|
||||
@ -39,177 +40,96 @@ export class ApiClientError extends Error {
|
||||
|
||||
// --- Request Options Interface ---
|
||||
// Extends standard RequestInit but omits 'body' as we handle it separately
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
// Can add custom options here later, e.g.:
|
||||
// skipAuth?: boolean; // To bypass adding the Authorization header
|
||||
interface RequestOptions extends Omit<RequestInit, 'body' | 'headers'> {
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
|
||||
|
||||
// --- Core Request Function ---
|
||||
// Uses generics <T> to allow specifying the expected successful response data type
|
||||
async function request<T = unknown>(
|
||||
method: string,
|
||||
path: string, // Relative path to the API endpoint (e.g., /v1/users/me)
|
||||
bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.)
|
||||
options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.)
|
||||
path: string,
|
||||
bodyData?: unknown,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> {
|
||||
|
||||
// Runtime check for BASE_URL, in case it wasn't set or available during initial load
|
||||
if (!BASE_URL) {
|
||||
// Depending on context (load function vs. component event), choose how to handle
|
||||
// error(500, 'API Base URL is not configured.'); // Use in load functions
|
||||
throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); // Throw for component events
|
||||
throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.');
|
||||
}
|
||||
|
||||
// Construct the full URL safely
|
||||
const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base
|
||||
const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path
|
||||
const cleanBase = BASE_URL.replace(/\/$/, '');
|
||||
const cleanPath = path.replace(/^\//, '');
|
||||
const url = `${cleanBase}/${cleanPath}`;
|
||||
|
||||
// Initialize headers, setting Accept to JSON by default
|
||||
const headers = new Headers({
|
||||
Accept: 'application/json',
|
||||
...options.headers // Spread custom headers provided in options early
|
||||
});
|
||||
// --- Refined Header Handling ---
|
||||
const headers = new Headers({ Accept: 'application/json' });
|
||||
|
||||
if (options.headers) {
|
||||
new Headers(options.headers).forEach((value, key) => {
|
||||
headers.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Prepare Request Body and Set Content-Type ---
|
||||
let processedBody: BodyInit | null = null;
|
||||
|
||||
if (bodyData !== undefined && bodyData !== null) {
|
||||
if (bodyData instanceof URLSearchParams) {
|
||||
// Handle URL-encoded form data
|
||||
headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
||||
processedBody = bodyData;
|
||||
} else if (bodyData instanceof FormData) {
|
||||
// Handle FormData (multipart/form-data)
|
||||
// Let the browser set the Content-Type with the correct boundary
|
||||
// Important: DO NOT set 'Content-Type' manually for FormData
|
||||
// headers.delete('Content-Type'); // Ensure no manual Content-Type is set
|
||||
processedBody = bodyData;
|
||||
} else if (typeof bodyData === 'object') {
|
||||
// Handle plain JavaScript objects as JSON
|
||||
headers.set('Content-Type', 'application/json');
|
||||
try {
|
||||
processedBody = JSON.stringify(bodyData);
|
||||
} catch (e) {
|
||||
console.error("Failed to stringify JSON body data:", bodyData, e);
|
||||
throw new Error("Invalid JSON body data provided.");
|
||||
}
|
||||
try { processedBody = JSON.stringify(bodyData); }
|
||||
catch (e) { throw new Error("Invalid JSON body data provided."); }
|
||||
} else {
|
||||
// Handle other primitives (string, number, boolean) - default to sending as JSON stringified
|
||||
// Adjust this logic if you need to send plain text or other formats
|
||||
headers.set('Content-Type', 'application/json');
|
||||
try {
|
||||
processedBody = JSON.stringify(bodyData)
|
||||
} catch (e) {
|
||||
console.error("Failed to stringify primitive body data:", bodyData, e);
|
||||
throw new Error("Invalid body data provided.");
|
||||
}
|
||||
try { processedBody = JSON.stringify(bodyData); }
|
||||
catch (e) { throw new Error("Invalid body data provided."); }
|
||||
}
|
||||
}
|
||||
// --- End Body Preparation ---
|
||||
|
||||
// --- Add Authorization Header ---
|
||||
const currentToken = getCurrentToken(); // Get token synchronously from auth store
|
||||
// Add header if token exists and Authorization wasn't manually set in options.headers
|
||||
const currentToken = getCurrentToken();
|
||||
if (currentToken && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${currentToken}`);
|
||||
}
|
||||
// --- End Authorization Header ---
|
||||
|
||||
// Assemble final fetch options
|
||||
// --- Assemble fetch options carefully ---
|
||||
const fetchOptions: RequestInit = {
|
||||
method: method.toUpperCase(),
|
||||
headers,
|
||||
body: processedBody, // Use the potentially processed body
|
||||
credentials: options.credentials ?? 'same-origin', // Default credentials policy
|
||||
mode: options.mode ?? 'cors', // Default mode
|
||||
cache: options.cache ?? 'default', // Default cache policy
|
||||
...options // Spread remaining options, potentially overriding defaults if needed
|
||||
headers: headers,
|
||||
body: processedBody,
|
||||
};
|
||||
|
||||
const { headers: _, ...restOfOptions } = options;
|
||||
Object.assign(fetchOptions, restOfOptions);
|
||||
|
||||
fetchOptions.credentials = fetchOptions.credentials ?? 'same-origin';
|
||||
fetchOptions.mode = fetchOptions.mode ?? 'cors';
|
||||
fetchOptions.cache = fetchOptions.cache ?? 'default';
|
||||
|
||||
// --- Execute Fetch and Handle Response ---
|
||||
try {
|
||||
// Optional: Log request details for debugging
|
||||
// console.debug(`API Request: ${fetchOptions.method} ${url}`, { headers: Object.fromEntries(headers.entries()), body: bodyData });
|
||||
const response = await fetch(url, fetchOptions);
|
||||
// Optional: Log response status
|
||||
// console.debug(`API Response Status: ${response.status} for ${fetchOptions.method} ${url}`);
|
||||
|
||||
// Check if the response status code indicates failure (not 2xx)
|
||||
if (!response.ok) {
|
||||
let errorJson: unknown = null;
|
||||
// Attempt to parse error details from the response body
|
||||
try {
|
||||
errorJson = await response.json();
|
||||
// console.debug(`API Error Response Body:`, errorJson);
|
||||
} catch (e) {
|
||||
// Ignore if response body isn't valid JSON or empty
|
||||
console.warn(`API Error response for ${response.status} was not valid JSON or empty.`);
|
||||
}
|
||||
|
||||
// Create the custom error object
|
||||
const errorToThrow = new ApiClientError(
|
||||
`API request failed: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
errorJson
|
||||
);
|
||||
|
||||
// --- Global 401 (Unauthorized) Handling ---
|
||||
// If the server returns 401, assume the token is invalid/expired
|
||||
// and automatically log the user out by clearing the auth store.
|
||||
if (response.status === 401) {
|
||||
console.warn(`API Client: Received 401 Unauthorized for ${method} ${path}. Logging out.`);
|
||||
// Calling logout clears the token from store & localStorage
|
||||
logout();
|
||||
// Optional: Trigger a redirect to login page. Often better handled
|
||||
// by calling code or root layout based on application structure.
|
||||
// import { goto } from '$app/navigation';
|
||||
// if (browser) await goto('/login?sessionExpired=true');
|
||||
}
|
||||
// --- End Global 401 Handling ---
|
||||
|
||||
// Throw the error regardless, so the calling code knows the request failed
|
||||
try { errorJson = await response.json(); }
|
||||
catch (e) { /* ignore */ }
|
||||
const errorToThrow = new ApiClientError(`HTTP Error ${response.status}`, response.status, errorJson);
|
||||
if (response.status === 401) { logout(); }
|
||||
throw errorToThrow;
|
||||
}
|
||||
|
||||
// Handle successful responses with no content (e.g., 204 No Content for DELETE)
|
||||
if (response.status === 204) {
|
||||
// Assert type as T, assuming T can accommodate null or void if needed
|
||||
return null as T;
|
||||
}
|
||||
|
||||
// Parse successful JSON response body
|
||||
const responseData = await response.json();
|
||||
// Assert the response data matches the expected generic type T
|
||||
return responseData as T;
|
||||
|
||||
if (response.status === 204) { return null as T; }
|
||||
return (await response.json()) as T;
|
||||
} catch (err) {
|
||||
// Handle network errors (fetch throws TypeError) or errors thrown above
|
||||
console.error(`API Client request error during ${method} ${path}:`, err);
|
||||
|
||||
// Ensure logout is called even if the caught error is a 401 ApiClientError
|
||||
// This handles cases where parsing a non-ok response might fail but status was 401
|
||||
if (err instanceof ApiClientError && err.status === 401) {
|
||||
console.warn(`API Client: Caught ApiClientError 401 for ${method} ${path}. Ensuring logout.`);
|
||||
// Ensure logout state is cleared even if error originated elsewhere
|
||||
logout();
|
||||
}
|
||||
|
||||
// Re-throw the error so the calling code can handle it appropriately
|
||||
// If it's already our custom error, re-throw it directly
|
||||
if (err instanceof ApiClientError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Otherwise, wrap network or other unexpected errors in our custom error type
|
||||
throw new ApiClientError(
|
||||
`Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`,
|
||||
0, // Use 0 or a specific code (e.g., -1) for non-HTTP errors
|
||||
err // Include the original error object as data
|
||||
);
|
||||
if (err instanceof ApiClientError && err.status === 401) { logout(); }
|
||||
if (err instanceof ApiClientError) { throw err; }
|
||||
throw new ApiClientError('Unknown error occurred', 0, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) ---
|
||||
// Provide simple wrappers around the core 'request' function
|
||||
|
||||
|
234
fe/src/lib/components/ImageOcrInput.svelte
Normal file
234
fe/src/lib/components/ImageOcrInput.svelte
Normal file
@ -0,0 +1,234 @@
|
||||
<!-- src/lib/components/ImageOcrInput.svelte -->
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
imageSelected: File; // Dispatch the selected file object
|
||||
cancel: void; // Dispatch when user cancels
|
||||
}>();
|
||||
|
||||
let selectedFile: File | null = null;
|
||||
let previewUrl: string | null = null;
|
||||
let inputKey = Date.now(); // Key to reset file input if needed
|
||||
let error: string | null = null;
|
||||
|
||||
// Refs for the input elements
|
||||
let fileInput: HTMLInputElement;
|
||||
let captureInput: HTMLInputElement;
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10; // Match backend limit
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
error = null; // Clear previous error
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
// Basic Validation
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
error = `Invalid file type. Please select JPEG, PNG, or WEBP. Type found: ${file.type}`;
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
|
||||
error = `File is too large (max ${MAX_FILE_SIZE_MB}MB). Size: ${(file.size / 1024 / 1024).toFixed(2)}MB`;
|
||||
resetInput();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
// Create a preview URL
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl); // Revoke previous URL
|
||||
previewUrl = URL.createObjectURL(file);
|
||||
console.log('Image selected:', file.name, file.type, file.size);
|
||||
} else {
|
||||
// No file selected (e.g., user cancelled file picker)
|
||||
// Optionally clear existing selection if needed
|
||||
// clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedFile = null;
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = null;
|
||||
}
|
||||
error = null;
|
||||
resetInput(); // Reset the input fields
|
||||
}
|
||||
|
||||
function resetInput() {
|
||||
// Changing the key forces Svelte to recreate the input, clearing its value
|
||||
inputKey = Date.now();
|
||||
// Also reset the value manually in case key trick doesn't work everywhere
|
||||
if (fileInput) fileInput.value = '';
|
||||
if (captureInput) captureInput.value = '';
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput?.click(); // Programmatically click the hidden file input
|
||||
}
|
||||
|
||||
function triggerCaptureInput() {
|
||||
captureInput?.click(); // Programmatically click the hidden capture input
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectedFile) {
|
||||
dispatch('imageSelected', selectedFile);
|
||||
} else {
|
||||
error = 'Please select or capture an image first.';
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
clearSelection();
|
||||
dispatch('cancel');
|
||||
}
|
||||
|
||||
// Clean up object URL when component is destroyed
|
||||
onDestroy(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
import { onDestroy } from 'svelte'; // Ensure onDestroy is imported
|
||||
</script>
|
||||
|
||||
<!-- Basic Modal Structure (adapt styling as needed) -->
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="bg-opacity-60 fixed inset-0 z-50 flex items-center justify-center bg-black p-4"
|
||||
on:click|self={handleCancel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ocr-modal-title"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 id="ocr-modal-title" class="mb-4 text-xl font-semibold text-gray-800">
|
||||
Add Items via Photo
|
||||
</h2>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden Inputs -->
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg, image/png, image/webp"
|
||||
on:change={handleFileChange}
|
||||
bind:this={fileInput}
|
||||
key={inputKey}
|
||||
class="hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg, image/png, image/webp"
|
||||
capture="environment"
|
||||
on:change={handleFileChange}
|
||||
bind:this={captureInput}
|
||||
key={inputKey}
|
||||
class="hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{#if previewUrl}
|
||||
<!-- Preview Section -->
|
||||
<div class="mb-4 text-center">
|
||||
<p class="mb-2 text-sm text-gray-600">Image Preview:</p>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Selected list preview"
|
||||
class="mx-auto max-h-60 w-auto rounded border border-gray-300 object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={clearSelection}
|
||||
class="mt-2 text-xs text-red-600 hover:underline"
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={triggerCaptureInput}
|
||||
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
|
||||
>
|
||||
<!-- Basic Camera Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/></svg
|
||||
>
|
||||
Take Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={triggerFileInput}
|
||||
class="flex w-full items-center justify-center rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none"
|
||||
>
|
||||
<!-- Basic Upload Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/></svg
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation/Cancel -->
|
||||
<div class="mt-6 flex justify-end space-x-3 border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleCancel}
|
||||
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleConfirm}
|
||||
disabled={!selectedFile}
|
||||
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Confirm Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,22 +1,20 @@
|
||||
<!-- src/lib/components/ItemDisplay.svelte -->
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||
import type { ItemPublic, ItemUpdate } from '$lib/schemas/item';
|
||||
// --- DB and Sync Imports ---
|
||||
import { putItemToDb, deleteItemFromDb, addSyncAction } from '$lib/db';
|
||||
import { processSyncQueue } from '$lib/syncService';
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/authStore'; // Get current user ID
|
||||
import { get } from 'svelte/store'; // Import get
|
||||
// --- End DB and Sync Imports ---
|
||||
import { authStore } from '$lib/stores/authStore';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export let item: ItemPublic;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
itemUpdated: ItemPublic; // Event when item is successfully updated (toggle/edit)
|
||||
itemDeleted: number; // Event when item is successfully deleted (sends ID)
|
||||
updateError: string; // Event to bubble up errors
|
||||
itemUpdated: ItemPublic;
|
||||
itemDeleted: number;
|
||||
updateError: string;
|
||||
}>();
|
||||
|
||||
// --- Component State ---
|
||||
@ -24,10 +22,21 @@
|
||||
let isToggling = false;
|
||||
let isDeleting = false;
|
||||
let isSavingEdit = false;
|
||||
let isSavingPrice = false;
|
||||
|
||||
// State for edit form
|
||||
let editName = '';
|
||||
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 ---
|
||||
function startEdit() {
|
||||
@ -35,16 +44,16 @@
|
||||
editName = item.name;
|
||||
editQuantity = item.quantity ?? '';
|
||||
isEditing = true;
|
||||
dispatch('updateError', ''); // Clear previous errors when starting edit
|
||||
dispatch('updateError', '');
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
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() {
|
||||
if (isToggling || isEditing) return;
|
||||
isToggling = true;
|
||||
@ -52,31 +61,28 @@
|
||||
|
||||
const newStatus = !item.is_complete;
|
||||
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 = {
|
||||
...item,
|
||||
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,
|
||||
updated_at: new Date().toISOString() // Update timestamp locally
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
await putItemToDb(optimisticItem);
|
||||
dispatch('itemUpdated', optimisticItem); // Dispatch optimistic update immediately
|
||||
dispatch('itemUpdated', optimisticItem);
|
||||
} catch (dbError) {
|
||||
console.error('Optimistic toggle DB update failed:', dbError);
|
||||
dispatch('updateError', 'Failed to save state locally.');
|
||||
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}`);
|
||||
try {
|
||||
if (browser && !navigator.onLine) {
|
||||
// OFFLINE: Queue action
|
||||
console.log(`Offline: Queuing update for item ${item.id}`);
|
||||
await addSyncAction({
|
||||
type: 'update_item',
|
||||
@ -84,66 +90,50 @@
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else {
|
||||
// ONLINE: Send API call directly
|
||||
const updatedItemFromServer = await apiClient.put<ItemPublic>(
|
||||
`/v1/items/${item.id}`,
|
||||
updateData
|
||||
);
|
||||
// Update DB and dispatch again with potentially more accurate server data
|
||||
await putItemToDb(updatedItemFromServer);
|
||||
dispatch('itemUpdated', updatedItemFromServer);
|
||||
}
|
||||
// Trigger sync if online after queuing or direct call
|
||||
if (browser && navigator.onLine) processSyncQueue();
|
||||
} catch (err) {
|
||||
console.error(`Toggle item ${item.id} failed:`, err);
|
||||
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.
|
||||
// Handle error
|
||||
} finally {
|
||||
isToggling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!editName.trim()) {
|
||||
dispatch('updateError', 'Item name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
if (isSavingEdit) return;
|
||||
|
||||
isSavingEdit = true;
|
||||
dispatch('updateError', '');
|
||||
|
||||
const updateData: ItemUpdate = {
|
||||
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 = {
|
||||
...item,
|
||||
name: updateData.name!,
|
||||
quantity: updateData.quantity ?? null,
|
||||
...updateData,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
await putItemToDb(optimisticItem);
|
||||
dispatch('itemUpdated', optimisticItem);
|
||||
await putItemToDb(optimisticItem as any);
|
||||
dispatch('itemUpdated', optimisticItem as any);
|
||||
} catch (dbError) {
|
||||
console.error('Optimistic edit DB update failed:', dbError);
|
||||
dispatch('updateError', 'Failed to save state locally.');
|
||||
isSavingEdit = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Queue or Send API Call
|
||||
// Queue or Send API Call
|
||||
console.log(`Saving edits for item ${item.id}`, updateData);
|
||||
try {
|
||||
if (browser && !navigator.onLine) {
|
||||
console.log(`Offline: Queuing update for item ${item.id}`);
|
||||
await addSyncAction({
|
||||
type: 'update_item',
|
||||
payload: { id: item.id, data: updateData },
|
||||
@ -155,115 +145,114 @@
|
||||
updateData
|
||||
);
|
||||
await putItemToDb(updatedItemFromServer);
|
||||
dispatch('itemUpdated', updatedItemFromServer); // Update with server data
|
||||
dispatch('itemUpdated', updatedItemFromServer);
|
||||
}
|
||||
if (browser && navigator.onLine) processSyncQueue();
|
||||
isEditing = false; // Exit edit mode on success
|
||||
isEditing = false;
|
||||
} catch (err) {
|
||||
console.error(`Save edit for item ${item.id} failed:`, err);
|
||||
const errorMsg =
|
||||
err instanceof ApiClientError ? `Error (${err.status}): ${err.message}` : 'Save failed';
|
||||
dispatch('updateError', errorMsg);
|
||||
// TODO: Revert optimistic update?
|
||||
// Handle error
|
||||
} finally {
|
||||
isSavingEdit = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (isDeleting || isEditing) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete item "${item.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
// --- Save Price Logic ---
|
||||
async function handleSavePrice() {
|
||||
if (isSavingPrice || isEditing || !item.is_complete) return;
|
||||
isSavingPrice = true;
|
||||
dispatch('updateError', '');
|
||||
|
||||
const itemIdToDelete = item.id;
|
||||
|
||||
// 1. Optimistic DB / UI
|
||||
let newPrice: number | null = null;
|
||||
try {
|
||||
await deleteItemFromDb(itemIdToDelete);
|
||||
dispatch('itemDeleted', itemIdToDelete); // Notify parent immediately
|
||||
} catch (dbError) {
|
||||
console.error('Optimistic delete DB update failed:', dbError);
|
||||
dispatch('updateError', 'Failed to delete item locally.');
|
||||
isDeleting = false;
|
||||
const trimmedPrice = editPrice.trim();
|
||||
if (trimmedPrice === '') {
|
||||
newPrice = null;
|
||||
} else {
|
||||
const parsed = parseFloat(trimmedPrice);
|
||||
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;
|
||||
}
|
||||
|
||||
// 2. Queue or Send API Call
|
||||
console.log(`Deleting item ${itemIdToDelete}`);
|
||||
if (newPrice === (item.price ?? null)) {
|
||||
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 {
|
||||
if (browser && !navigator.onLine) {
|
||||
console.log(`Offline: Queuing delete for item ${itemIdToDelete}`);
|
||||
console.log(`Offline: Queuing price update for item ${item.id}`);
|
||||
await addSyncAction({
|
||||
type: 'delete_item',
|
||||
payload: { id: itemIdToDelete },
|
||||
type: 'update_item',
|
||||
payload: { id: item.id, data: updateData },
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} 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();
|
||||
// Component will be destroyed by parent on success
|
||||
} catch (err) {
|
||||
console.error(`Delete item ${itemIdToDelete} failed:`, err);
|
||||
console.error(`Save price for item ${item.id} failed:`, err);
|
||||
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);
|
||||
// If API delete failed, the item was already removed from UI/DB optimistically.
|
||||
// User may need to refresh to see it again if the delete wasn't valid server-side.
|
||||
// For MVP, just show the error.
|
||||
isDeleting = false; // Reset loading state only on error
|
||||
} finally {
|
||||
isSavingPrice = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
// Existing delete logic
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- TEMPLATE -->
|
||||
<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-blue-400={isEditing}
|
||||
class:opacity-60={item.is_complete && !isEditing}
|
||||
>
|
||||
{#if isEditing}
|
||||
<!-- Edit Mode Form -->
|
||||
<form on:submit|preventDefault={handleSaveEdit} class="flex flex-grow items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
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'}
|
||||
</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
|
||||
on:submit|preventDefault={handleSaveEdit}
|
||||
class="flex w-full flex-grow items-center gap-2"
|
||||
>
|
||||
<!-- Name/Qty inputs, Save/Cancel buttons -->
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Display Mode -->
|
||||
@ -272,9 +261,9 @@
|
||||
type="checkbox"
|
||||
checked={item.is_complete}
|
||||
disabled={isToggling || isDeleting}
|
||||
on:change={handleToggleComplete}
|
||||
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"
|
||||
on:change={handleToggleComplete}
|
||||
/>
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<span
|
||||
@ -294,12 +283,43 @@
|
||||
Qty: {item.quantity}
|
||||
</span>
|
||||
{/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>
|
||||
|
||||
<!-- Action Buttons & Price Input Area -->
|
||||
<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
|
||||
on:click={startEdit}
|
||||
class="rounded p-1 text-xs text-gray-400 hover:bg-gray-200 hover:text-gray-700"
|
||||
class="..."
|
||||
title="Edit Item"
|
||||
disabled={isToggling || isDeleting}
|
||||
>
|
||||
@ -307,7 +327,7 @@
|
||||
</button>
|
||||
<button
|
||||
on:click={handleDelete}
|
||||
class="rounded p-1 text-xs text-red-400 hover:bg-red-100 hover:text-red-600"
|
||||
class="..."
|
||||
title="Delete Item"
|
||||
disabled={isToggling || isDeleting}
|
||||
>
|
||||
|
165
fe/src/lib/components/OcrReview.svelte
Normal file
165
fe/src/lib/components/OcrReview.svelte
Normal file
@ -0,0 +1,165 @@
|
||||
<!-- src/lib/components/OcrReview.svelte -->
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount, tick } from 'svelte'; // Added tick
|
||||
import { fade } from 'svelte/transition';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
// Props
|
||||
/** Initial list of item names extracted by OCR */
|
||||
export let initialItems: string[] = [];
|
||||
|
||||
// Events
|
||||
const dispatch = createEventDispatcher<{
|
||||
confirm: string[]; // Final list of item names
|
||||
cancel: void;
|
||||
}>();
|
||||
|
||||
// Internal State
|
||||
interface ReviewItem {
|
||||
id: string; // Unique key for {#each} and focus management
|
||||
name: string;
|
||||
}
|
||||
let reviewedItems: ReviewItem[] = [];
|
||||
export let isLoading: boolean = false; // Add isLoading prop
|
||||
let inputRefs: Record<string, HTMLInputElement> = {}; // To store references for focusing
|
||||
|
||||
// Initialize items with unique IDs when component mounts or prop changes
|
||||
$: if (initialItems) {
|
||||
reviewedItems = initialItems.map((name) => ({
|
||||
id: crypto.randomUUID(), // Generate unique ID for each item
|
||||
name: name
|
||||
}));
|
||||
console.log('OcrReview initialized with items:', reviewedItems);
|
||||
}
|
||||
|
||||
/** Deletes an item from the review list */
|
||||
function deleteItem(idToDelete: string) {
|
||||
reviewedItems = reviewedItems.filter((item) => item.id !== idToDelete);
|
||||
}
|
||||
|
||||
/** Adds a new, empty input field to the list */
|
||||
async function addItemManually() {
|
||||
const newItemId = crypto.randomUUID();
|
||||
// Add a new empty item at the end
|
||||
reviewedItems = [...reviewedItems, { id: newItemId, name: '' }];
|
||||
// Wait for the DOM to update, then focus the new input
|
||||
await tick();
|
||||
if (inputRefs[newItemId]) {
|
||||
inputRefs[newItemId].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatches the confirmed list of non-empty item names */
|
||||
function handleConfirm() {
|
||||
// Filter out empty items and extract just the names
|
||||
const finalItemNames = reviewedItems
|
||||
.map((item) => item.name.trim())
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
console.log('OcrReview confirming items:', finalItemNames);
|
||||
dispatch('confirm', finalItemNames);
|
||||
}
|
||||
|
||||
/** Dispatches the cancel event */
|
||||
function handleCancel() {
|
||||
dispatch('cancel');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Modal Structure -->
|
||||
<div
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4"
|
||||
on:click|self={handleCancel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ocr-review-title"
|
||||
>
|
||||
<!-- Prevent backdrop click from closing when clicking inside modal content -->
|
||||
<div
|
||||
class="flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex-shrink-0 border-b p-4">
|
||||
<h2 id="ocr-review-title" class="text-xl font-semibold text-gray-800">
|
||||
Review Extracted Items
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Edit, delete, or add items below. Confirm to add them to your list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Item List (Scrollable) -->
|
||||
<div class="flex-grow overflow-y-auto p-4">
|
||||
{#if reviewedItems.length > 0}
|
||||
<ul class="space-y-2">
|
||||
{#each reviewedItems as item (item.id)}
|
||||
<li class="flex items-center gap-2" animate:flip={{ duration: 200 }}>
|
||||
<!-- Bind input element reference using item.id as key -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={item.name}
|
||||
bind:this={inputRefs[item.id]}
|
||||
placeholder="Enter item name..."
|
||||
class="flex-grow 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"
|
||||
aria-label="Item name"
|
||||
/>
|
||||
<button
|
||||
on:click={() => deleteItem(item.id)}
|
||||
title="Remove item"
|
||||
class="flex-shrink-0 rounded p-1 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-1 focus:ring-red-400"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<!-- Basic 'X' icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="py-4 text-center text-sm text-gray-500">
|
||||
No items extracted. Add items manually below.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add Manually Button -->
|
||||
<div class="flex-shrink-0 px-4 pb-4">
|
||||
<button
|
||||
on:click={addItemManually}
|
||||
class="w-full rounded border border-dashed border-gray-400 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
+ Add Item Manually
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="flex flex-shrink-0 justify-end space-x-3 border-t bg-gray-50 p-4">
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleCancel}
|
||||
class="rounded border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleConfirm}
|
||||
class="rounded border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Confirm & Add Items'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
59
fe/src/lib/schemas/expense.ts
Normal file
59
fe/src/lib/schemas/expense.ts
Normal 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
|
||||
}
|
8
fe/src/lib/schemas/ocr.ts
Normal file
8
fe/src/lib/schemas/ocr.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface OcrExtractResponse {
|
||||
extracted_items: string[]; // Matches the backend schema
|
||||
}
|
||||
|
||||
export interface OcrReviewItem {
|
||||
id: number; // Temporary unique ID for the {#each} key
|
||||
text: string; // The item name, editable
|
||||
}
|
@ -2,25 +2,30 @@
|
||||
<script lang="ts">
|
||||
// Svelte/SvelteKit Imports
|
||||
import { page } from '$app/stores';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { PageData } from '../$types';
|
||||
import type { PageData } from './$types'; // Correct import for PageData
|
||||
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
|
||||
import ItemDisplay from '$lib/components/ItemDisplay.svelte';
|
||||
import ItemDisplay from '$lib/components/ItemDisplay.svelte'; // Assuming ItemDisplay component path
|
||||
import OcrReview from '$lib/components/OcrReview.svelte';
|
||||
import ImageOcrInput from '$lib/components/ImageOcrInput.svelte'; // Added import based on usage
|
||||
|
||||
// Utility/Store Imports
|
||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||
import { authStore } from '$lib/stores/authStore'; // Get current user ID
|
||||
import { get, writable } from 'svelte/store'; // For local reactive list state
|
||||
|
||||
import { authStore } from 'c:/Users/Vinylnostalgia/Desktop/dev/doe/fe/src/lib/stores/authStore';
|
||||
// Schema Imports
|
||||
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 { Message } from '$lib/schemas/message'; // Corrected import
|
||||
import type { ExpenseRecordPublic, ExpenseSharePublic } from '$lib/schemas/expense';
|
||||
|
||||
// --- DB and Sync Imports ---
|
||||
import {
|
||||
getListFromDb,
|
||||
getItemsByListIdFromDb,
|
||||
putListToDb,
|
||||
putItemToDb,
|
||||
deleteItemFromDb,
|
||||
@ -28,41 +33,58 @@
|
||||
} from '$lib/db';
|
||||
import { syncStatus, syncError, processSyncQueue, triggerSync } from '$lib/syncService';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
// --- End DB and Sync Imports ---
|
||||
|
||||
// --- 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 ---
|
||||
// Use a writable store locally to manage the list and items for easier updates
|
||||
// Initialize with data from SSR/load function as fallback
|
||||
const localListStore = writable<ListDetail | null>(data.list);
|
||||
// --- Local State ---
|
||||
const localListStore = writable<ListDetail | null>(null);
|
||||
const localExpensesStore = writable<ExpenseRecordPublic[]>([]);
|
||||
let initialLoadError: string | null = null;
|
||||
|
||||
// --- Add Item State ---
|
||||
// Add Item State
|
||||
let newItemName = '';
|
||||
let newItemQuantity = '';
|
||||
let isAddingItem = false;
|
||||
let addItemError: string | null = null;
|
||||
|
||||
// --- General Item Error Display ---
|
||||
// General Item Update Error Display
|
||||
let itemUpdateError: string | null = null;
|
||||
let itemErrorTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
// --- Polling State ---
|
||||
let itemErrorTimeout: ReturnType<typeof setTimeout>;
|
||||
// Polling State
|
||||
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let lastKnownStatus: {
|
||||
// Ensure this stores Date objects or null
|
||||
list_updated_at: Date;
|
||||
latest_item_updated_at: Date | null;
|
||||
item_count: number;
|
||||
} | null = null;
|
||||
let lastKnownStatus: any | null = null;
|
||||
let isRefreshing = false;
|
||||
const POLLING_INTERVAL_MS = 15000; // Poll every 15 seconds
|
||||
const POLLING_INTERVAL_MS = 15000;
|
||||
// OCR State
|
||||
let showOcrModal = false;
|
||||
let isProcessingOcr = false;
|
||||
let ocrError: string | null = null;
|
||||
let showOcrReview = false;
|
||||
let ocrResults: string[] = [];
|
||||
let isConfirmingOcrItems = false;
|
||||
let confirmOcrError: string | null = null;
|
||||
// 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 ---
|
||||
onMount(() => {
|
||||
let isMounted = true;
|
||||
|
||||
(async () => {
|
||||
let listId: number | null = null;
|
||||
try {
|
||||
@ -73,64 +95,64 @@
|
||||
|
||||
if (!listId) {
|
||||
console.error('List Detail Mount: Invalid or missing listId in params.');
|
||||
// Optionally redirect or show permanent error
|
||||
initialLoadError = 'Invalid List ID specified in URL.'; // Show error
|
||||
localListStore.set(null); // Ensure list is null
|
||||
localExpensesStore.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Load from IndexedDB first for faster initial display/offline
|
||||
// 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) {
|
||||
console.log('List Detail Mount: Loading from IndexedDB for list', listId);
|
||||
const listFromDb = await getListFromDb(listId);
|
||||
if (listFromDb) {
|
||||
console.log('List Detail Mount: Found list in DB', listFromDb);
|
||||
// Items should be part of ListDetail object store
|
||||
if (isMounted) {
|
||||
localListStore.set(listFromDb);
|
||||
initializePollingStatus(listFromDb);
|
||||
}
|
||||
localListStore.set(listFromDb);
|
||||
initializePollingStatus(listFromDb);
|
||||
// TODO: Load expenses from DB too? Requires adding expenses to DB schema/functions
|
||||
} else {
|
||||
console.log('List Detail Mount: List not found in DB, using SSR/load data.');
|
||||
if (isMounted) {
|
||||
localListStore.set(data.list); // Fallback to initial data
|
||||
initializePollingStatus(data.list);
|
||||
}
|
||||
// Already set above from data prop
|
||||
}
|
||||
|
||||
// 2. If online, trigger an API fetch in background to update DB/UI
|
||||
// If online, trigger API fetches in background
|
||||
if (navigator.onLine) {
|
||||
console.log('List Detail Mount: Online, fetching fresh data...');
|
||||
fetchAndUpdateList(listId); // Don't await, let it run in background
|
||||
// Also trigger sync queue processing
|
||||
fetchAndUpdateList(listId); // Don't await
|
||||
fetchExpenseHistory(listId); // Don't await
|
||||
processSyncQueue(); // Don't await
|
||||
}
|
||||
|
||||
// 3. Start polling
|
||||
// Start polling
|
||||
startPolling();
|
||||
} else {
|
||||
// Server side: Use data from load function directly
|
||||
if (isMounted) {
|
||||
localListStore.set(data.list);
|
||||
initializePollingStatus(data.list);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
stopPolling();
|
||||
clearTimeout(itemErrorTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
// 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) {
|
||||
if (isRefreshing) return; // Prevent concurrent refreshes
|
||||
isRefreshing = true;
|
||||
console.log('List Detail: Fetching fresh list data for', listId);
|
||||
try {
|
||||
const freshList = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
||||
await putListToDb(freshList); // Update IndexedDB
|
||||
localListStore.set(freshList); // Update the UI store
|
||||
// No need to re-initialize polling status here, checkListStatus will update it
|
||||
await putListToDb(freshList);
|
||||
localListStore.set(freshList);
|
||||
// Polling status will be reset by checkListStatus after refresh
|
||||
console.log('List Detail: Fetched and updated list', listId);
|
||||
clearItemError();
|
||||
} catch (err) {
|
||||
console.error('List Detail: Failed to fetch fresh list data', err);
|
||||
handleItemUpdateError(
|
||||
@ -141,7 +163,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to initialize polling status from ListDetail data
|
||||
/** 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) {
|
||||
if (!listData) {
|
||||
lastKnownStatus = null;
|
||||
@ -151,6 +192,7 @@
|
||||
const listUpdatedAt = new Date(listData.updated_at);
|
||||
let latestItemUpdate: Date | null = null;
|
||||
if (listData.items && listData.items.length > 0) {
|
||||
// Find the latest date string first, then convert
|
||||
const latestDateString = listData.items.reduce(
|
||||
(latest, item) => (item.updated_at > latest ? item.updated_at : latest),
|
||||
listData.items[0].updated_at
|
||||
@ -169,27 +211,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Polling Logic ---
|
||||
/** Starts the polling interval */
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
if (!$localListStore) return;
|
||||
const currentList = get(localListStore);
|
||||
if (!currentList || !browser) return; // Only poll in browser
|
||||
console.log(
|
||||
`Polling: Starting polling for list ${$localListStore.id} every ${POLLING_INTERVAL_MS}ms`
|
||||
`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); // Use get for non-reactive access inside async
|
||||
if (!currentList || isRefreshing || !lastKnownStatus || !navigator.onLine) {
|
||||
if (!navigator.onLine) console.log('Polling: Offline, skipping status check.');
|
||||
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}`);
|
||||
@ -200,12 +247,13 @@
|
||||
? 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() ||
|
||||
currentStatus.item_count !== lastKnownStatus.item_count;
|
||||
Number(currentStatus.item_count) !== lastKnownStatus.item_count;
|
||||
|
||||
if (listChanged || itemsChanged) {
|
||||
console.log('Polling: Change detected!', { listChanged, itemsChanged });
|
||||
@ -214,83 +262,146 @@
|
||||
lastKnownStatus = {
|
||||
list_updated_at: currentListUpdatedAt,
|
||||
latest_item_updated_at: currentLatestItemUpdatedAt,
|
||||
item_count: currentStatus.item_count
|
||||
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() {
|
||||
// Refactored to use store value
|
||||
const listId = get(localListStore)?.id;
|
||||
if (listId) {
|
||||
await fetchAndUpdateList(listId);
|
||||
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 ---
|
||||
/** Handles the itemUpdated event from ItemDisplay */
|
||||
async function handleItemUpdated(event: CustomEvent<ItemPublic>) {
|
||||
const updatedItem = event.detail;
|
||||
console.log('Parent received itemUpdated:', updatedItem);
|
||||
// Update DB (already done in ItemDisplay optimistic update)
|
||||
// Update store for UI
|
||||
localListStore.update((currentList) => {
|
||||
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) {
|
||||
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] };
|
||||
});
|
||||
// DB update was handled optimistically in ItemDisplay
|
||||
clearItemError();
|
||||
}
|
||||
|
||||
/** Handles the itemDeleted event from ItemDisplay */
|
||||
async function handleItemDeleted(event: CustomEvent<number>) {
|
||||
const deletedItemId = event.detail;
|
||||
console.log('Parent received itemDeleted:', deletedItemId);
|
||||
// Update DB (already done in ItemDisplay optimistic update)
|
||||
// Update store for UI
|
||||
localListStore.update((currentList) => {
|
||||
if (!currentList) return null;
|
||||
return {
|
||||
...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();
|
||||
}
|
||||
|
||||
/** Handles the updateError event from ItemDisplay */
|
||||
function handleItemUpdateError(event: CustomEvent<string>) {
|
||||
/* ... (keep existing) ... */
|
||||
}
|
||||
function clearItemError() {
|
||||
/* ... (keep existing) ... */
|
||||
const errorMsg = event.detail;
|
||||
console.log('Parent received updateError:', errorMsg);
|
||||
itemUpdateError = errorMsg;
|
||||
clearTimeout(itemErrorTimeout);
|
||||
itemErrorTimeout = setTimeout(() => {
|
||||
itemUpdateError = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// --- Add Item Logic ---
|
||||
/** Clears the general item update error message */
|
||||
function clearItemError() {
|
||||
itemUpdateError = null;
|
||||
clearTimeout(itemErrorTimeout);
|
||||
}
|
||||
|
||||
// --- Add Item Logic (Single Item) ---
|
||||
/** Handles submission of the Add Item form */
|
||||
async function handleAddItem() {
|
||||
const currentList = get(localListStore); // Use get for non-reactive access
|
||||
if (!newItemName.trim() || !currentList) return;
|
||||
const currentList = get(localListStore);
|
||||
if (!newItemName.trim() || !currentList) {
|
||||
addItemError = 'Item name cannot be empty.';
|
||||
return;
|
||||
}
|
||||
if (isAddingItem) return;
|
||||
|
||||
isAddingItem = true;
|
||||
addItemError = null;
|
||||
clearItemError();
|
||||
|
||||
// 1. Optimistic UI Update with Temporary ID (Using negative random number for simplicity)
|
||||
const tempId = Math.floor(Math.random() * -1000000);
|
||||
const currentUserId = get(authStore).user?.id; // Get current user ID synchronously
|
||||
// 1. Optimistic UI Update with Temporary ID
|
||||
const currentUserId = get(authStore).user?.id;
|
||||
if (!currentUserId) {
|
||||
addItemError = 'Cannot add item: User not identified.';
|
||||
isAddingItem = false;
|
||||
return;
|
||||
}
|
||||
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const optimisticItem: ItemPublic = {
|
||||
id: tempId, // Use temporary ID
|
||||
id: tempId as any, // Cast needed as DB expects number, but temp is string/negative
|
||||
list_id: currentList.id,
|
||||
name: newItemName.trim(),
|
||||
quantity: newItemQuantity.trim() || null,
|
||||
@ -305,8 +416,7 @@
|
||||
localListStore.update((list) =>
|
||||
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
||||
);
|
||||
// Note: Cannot add item with temp ID to IndexedDB if keyPath is 'id' and type is number.
|
||||
// For MVP, we skip adding temp items to DB and rely on sync + refresh.
|
||||
// Skip adding temp item to IndexedDB for simplicity in MVP
|
||||
|
||||
// 2. Queue Sync Action
|
||||
const actionPayload: ItemCreate = {
|
||||
@ -317,8 +427,8 @@
|
||||
await addSyncAction({
|
||||
type: 'create_item',
|
||||
payload: { listId: currentList.id, data: actionPayload },
|
||||
timestamp: Date.now()
|
||||
// tempId: tempId // Optional: include tempId for mapping later
|
||||
timestamp: Date.now(),
|
||||
tempId: tempId // Include tempId for potential mapping later
|
||||
});
|
||||
|
||||
// 3. Trigger sync if online
|
||||
@ -330,17 +440,249 @@
|
||||
} catch (dbError) {
|
||||
console.error('Failed to queue add item action:', dbError);
|
||||
addItemError = 'Failed to save item for offline sync.';
|
||||
// Revert optimistic UI update? More complex.
|
||||
// Revert optimistic UI update: convert item id to string for reliable comparison
|
||||
localListStore.update((list) =>
|
||||
list ? { ...list, items: list.items.filter((i) => i.id !== tempId) } : null
|
||||
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
|
||||
);
|
||||
} finally {
|
||||
isAddingItem = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- OCR Handling ---
|
||||
function openOcrModal() {
|
||||
ocrError = null;
|
||||
confirmOcrError = null;
|
||||
showOcrModal = true;
|
||||
}
|
||||
function closeOcrModal() {
|
||||
showOcrModal = false;
|
||||
}
|
||||
function closeOcrReview() {
|
||||
showOcrReview = false;
|
||||
ocrResults = [];
|
||||
}
|
||||
|
||||
/** Handles image selection from the modal, uploads it, and shows review modal */
|
||||
async function handleImageSelected(event: CustomEvent<File>) {
|
||||
const imageFile = event.detail;
|
||||
closeOcrModal();
|
||||
isProcessingOcr = true;
|
||||
ocrError = null;
|
||||
confirmOcrError = null;
|
||||
const formData = new FormData();
|
||||
formData.append('image_file', imageFile);
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<OcrExtractResponse>('/v1/ocr/extract-items', formData);
|
||||
console.log('OCR Extraction successful:', result);
|
||||
if (result.extracted_items && result.extracted_items.length > 0) {
|
||||
ocrResults = result.extracted_items;
|
||||
showOcrReview = true; // Show the review modal
|
||||
} else {
|
||||
ocrError = 'OCR processing finished, but no items were extracted.';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OCR failed:', err);
|
||||
if (err instanceof ApiClientError) {
|
||||
ocrError = `OCR API Error (${err.status}): ${err.body?.message || 'Unknown API error'}`;
|
||||
} else if (err instanceof Error) {
|
||||
ocrError = `OCR Network/Client Error: ${err.message}`;
|
||||
} else {
|
||||
ocrError = 'An unexpected OCR error occurred.';
|
||||
}
|
||||
} finally {
|
||||
isProcessingOcr = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles confirmation from the OCR Review modal */
|
||||
async function handleOcrConfirm(event: CustomEvent<string[]>) {
|
||||
const itemNamesToAdd = event.detail;
|
||||
closeOcrReview();
|
||||
|
||||
if (!itemNamesToAdd || itemNamesToAdd.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConfirmingOcrItems = true;
|
||||
confirmOcrError = null;
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const currentList = get(localListStore);
|
||||
const currentUserId = get(authStore).user?.id;
|
||||
|
||||
if (!currentList || !currentUserId) {
|
||||
confirmOcrError = 'Cannot add items: List or user information missing.';
|
||||
isConfirmingOcrItems = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`OCR Confirm: Attempting to add ${itemNamesToAdd.length} items...`);
|
||||
|
||||
// Process items sequentially
|
||||
for (const name of itemNamesToAdd) {
|
||||
if (!name.trim()) continue;
|
||||
|
||||
const tempId = `temp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
// Optimistic UI update
|
||||
const optimisticItem: ItemPublic = {
|
||||
id: tempId as any,
|
||||
list_id: currentList.id,
|
||||
name: name.trim(),
|
||||
quantity: null, // Default quantity for OCR items
|
||||
is_complete: false,
|
||||
price: null,
|
||||
added_by_id: currentUserId,
|
||||
completed_by_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
localListStore.update((list) =>
|
||||
list ? { ...list, items: [...list.items, optimisticItem] } : null
|
||||
);
|
||||
|
||||
// Queue Sync Action
|
||||
const actionPayload: ItemCreate = { name: name.trim(), quantity: undefined };
|
||||
try {
|
||||
await addSyncAction({
|
||||
type: 'create_item',
|
||||
payload: { listId: currentList.id, data: actionPayload },
|
||||
timestamp: Date.now(),
|
||||
tempId: tempId
|
||||
});
|
||||
successCount++;
|
||||
} catch (dbError) {
|
||||
console.error(`Failed to queue item '${name}':`, dbError);
|
||||
failCount++;
|
||||
// Revert optimistic UI update: convert item id to string for reliable comparison
|
||||
localListStore.update((list) =>
|
||||
list ? { ...list, items: list.items.filter((i) => String(i.id) !== tempId) } : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger sync if online
|
||||
if (browser && navigator.onLine) processSyncQueue();
|
||||
isConfirmingOcrItems = false;
|
||||
|
||||
// Provide feedback
|
||||
if (failCount > 0) {
|
||||
confirmOcrError = `Added ${successCount} items. Failed to queue ${failCount} items for sync.`;
|
||||
} else {
|
||||
console.log(`Successfully queued ${successCount} items from OCR.`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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>
|
||||
|
||||
<!-- Template -->
|
||||
<!-- TEMPLATE -->
|
||||
{#if $localListStore}
|
||||
{@const list = $localListStore}
|
||||
<!-- Create local const for easier access -->
|
||||
@ -348,17 +690,22 @@
|
||||
<!-- Sync Status Indicator -->
|
||||
{#if $syncStatus === 'syncing'}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
|
||||
class="fixed bottom-4 right-4 z-50 animate-pulse rounded bg-blue-100 p-3 text-sm text-blue-700 shadow"
|
||||
role="status"
|
||||
>
|
||||
Syncing changes...
|
||||
</div>
|
||||
{:else if $syncStatus === 'error' && $syncError}
|
||||
<div
|
||||
class="fixed right-4 bottom-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
|
||||
class="fixed bottom-4 right-4 z-50 rounded bg-red-100 p-3 text-sm text-red-700 shadow"
|
||||
role="alert"
|
||||
>
|
||||
Sync Error: {$syncError}
|
||||
<button
|
||||
class="ml-2 font-semibold underline"
|
||||
on:click={triggerSync}
|
||||
title="Retry Synchronization">Retry</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -380,21 +727,49 @@
|
||||
list.updated_at
|
||||
).toLocaleString()}
|
||||
</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 class="flex-shrink-0 space-x-2">
|
||||
<div class="flex flex-shrink-0 items-center space-x-2">
|
||||
<!-- Action Buttons -->
|
||||
{#if isRefreshing}
|
||||
<span class="animate-pulse text-sm text-blue-600">Refreshing...</span>
|
||||
{/if}
|
||||
<!-- OCR Button -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={openOcrModal}
|
||||
disabled={isProcessingOcr || isConfirmingOcrItems}
|
||||
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}
|
||||
<!-- Spinner --> Processing...
|
||||
{:else if isConfirmingOcrItems}
|
||||
<!-- Spinner --> Adding Items...
|
||||
{:else}
|
||||
📷 Add via Photo
|
||||
{/if}
|
||||
</button>
|
||||
<a
|
||||
href="/lists/{list.id}/edit"
|
||||
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2 focus:outline-none"
|
||||
class="rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-yellow-600 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2"
|
||||
>
|
||||
Edit List Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{#if ocrError || confirmOcrError || calculateSplitError}
|
||||
<!-- Display Action errors -->
|
||||
<div class="rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700" role="alert">
|
||||
{ocrError || confirmOcrError || calculateSplitError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add New Item Form -->
|
||||
<!-- Add New Item Form Section -->
|
||||
<div class="rounded bg-white p-4 shadow">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-700">Add New Item</h2>
|
||||
<form
|
||||
@ -409,7 +784,7 @@
|
||||
placeholder="Item name (required)"
|
||||
required
|
||||
bind:value={newItemName}
|
||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
|
||||
disabled={isAddingItem}
|
||||
/>
|
||||
</div>
|
||||
@ -420,13 +795,13 @@
|
||||
id="new-item-qty"
|
||||
placeholder="Quantity (opt.)"
|
||||
bind:value={newItemQuantity}
|
||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
|
||||
class="w-full rounded border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-70"
|
||||
disabled={isAddingItem}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white shadow-sm transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="whitespace-nowrap rounded bg-blue-600 px-4 py-2 font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isAddingItem}
|
||||
>
|
||||
{isAddingItem ? 'Adding...' : 'Add Item'}
|
||||
@ -441,6 +816,7 @@
|
||||
<div class="rounded bg-white p-6 shadow">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-700">Items ({list.items?.length ?? 0})</h2>
|
||||
{#if itemUpdateError}
|
||||
<!-- Display errors bubbled up from items -->
|
||||
<div
|
||||
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-sm text-red-700"
|
||||
role="alert"
|
||||
@ -450,6 +826,7 @@
|
||||
{/if}
|
||||
{#if list.items && list.items.length > 0}
|
||||
<ul class="space-y-2">
|
||||
<!-- Use {#key} block to help Svelte efficiently update the list -->
|
||||
{#each list.items as item (item.id)}
|
||||
<ItemDisplay
|
||||
{item}
|
||||
@ -464,6 +841,111 @@
|
||||
{/if}
|
||||
</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 -->
|
||||
<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>
|
||||
@ -472,4 +954,26 @@
|
||||
{:else}
|
||||
<!-- Fallback if list data is somehow null/undefined after load function -->
|
||||
<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}
|
||||
|
||||
<!-- Modals -->
|
||||
{#if showOcrModal}
|
||||
<ImageOcrInput on:imageSelected={handleImageSelected} on:cancel={closeOcrModal} />
|
||||
{/if}
|
||||
{#if showOcrReview}
|
||||
<OcrReview
|
||||
initialItems={ocrResults}
|
||||
on:confirm={handleOcrConfirm}
|
||||
on:cancel={closeOcrReview}
|
||||
bind:isLoading={isConfirmingOcrItems}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -2,52 +2,99 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { apiClient, ApiClientError } from '$lib/apiClient';
|
||||
import type { ListDetail } from '$lib/schemas/list';
|
||||
// --- Use the correct generated type ---
|
||||
import type { PageLoad } from './$types'; // This type includes correctly typed 'params'
|
||||
import type { ExpenseRecordPublic } from '$lib/schemas/expense'; // Import expense type
|
||||
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 {
|
||||
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 }) => {
|
||||
// Get listId from the URL parameter provided by SvelteKit routing
|
||||
const listId = params.listId;
|
||||
console.log(`List Detail page load: Fetching data for list ID: ${listId}`);
|
||||
|
||||
// Validate the listId parameter
|
||||
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 {
|
||||
// Fetch the specific list details (expecting items to be included)
|
||||
// The backend GET /api/v1/lists/{list_id} should return ListDetail schema
|
||||
const listData = await apiClient.get<ListDetail>(`/v1/lists/${listId}`);
|
||||
// Fetch list details and expense history concurrently for efficiency
|
||||
const [listResult, expensesResult] = await Promise.allSettled([
|
||||
apiClient.get<ListDetail>(`/v1/lists/${numericListId}`), // Fetch specific list details
|
||||
apiClient.get<ExpenseRecordPublic[]>(`/v1/lists/${numericListId}/expenses`) // Fetch expense history
|
||||
]);
|
||||
|
||||
if (!listData) {
|
||||
// Should not happen if API call was successful, but check defensively
|
||||
throw error(404, 'List not found (API returned no data)');
|
||||
}
|
||||
let listData: ListDetail;
|
||||
let expensesData: ExpenseRecordPublic[] = [];
|
||||
let expensesLoadError: string | null = null;
|
||||
|
||||
console.log('List Detail page load: Data fetched successfully', listData);
|
||||
return {
|
||||
list: listData
|
||||
};
|
||||
} 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}`);
|
||||
// --- Process List Result (Critical) ---
|
||||
if (listResult.status === 'fulfilled' && listResult.value) {
|
||||
listData = listResult.value;
|
||||
console.log(`List Detail load: Successfully fetched list ${numericListId}`);
|
||||
} else {
|
||||
// Unknown error
|
||||
throw error(500, 'An unexpected error occurred while loading list data.');
|
||||
// Handle list fetch failure - this is critical, so throw SvelteKit error
|
||||
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'}`);
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user