feat: Implement comprehensive roadmap for feature updates and enhancements
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m30s

This commit introduces a detailed roadmap for implementing various features, focusing on backend and frontend improvements. Key additions include:

- New database schema designs for financial audit logging, archiving lists, and categorizing items.
- Backend logic for financial audit logging, archiving functionality, and chore subtasks.
- Frontend UI updates for archiving lists, managing categories, and enhancing the chore interface.
- Introduction of a guest user flow and integration of Redis for caching to improve performance.

These changes aim to enhance the application's functionality, user experience, and maintainability.
This commit is contained in:
mohamad 2025-06-10 08:16:55 +02:00
parent 7ffeae1476
commit 448a0705d2
18 changed files with 883 additions and 366 deletions

267
.cursor/rules/roadmap.mdc Normal file
View File

@ -0,0 +1,267 @@
---
description:
globs:
alwaysApply: false
---
Of course. Based on a thorough review of your project's structure and code, here is a detailed, LLM-friendly task list to implement the requested features.
This plan is designed to be sequential and modular, focusing on backend database changes first, then backend logic and APIs, and finally the corresponding frontend implementation for each feature.
---
### **High-Level Strategy & Recommendations**
1. **Iterative Implementation:** Tackle one major feature at a time (e.g., complete Audit Logging, then Archiving, etc.). This keeps pull requests manageable and easier to review.
2. **Traceability:** The request for traceability is key. We will use timestamp-based flags (`archived_at`, `deleted_at`) instead of booleans and create dedicated history/log tables for critical actions.
---
### **Phase 1: Database Schema Redesign**
This is the most critical first step. All subsequent tasks depend on these changes. You will need to create a new Alembic migration to apply these.
**File to Modify:** `be/app/models.py`
**Action:** Create a new Alembic migration file (`alembic revision -m "feature_updates_phase1"`) and implement the following changes in `upgrade()`.
**1. Financial Audit Logging**
* Create a new table to log every financial transaction and change. This ensures complete traceability.
```python
# In be/app/models.py
class FinancialAuditLog(Base):
__tablename__ = 'financial_audit_log'
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # User who performed the action. Nullable for system actions.
action_type = Column(String, nullable=False, index=True) # e.g., 'EXPENSE_CREATED', 'SPLIT_PAID', 'SETTLEMENT_DELETED'
entity_type = Column(String, nullable=False) # e.g., 'Expense', 'ExpenseSplit', 'Settlement'
entity_id = Column(Integer, nullable=False)
details = Column(JSONB, nullable=True) # To store 'before' and 'after' states or other relevant data.
user = relationship("User")
```
**2. Archiving Lists and History**
* Modify the `lists` table to support soft deletion/archiving.
```python
# In be/app/models.py, class List(Base):
# REMOVE: is_deleted = Column(Boolean, default=False, nullable=False) # If it exists
archived_at = Column(DateTime(timezone=True), nullable=True, index=True)
```
**3. Chore Subtasks**
* Add a self-referencing foreign key to the `chores` table.
```python
# In be/app/models.py, class Chore(Base):
parent_chore_id = Column(Integer, ForeignKey('chores.id'), nullable=True, index=True)
# Add relationships
parent_chore = relationship("Chore", remote_side=[id], back_populates="child_chores")
child_chores = relationship("Chore", back_populates="parent_chore", cascade="all, delete-orphan")
```
**4. List Categories**
* Create a new `categories` table and link it to the `items` table. This allows items to be categorized.
```python
# In be/app/models.py
class Category(Base):
__tablename__ = 'categories'
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Nullable for global categories
group_id = Column(Integer, ForeignKey('groups.id'), nullable=True) # Nullable for user-specific or global
# Add constraints to ensure either user_id or group_id is set, or both are null for global categories
__table_args__ = (UniqueConstraint('name', 'user_id', 'group_id', name='uq_category_scope'),)
# In be/app/models.py, class Item(Base):
category_id = Column(Integer, ForeignKey('categories.id'), nullable=True)
category = relationship("Category")
```
**5. Time Tracking for Chores**
* Create a new `time_entries` table to log time spent on chore assignments.
```python
# In be/app/models.py
class TimeEntry(Base):
__tablename__ = 'time_entries'
id = Column(Integer, primary_key=True, index=True)
chore_assignment_id = Column(Integer, ForeignKey('chore_assignments.id', ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, nullable=True) # Calculated on end_time set
assignment = relationship("ChoreAssignment")
user = relationship("User")
```
---
### **Phase 2: Backend Implementation**
For each feature, implement the necessary backend logic.
#### **Task 2.1: Implement Financial Audit Logging**
* **Goal:** Automatically log all changes to expenses, splits, and settlements.
* **Tasks:**
1. **CRUD (`be/app/crud/audit.py`):**
* Create a new file `audit.py`.
* Implement `create_financial_audit_log(db: AsyncSession, user_id: int, action_type: str, entity: Base, details: dict)`. This function will create a new log entry.
2. **Integrate Logging:**
* Modify `be/app/crud/expense.py`: In `create_expense`, `update_expense`, `delete_expense`, call `create_financial_audit_log`. For updates, the `details` JSONB should contain `{"before": {...}, "after": {...}}`.
* Modify `be/app/crud/settlement.py`: Do the same for `create_settlement`, `update_settlement`, `delete_settlement`.
* Modify `be/app/crud/settlement_activity.py`: Do the same for `create_settlement_activity`.
3. **API (`be/app/api/v1/endpoints/history.py` - new file):**
* Create a new endpoint `GET /history/financial/group/{group_id}` to view the audit log for a group.
* Create a new endpoint `GET /history/financial/user/me` for a user's personal financial history.
#### **Task 2.2: Implement Archiving**
* **Goal:** Allow users to archive lists instead of permanently deleting them.
* **Tasks:**
1. **CRUD (`be/app/crud/list.py`):**
* Rename `delete_list` to `archive_list`. Instead of `db.delete(list_db)`, it should set `list_db.archived_at = datetime.now(timezone.utc)`.
* Modify `get_lists_for_user` to filter out archived lists by default: `.where(ListModel.archived_at.is_(None))`.
2. **API (`be/app/api/v1/endpoints/lists.py`):**
* Update the `DELETE /{list_id}` endpoint to call `archive_list`.
* Create a new endpoint `GET /archived` to fetch archived lists for the user.
* Create a new endpoint `POST /{list_id}/unarchive` to set `archived_at` back to `NULL`.
#### **Task 2.3: Implement Chore Subtasks & Unmarking Completion**
* **Goal:** Allow chores to have a hierarchy and for completion to be reversible.
* **Tasks:**
1. **Schemas (`be/app/schemas/chore.py`):**
* Update `ChorePublic` and `ChoreCreate` schemas to include `parent_chore_id: Optional[int]` and `child_chores: List[ChorePublic] = []`.
2. **CRUD (`be/app/crud/chore.py`):**
* Modify `create_chore` and `update_chore` to handle the `parent_chore_id`.
* In `update_chore_assignment`, enhance the `is_complete=False` logic. When a chore is re-opened, log it to the history. Decide on the policy for the parent chore's `next_due_date` (recommendation: do not automatically roll it back; let the user adjust it manually if needed).
3. **API (`be/app/api/v1/endpoints/chores.py`):**
* Update the `POST` and `PUT` endpoints for chores to accept `parent_chore_id`.
* The `PUT /assignments/{assignment_id}` endpoint already supports setting `is_complete`. Ensure it correctly calls the updated CRUD logic.
#### **Task 2.4: Implement List Categories**
* **Goal:** Allow items to be categorized for better organization.
* **Tasks:**
1. **Schemas (`be/app/schemas/category.py` - new file):**
* Create `CategoryCreate`, `CategoryUpdate`, `CategoryPublic`.
2. **CRUD (`be/app/crud/category.py` - new file):**
* Implement full CRUD functions for categories (`create_category`, `get_user_categories`, `update_category`, `delete_category`).
3. **API (`be/app/api/v1/endpoints/categories.py` - new file):**
* Create endpoints for `GET /`, `POST /`, `PUT /{id}`, `DELETE /{id}` for categories.
4. **Item Integration:**
* Update `ItemCreate` and `ItemUpdate` schemas in `be/app/schemas/item.py` to include `category_id: Optional[int]`.
* Update `crud_item.create_item` and `crud_item.update_item` to handle setting the `category_id`.
#### **Task 2.5: Enhance OCR for Receipts**
* **skipped**
#### **Task 2.6: Implement "Continue as Guest"**
* **Goal:** Allow users to use the app without creating a full account.
* **Tasks:**
1. **DB Model (`be/app/models.py`):**
* Add `is_guest = Column(Boolean, default=False, nullable=False)` to the `User` model.
2. **Auth (`be/app/api/auth/guest.py` - new file):**
* Create a new router for guest functionality.
* Implement a `POST /auth/guest` endpoint. This endpoint will:
* Create a new user with a unique but temporary-looking email (e.g., `guest_{uuid}@guest.mitlist.app`).
* Set `is_guest=True`.
* Generate and return JWT tokens for this guest user, just like a normal login.
3. **Claim Account (`be/app/api/auth/guest.py`):**
* Implement a `POST /auth/guest/claim` endpoint (requires auth). This endpoint will take a new email and password, update the `is_guest=False`, set the new credentials, and mark the email for verification.
#### **Task 2.7: Implement Redis**
* **Goal:** Integrate Redis for caching to improve performance.
* **Tasks:**
1. **Dependencies (`be/requirements.txt`):** Add `redis`.
2. **Configuration (`be/app/config.py`):** Add `REDIS_URL` to settings.
3. **Connection (`be/app/core/redis.py` - new file):** Create a Redis connection pool.
4. **Caching (`be/app/core/cache.py` - new file):** Implement a simple caching decorator.
```python
# Example decorator
def cache(expire_time: int = 3600):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# ... logic to check cache, return if hit ...
# ... if miss, call func, store result in cache ...
return result
return wrapper
return decorator
```
5. **Apply Caching:** Apply the `@cache` decorator to read-heavy, non-volatile CRUD functions like `crud_group.get_group_by_id`.
---
### **Phase 3: Frontend Implementation**
Implement the UI for the new features, using your Valerie UI components.
#### **Task 3.1: Implement Archiving UI**
* **Goal:** Allow users to archive and view archived lists.
* **Files to Modify:** `fe/src/pages/ListsPage.vue`, `fe/src/stores/listStore.ts` (if you create one).
* **Tasks:**
1. Change the "Delete" action on lists to "Archive".
2. Add a toggle/filter to show archived lists.
3. When viewing archived lists, show an "Unarchive" button.
#### **Task 3.2: Implement Subtasks and Unmarking UI**
* **Goal:** Update the chore interface for subtasks and undoing completion.
* **Files to Modify:** `fe/src/pages/ChoresPage.vue`, `fe/src/components/ChoreItem.vue` (if it exists).
* **Tasks:**
1. Modify the chore list to be a nested/tree view to display parent-child relationships.
2. Update the chore creation/edit modal to include a "Parent Chore" dropdown.
3. On completed chores, change the "Completed" checkmark to an "Undo" button. Clicking it should call the API to set `is_complete` to `false`.
#### **Task 3.3: Implement Category Management and Supermarkt Mode**
* **Goal:** Add category features and the special "Supermarkt Mode".
* **Files to Modify:** `fe/src/pages/ListDetailPage.vue`, `fe/src/components/Item.vue`.
* **Tasks:**
1. Create a new page/modal for managing categories (CRUD).
2. In the `ListDetailPage`, add a "Category" dropdown when adding/editing an item.
3. Display items grouped by category.
4. **Supermarkt Mode:**
* Add a toggle button on the `ListDetailPage` to enter "Supermarkt Mode".
* When an item is checked, apply a temporary CSS class to other items in the same category.
* Ensure the price input field appears next to checked items.
* Add a `VProgressBar` at the top, with `value` bound to `completedItems.length` and `max` bound to `totalItems.length`.
#### **Task 3.4: Implement Time Tracking UI**
* **Goal:** Allow users to track time on chores.
* **Files to Modify:** `fe/src/pages/ChoresPage.vue`.
* **Tasks:**
1. Add a "Start/Stop" timer button on each chore assignment.
2. Clicking "Start" sends a `POST /time_entries` request.
3. Clicking "Stop" sends a `PUT /time_entries/{id}` request.
4. Display the total time spent on the chore.
#### **Task 3.5: Implement Guest Flow**
* **Goal:** Provide a seamless entry point for new users.
* **Files to Modify:** `fe/src/pages/LoginPage.vue`, `fe/src/stores/auth.ts`, `fe/src/router/index.ts`.
* **Tasks:**
1. On the `LoginPage`, add a "Continue as Guest" button.
2. This button calls a new `authStore.loginAsGuest()` action.
3. The action hits the `POST /auth/guest` endpoint, receives tokens, and stores them.
4. The router logic needs adjustment to handle guest users. You might want to protect certain pages (like "Account Settings") even from guests.
5. Add a persistent banner in the UI for guest users: "You are using a guest account. **Sign up** to save your data."
</file>
```
**Final Note:** This is a comprehensive roadmap. Each major task can be broken down further into smaller sub-tasks. Good luck with the implementation

View File

@ -4,10 +4,10 @@ import uuid
from app import models
from app.schemas.user import UserCreate, UserClaim, UserPublic
from app.schemas.token import Token
from app.schemas.auth import Token
from app.database import get_session
from app.auth import current_active_user
from app.core.security import create_access_token, get_password_hash
from app.auth import current_active_user, get_jwt_strategy, get_refresh_jwt_strategy
from app.core.security import get_password_hash
from app.crud import user as crud_user
router = APIRouter()
@ -23,8 +23,18 @@ async def create_guest_user(db: AsyncSession = Depends(get_session)):
user_in = UserCreate(email=guest_email, password=guest_password)
user = await crud_user.create_user(db, user_in=user_in, is_guest=True)
access_token = create_access_token(data={"sub": user.email})
return {"access_token": access_token, "token_type": "bearer"}
# Use the same JWT strategy as regular login to generate both access and refresh tokens
access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user)
refresh_token = await refresh_strategy.write_token(user)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer"
}
@router.post("/guest/claim", response_model=UserPublic)
async def claim_guest_account(

View File

@ -3,6 +3,11 @@ from datetime import datetime
from typing import Optional
from decimal import Decimal
class UserReference(BaseModel):
id: int
name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class ItemPublic(BaseModel):
id: int
list_id: int
@ -10,8 +15,11 @@ class ItemPublic(BaseModel):
quantity: Optional[str] = None
is_complete: bool
price: Optional[Decimal] = None
category_id: Optional[int] = None
added_by_id: int
completed_by_id: Optional[int] = None
added_by_user: Optional[UserReference] = None
completed_by_user: Optional[UserReference] = None
created_at: datetime
updated_at: datetime
version: int

View File

@ -113,11 +113,231 @@ export default {
</script>
<style scoped lang="scss">
.child-chore-list {
list-style: none;
padding-left: 2rem;
margin-top: 0.5rem;
border-left: 2px solid #e5e7eb;
/* Neo-style list items */
.neo-list-item {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
position: relative;
transition: background-color 0.2s ease;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.neo-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: auto;
.btn {
margin-left: 0.25rem;
}
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
margin-bottom: 0;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: none;
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
font-weight: 500;
color: var(--dark);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Animated strikethrough line */
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
/* Status-based styling */
.status-completed {
opacity: 0.7;
}
/* Chore-specific styles */
.chore-main-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.chore-badges {
display: flex;
gap: 0.25rem;
}
.badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-group {
background-color: #3b82f6;
color: white;
}
.badge-overdue {
background-color: #ef4444;
color: white;
}
.badge-due-today {
background-color: #f59e0b;
color: white;
}
.chore-description {
font-size: 0.875rem;
color: var(--dark);
opacity: 0.8;
margin-top: 0.25rem;
font-style: italic;
}
.item-time {
font-size: 0.9rem;
color: var(--dark);
opacity: 0.7;
}
.total-time {
@ -125,4 +345,11 @@ export default {
color: #666;
margin-top: 0.25rem;
}
.child-chore-list {
list-style: none;
padding-left: 2rem;
margin-top: 0.5rem;
border-left: 2px solid #e5e7eb;
}
</style>

View File

@ -4,10 +4,11 @@
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
<draggable :list="group.items" item-key="id" handle=".drag-handle" @end="handleDragEnd"
:disabled="!isOnline" class="neo-item-list" ghost-class="sortable-ghost" drag-class="sortable-drag">
:disabled="!isOnline || supermarktMode" class="neo-item-list" ghost-class="sortable-ghost"
drag-class="sortable-drag">
<template #item="{ element: item }">
<ListItem :item="item" :is-online="isOnline" :category-options="categoryOptions"
@delete-item="$emit('delete-item', item)"
:supermarkt-mode="supermarktMode" @delete-item="$emit('delete-item', item)"
@checkbox-change="(item, checked) => $emit('checkbox-change', item, checked)"
@update-price="$emit('update-price', item)" @start-edit="$emit('start-edit', item)"
@save-edit="$emit('save-edit', item)" @cancel-edit="$emit('cancel-edit', item)"
@ -19,7 +20,7 @@
</div>
<!-- New Add Item LI, integrated into the list -->
<li class="neo-list-item new-item-input-container">
<li class="neo-list-item new-item-input-container" v-show="!supermarktMode">
<label class="neo-checkbox-label">
<input type="checkbox" disabled />
<input type="text" class="neo-new-item-input"
@ -55,6 +56,7 @@ interface ItemWithUI extends Item {
editQuantity?: number | string | null;
editCategoryId?: number | null;
showFirework?: boolean;
group_id?: number;
}
const props = defineProps({

View File

@ -3,7 +3,7 @@
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync }">
<div class="neo-item-content">
<!-- Drag Handle -->
<div class="drag-handle" v-if="isOnline">
<div class="drag-handle" v-if="isOnline && !supermarktMode">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"></circle>
@ -20,11 +20,25 @@
<input type="checkbox" :checked="item.is_complete"
@change="$emit('checkbox-change', item, ($event.target as HTMLInputElement).checked)" />
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<div class="item-text-container">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<!-- User Information -->
<div class="item-user-info" v-if="item.added_by_user || item.completed_by_user">
<span v-if="item.added_by_user" class="user-badge added-by"
:title="$t('listDetailPage.items.addedByTooltip', { name: item.added_by_user.name })">
{{ $t('listDetailPage.items.addedBy') }} {{ item.added_by_user.name }}
</span>
<span v-if="item.is_complete && item.completed_by_user" class="user-badge completed-by"
:title="$t('listDetailPage.items.completedByTooltip', { name: item.completed_by_user.name })">
{{ $t('listDetailPage.items.completedBy') }} {{ item.completed_by_user.name }}
</span>
</div>
</div>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="onPriceInput"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24"
@ -33,7 +47,7 @@
</div>
</div>
</label>
<div class="neo-item-actions">
<div class="neo-item-actions" v-if="!supermarktMode">
<button class="neo-icon-button neo-edit-button" @click.stop="$emit('start-edit', item)"
:aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
@ -139,6 +153,10 @@ const props = defineProps({
type: Array as PropType<{ label: string; value: number | null }[]>,
required: true,
},
supermarktMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@ -441,4 +459,39 @@ const onPriceInput = (value: string | number) => {
.drag-handle:active {
cursor: grabbing;
}
/* User Information Styles */
.item-text-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-user-info {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.user-badge {
font-size: 0.75rem;
color: #6b7280;
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid #e5e7eb;
white-space: nowrap;
}
.user-badge.added-by {
color: #059669;
background: #ecfdf5;
border-color: #a7f3d0;
}
.user-badge.completed-by {
color: #7c3aed;
background: #f3e8ff;
border-color: #c4b5fd;
}
</style>

View File

@ -375,7 +375,8 @@
"confirm": "Confirm",
"saveChanges": "Save Changes",
"close": "Close",
"costSummary": "Cost Summary"
"costSummary": "Cost Summary",
"exitSupermarketMode": "Exit Supermarket Mode"
},
"badges": {
"groupList": "Group List",
@ -394,7 +395,12 @@
},
"pricePlaceholder": "Price",
"editItemAriaLabel": "Edit item",
"deleteItemAriaLabel": "Delete item"
"deleteItemAriaLabel": "Delete item",
"addedBy": "Added by",
"completedBy": "Completed by",
"addedByTooltip": "This item was added by {name}",
"completedByTooltip": "This item was completed by {name}",
"noCategory": "No Category"
},
"modals": {
"ocr": {

View File

@ -207,7 +207,10 @@ const navigateToGroups = () => {
onMounted(async () => {
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser();
// Only fetch user if we don't have user data yet
if (!authStore.user) {
await authStore.fetchCurrentUser();
}
await groupStore.fetchGroups();
} catch (error) {
console.error('Failed to initialize app data:', error);

View File

@ -41,7 +41,11 @@ onMounted(async () => {
throw new Error(t('authCallbackPage.errors.noTokenProvided'));
}
await authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });
authStore.setTokens({ access_token: tokenToUse, refresh_token: refreshToken });
// Fetch user data after setting tokens
await authStore.fetchCurrentUser();
notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' });
router.push('/');
} catch (err) {

View File

@ -1,13 +1,13 @@
<template>
<div class="container">
<header class="flex justify-between items-center">
<header v-if="!props.groupId" class="flex justify-between items-center">
<h1 style="margin-block-start: 0;">Expenses</h1>
<button @click="openCreateExpenseModal" class="btn btn-primary">
Add Expense
</button>
</header>
<div v-if="loading" class="flex justify-center mt-4">
<div v-if="loading" class="flex justify-center">
<div class="spinner-dots">
<span></span>
<span></span>
@ -20,7 +20,7 @@
<span class="block sm:inline">{{ error }}</span>
</div>
<div v-else-if="expenses.length === 0" class="empty-state-card">
<div v-else-if="filteredExpenses.length === 0" class="empty-state-card">
<h3>No Expenses Yet</h3>
<p>Get started by adding your first expense!</p>
<button class="btn btn-primary" @click="openCreateExpenseModal">
@ -176,6 +176,10 @@
import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
const props = defineProps<{
groupId?: number | string;
}>();
// Types are kept local to this component
interface UserPublic {
id: number;
@ -247,6 +251,14 @@ const initialFormState: CreateExpenseData = {
const formState = reactive<any>({ ...initialFormState })
const filteredExpenses = computed(() => {
if (props.groupId) {
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
return expenses.value.filter(expense => expense.group_id === groupIdNum);
}
return expenses.value;
});
onMounted(async () => {
try {
loading.value = true
@ -260,8 +272,8 @@ onMounted(async () => {
})
const groupedExpenses = computed(() => {
if (!expenses.value) return [];
const expensesByDate = expenses.value.reduce((acc, expense) => {
if (!filteredExpenses.value) return [];
const expensesByDate = filteredExpenses.value.reduce((acc, expense) => {
const dateKey = expense.expense_date ? new Date(expense.expense_date).toISOString().split('T')[0] : 'nodate';
if (!acc[dateKey]) {
acc[dateKey] = [];
@ -324,6 +336,9 @@ const getStatusClass = (status: string) => {
const openCreateExpenseModal = () => {
editingExpense.value = null
Object.assign(formState, initialFormState)
if (props.groupId) {
formState.group_id = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
}
// TODO: Set formState.paid_by_user_id to current user's ID
// TODO: Fetch users/groups/lists for dropdowns
showModal.value = true
@ -735,4 +750,12 @@ select.form-input {
.empty-state-card .btn {
margin-top: 1.5rem;
}
.neo-section-header {
font-weight: 900;
font-size: 1.25rem;
margin: 0;
margin-bottom: 1rem;
letter-spacing: 0.5px;
}
</style>

View File

@ -0,0 +1,13 @@
<template>
<div>
<ExpensePage :group-id="groupId" />
</div>
</template>
<script setup lang="ts">
import ExpensePage from './ExpensePage.vue';
const props = defineProps<{
groupId?: number | string;
}>();
</script>

View File

@ -78,102 +78,7 @@
<div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
</div>
<div v-if="recentExpenses.length > 0" class="neo-expense-list">
<div v-for="expense in recentExpenses" :key="expense.id" class="neo-expense-item-wrapper">
<div class="neo-expense-item" @click="toggleExpense(expense.id)"
:class="{ 'is-expanded': isExpenseExpanded(expense.id) }">
<div class="expense-main-content">
<div class="expense-icon-container">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" x2="12" y1="2" y2="22"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="expense-text-content">
<div class="neo-expense-header">
{{ expense.description }}
</div>
<div class="neo-expense-details">
{{ formatCurrency(expense.total_amount) }} &mdash;
{{ t('groupDetailPage.expenses.paidBy') }} <strong>{{ expense.paid_by_user?.name ||
expense.paid_by_user?.email }}</strong>
</div>
</div>
</div>
<div class="expense-side-content">
<span class="neo-expense-status" :class="getStatusClass(expense.overall_settlement_status)">
{{ getOverallExpenseStatusText(expense.overall_settlement_status) }}
</span>
<div class="expense-toggle-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="feather feather-chevron-down">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
</div>
</div>
<div v-if="isExpenseExpanded(expense.id)" class="neo-splits-container">
<div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="split-col split-user">
<strong>{{ split.user?.name || split.user?.email ||
t('groupDetailPage.expenses.fallbackUserName',
{
userId: split.user_id
}) }}</strong>
</div>
<div class="split-col split-owes">
{{ t('groupDetailPage.expenses.owes') }} <strong>{{
formatCurrency(split.owed_amount) }}</strong>
</div>
<div class="split-col split-status">
<span class="neo-expense-status" :class="getStatusClass(split.status)">
{{ getSplitStatusText(split.status) }}
</span>
</div>
<div class="split-col split-paid-info">
<div v-if="split.paid_at" class="paid-details">
{{ t('groupDetailPage.expenses.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> {{ t('groupDetailPage.expenses.onDate') }} {{ new
Date(split.paid_at).toLocaleDateString() }}</span>
</div>
</div>
<div class="split-col split-action">
<button
v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
class="btn btn-sm btn-primary" @click="openSettleShareModal(expense, split)"
:disabled="isSettlementLoading">
{{ t('groupDetailPage.expenses.settleShareButton') }}
</button>
</div>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id">
{{ t('groupDetailPage.expenses.activityLabel') }} {{
formatCurrency(activity.amount_paid) }}
{{
t('groupDetailPage.expenses.byUser') }} {{ activity.payer?.name ||
t('groupDetailPage.expenses.activityByUserFallback', { userId: activity.paid_by_user_id }) }}
{{
t('groupDetailPage.expenses.onDate') }} {{ new
Date(activity.paid_at).toLocaleDateString() }}
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<VIcon name="payments" size="lg" class="opacity-50 mb-2" />
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
</div>
<ExpensesPage :group-id="groupId" />
</div>
@ -195,36 +100,7 @@
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
<VModal v-model="showSettleModal" :title="t('groupDetailPage.settleShareModal.title')"
@update:modelValue="!$event && closeSettleShareModal()" size="md">
<template #default>
<div v-if="isSettlementLoading" class="text-center">
<VSpinner :label="t('groupDetailPage.loading.settlement')" />
</div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else>
<p>{{ t('groupDetailPage.settleShareModal.settleAmountFor', {
userName: selectedSplitForSettlement?.user?.name
|| selectedSplitForSettlement?.user?.email || t('groupDetailPage.expenses.fallbackUserName', {
userId:
selectedSplitForSettlement?.user_id
})
}) }}</p>
<VFormField :label="t('groupDetailPage.settleShareModal.amountLabel')"
:error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField>
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
t('groupDetailPage.settleShareModal.cancelButton')
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
t('groupDetailPage.settleShareModal.confirmButton')
}}</VButton>
</template>
</VModal>
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
<template #default>
@ -242,7 +118,7 @@
<div class="meta-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
}}</span>
</div>
<div class="meta-item">
<span class="label">Created:</span>
@ -390,10 +266,8 @@ import { useNotificationStore } from '@/stores/notifications';
import { choreService } from '../services/choreService'
import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore'
import { format, formatDistanceToNow, parseISO, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
import type { Expense, ExpenseSplit, SettlementActivityCreate } from '@/types/expense';
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
import { useAuthStore } from '@/stores/auth';
import { Decimal } from 'decimal.js';
import type { BadgeVariant } from '@/components/valerie/VBadge.vue';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
@ -411,6 +285,7 @@ import VSelect from '@/components/valerie/VSelect.vue';
import { onClickOutside } from '@vueuse/core'
import { groupService } from '../services/groupService';
import ChoresPage from './ChoresPage.vue';
import ExpensesPage from './ExpensesPage.vue';
const { t } = useI18n();
@ -471,15 +346,10 @@ const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
const upcomingChores = ref<Chore[]>([])
const recentExpenses = ref<Expense[]>([])
const expandedExpenses = ref<Set<number>>(new Set());
const authStore = useAuthStore();
const showSettleModal = ref(false);
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
const settleAmount = ref<string>('');
const settleAmountError = ref<string | null>(null);
const isSettlementLoading = ref(false);
const showChoreDetailModal = ref(false);
const selectedChore = ref<Chore | null>(null);
@ -720,172 +590,13 @@ const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
return colorMap[frequency] || 'secondary';
};
const loadRecentExpenses = async () => {
if (!groupId.value) return
try {
const response = await apiClient.get(
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value || ''}&limit=5&detailed=true`
)
recentExpenses.value = response.data
} catch (error) {
console.error(t('groupDetailPage.errors.failedToLoadRecentExpenses'), error)
notificationStore.addNotification({ message: t('groupDetailPage.notifications.loadExpensesFailed'), type: 'error' });
}
}
const formatAmount = (amount: string) => {
return parseFloat(amount).toFixed(2)
}
const formatSplitType = (type: string) => {
const key = `groupDetailPage.expenses.splitTypes.${type.toLowerCase().replace(/_([a-z])/g, g => g[1].toUpperCase())}`;
return t(key);
};
const getSplitTypeBadgeVariant = (type: string): BadgeVariant => {
const colorMap: Record<string, BadgeVariant> = {
equal: 'info',
exact_amounts: 'success',
percentage: 'accent',
shares: 'warning',
item_based: 'secondary',
};
return colorMap[type] || 'neutral';
};
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
if (typeof value === 'string' && !value.trim()) return '$0.00';
const numValue = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
};
const getPaidAmountForSplit = (split: ExpenseSplit): Decimal => {
if (!split.settlement_activities) return new Decimal(0);
return split.settlement_activities.reduce((sum, activity) => {
return sum.plus(new Decimal(activity.amount_paid));
}, new Decimal(0));
}
const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
const amount = getPaidAmountForSplit(split);
return formatCurrency(amount.toString());
};
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
switch (status) {
case ExpenseSplitStatusEnum.PAID: return t('groupDetailPage.status.paid');
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('groupDetailPage.status.partiallyPaid');
case ExpenseSplitStatusEnum.UNPAID: return t('groupDetailPage.status.unpaid');
default: return t('groupDetailPage.status.unknown');
}
};
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
switch (status) {
case ExpenseOverallStatusEnum.PAID: return t('groupDetailPage.status.settled');
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('groupDetailPage.status.partiallySettled');
case ExpenseOverallStatusEnum.UNPAID: return t('groupDetailPage.status.unsettled');
default: return t('groupDetailPage.status.unknown');
}
};
const getStatusClass = (status: ExpenseSplitStatusEnum | ExpenseOverallStatusEnum): string => {
if (status === ExpenseSplitStatusEnum.PAID || status === ExpenseOverallStatusEnum.PAID) return 'status-paid';
if (status === ExpenseSplitStatusEnum.PARTIALLY_PAID || status === ExpenseOverallStatusEnum.PARTIALLY_PAID) return 'status-partially_paid';
if (status === ExpenseSplitStatusEnum.UNPAID || status === ExpenseOverallStatusEnum.UNPAID) return 'status-unpaid';
return '';
};
const toggleExpense = (expenseId: number) => {
const newSet = new Set(expandedExpenses.value);
if (newSet.has(expenseId)) {
newSet.delete(expenseId);
} else {
newSet.add(expenseId);
}
expandedExpenses.value = newSet;
};
const isExpenseExpanded = (expenseId: number) => {
return expandedExpenses.value.has(expenseId);
};
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
if (split.user_id !== authStore.user?.id) {
notificationStore.addNotification({ message: t('groupDetailPage.notifications.cannotSettleOthersShares'), type: 'warning' });
return;
}
selectedSplitForSettlement.value = split;
const alreadyPaid = getPaidAmountForSplit(split);
const owed = new Decimal(split.owed_amount);
const remaining = owed.minus(alreadyPaid);
settleAmount.value = remaining.toFixed(2);
settleAmountError.value = null;
showSettleModal.value = true;
};
const closeSettleShareModal = () => {
showSettleModal.value = false;
selectedSplitForSettlement.value = null;
settleAmount.value = '';
settleAmountError.value = null;
};
const validateSettleAmount = (): boolean => {
settleAmountError.value = null;
if (!settleAmount.value.trim()) {
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.enterAmount');
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.positiveAmount');
return false;
}
if (selectedSplitForSettlement.value) {
const alreadyPaid = getPaidAmountForSplit(selectedSplitForSettlement.value);
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false;
}
} else {
settleAmountError.value = t('groupDetailPage.settleShareModal.errors.noSplitSelected');
return false;
}
return true;
};
const handleConfirmSettle = async () => {
if (!validateSettleAmount()) return;
if (!selectedSplitForSettlement.value || !authStore.user?.id) {
notificationStore.addNotification({ message: t('groupDetailPage.notifications.settlementDataMissing'), type: 'error' });
return;
}
isSettlementLoading.value = true;
try {
const activityData: SettlementActivityCreate = {
expense_split_id: selectedSplitForSettlement.value.id,
paid_by_user_id: Number(authStore.user.id),
amount_paid: new Decimal(settleAmount.value).toString(),
paid_at: new Date().toISOString(),
};
await apiClient.post(API_ENDPOINTS.FINANCIALS.SETTLEMENTS, activityData);
notificationStore.addNotification({ message: t('groupDetailPage.notifications.settleShareSuccess'), type: 'success' });
closeSettleShareModal();
await loadRecentExpenses();
} catch (err) {
const message = getApiErrorMessage(err, 'groupDetailPage.notifications.settleShareFailed');
notificationStore.addNotification({ message, type: 'error' });
} finally {
isSettlementLoading.value = false;
}
};
const toggleMemberMenu = (memberId: number) => {
if (activeMemberMenu.value === memberId) {
@ -1053,7 +764,6 @@ const isAssignmentOverdue = (assignment: ChoreAssignment): boolean => {
onMounted(() => {
fetchGroupDetails();
loadUpcomingChores();
loadRecentExpenses();
loadGroupChoreHistory();
});
</script>

View File

@ -18,9 +18,9 @@
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard"
:empty-title="$t('listDetailPage.items.emptyState.title')"
:empty-message="$t('listDetailPage.items.emptyState.message')" class="mt-4" />
<div v-else class="neo-item-list-container-wrapper">
<div v-else class="neo-item-list-container-wrapper" :class="{ 'supermarket-fullscreen': supermarktMode }">
<!-- Integrated Header -->
<div class="neo-list-card-header">
<div class="neo-list-card-header" v-show="!supermarktMode">
<div class="neo-list-header-main">
<div class="neo-list-title-group">
<VHeading :level="1" :text="list.name" class="neo-title" />
@ -56,6 +56,22 @@
</div>
<VProgressBar v-if="supermarktMode" :value="itemCompletionProgress" class="mt-4" />
</div>
<!-- Supermarket Mode Header -->
<div v-if="supermarktMode" class="supermarket-header">
<div class="supermarket-title-row">
<VHeading :level="1" :text="list.name" class="supermarket-title" />
<button class="supermarket-exit-btn" @click="supermarktMode = false"
:aria-label="$t('listDetailPage.buttons.exitSupermarketMode')">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<VProgressBar :value="itemCompletionProgress" class="supermarket-progress" />
</div>
<!-- End Integrated Header -->
<ItemsList v-if="list" ref="itemsListRef" :items="list.items" :is-online="isOnline"
@ -67,8 +83,9 @@
@update:newItemCategoryId="($event) => newItem.category_id = $event === null ? null : Number($event)" />
<!-- Expenses Section -->
<ExpenseSection v-if="list && !itemsAreLoading" :expenses="expenses" :is-loading="listDetailStore.isLoading"
:error="listDetailStore.error" :current-user-id="authStore.user ? Number(authStore.user.id) : null"
<ExpenseSection v-if="list && !itemsAreLoading && !supermarktMode" :expenses="expenses"
:is-loading="listDetailStore.isLoading" :error="listDetailStore.error"
:current-user-id="authStore.user ? Number(authStore.user.id) : null"
:is-settlement-loading="isSettlementLoading"
@retry-fetch="listDetailStore.fetchListWithExpenses(String(list?.id))" @settle-share="openSettleShareModal" />
</div>
@ -636,12 +653,19 @@ const getGroupName = (groupId: number): string => {
};
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
// Escape key to exit supermarket mode
if (event.key === 'Escape' && supermarktMode.value) {
event.preventDefault();
supermarktMode.value = false;
return;
}
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return;
}
if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value || supermarktMode.value) {
return;
}
event.preventDefault();
@ -916,4 +940,119 @@ const handleExpenseCreated = (expense: any) => {
.supermarkt-mode-toggle {
margin-top: 1rem;
}
/* Supermarket Fullscreen Mode */
.supermarket-fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 1000 !important;
background: white !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
overflow-y: auto !important;
}
.supermarket-header {
position: sticky;
top: 0;
background: white;
border-bottom: 2px solid #111;
padding: 1rem 1.5rem;
z-index: 100;
}
.supermarket-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.supermarket-title {
font-size: 2rem;
font-weight: 800;
margin: 0;
}
.supermarket-exit-btn {
background: #ef4444;
color: white;
border: 2px solid #111;
border-radius: 8px;
padding: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 3px 3px 0 #111;
}
.supermarket-exit-btn:hover {
background: #dc2626;
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 #111;
}
.supermarket-exit-btn:active {
transform: translate(1px, 1px);
box-shadow: 1px 1px 0 #111;
}
.supermarket-progress {
margin: 0;
}
/* Adjust ItemsList for supermarket mode */
.supermarket-fullscreen .neo-item-list-cotainer {
border: none !important;
box-shadow: none !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.supermarket-fullscreen .category-header {
font-size: 1.8rem;
font-weight: 800;
padding: 1rem 1.5rem;
margin: 0;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
position: sticky;
top: 120px;
/* Account for supermarket header */
z-index: 90;
}
.supermarket-fullscreen .neo-list-item {
padding: 1.5rem;
font-size: 1.2rem;
border-bottom: 1px solid #eee;
}
.supermarket-fullscreen .neo-checkbox-label {
gap: 1.5rem;
}
.supermarket-fullscreen .neo-checkbox-label input[type="checkbox"] {
width: 24px;
height: 24px;
}
.supermarket-fullscreen .new-item-input-container {
padding: 1.5rem;
background: #f8f9fa;
border-top: 2px solid #111;
position: sticky;
bottom: 0;
z-index: 95;
}
</style>

View File

@ -1,9 +1,5 @@
<template>
<main class="container page-padding">
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-bold">{{ pageTitle }}</h1>
<VToggleSwitch v-model="showArchived" :label="t('listsPage.showArchived')" />
</div>
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions>

View File

@ -52,6 +52,13 @@ const routes: RouteRecordRaw[] = [
props: (route) => ({ groupId: Number(route.params.groupId) }),
meta: { requiresAuth: true, keepAlive: false },
},
{
path: '/groups/:groupId/expenses',
name: 'GroupExpenses',
component: () => import('@/pages/ExpensesPage.vue'),
props: (route) => ({ groupId: Number(route.params.groupId) }),
meta: { requiresAuth: true, keepAlive: false },
},
{
path: '/chores',
name: 'Chores',

View File

@ -26,6 +26,9 @@ const apiClient = {
delete: (endpoint: string, config = {}) => api.delete(endpoint, config),
}
// Store for tracking refresh promise to prevent concurrent refresh attempts
let refreshPromise: Promise<any> | null = null
// Request interceptor
api.interceptors.request.use(
(config) => {
@ -50,30 +53,53 @@ api.interceptors.response.use(
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshTokenValue = authStore.refreshToken
if (!refreshTokenValue) {
authStore.clearTokens()
await router.push('/auth/login')
// If we're already refreshing, wait for that to complete
if (refreshPromise) {
try {
await refreshPromise
// After refresh completes, retry with new token
const newToken = localStorage.getItem('token')
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
return api(originalRequest)
}
} catch (refreshError) {
// Refresh failed, redirect to login
return Promise.reject(error)
}
}
// Send refresh token in request body as expected by backend
const response = await api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, {
headers: {
'Content-Type': 'application/json',
}
})
const refreshTokenValue = authStore.refreshToken
if (!refreshTokenValue) {
authStore.clearTokens()
await router.push('/auth/login')
return Promise.reject(error)
}
// Set refreshing state and create refresh promise
authStore.isRefreshing = true
refreshPromise = api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, {
headers: {
'Content-Type': 'application/json',
}
})
try {
const response = await refreshPromise
const { access_token: newAccessToken, refresh_token: newRefreshToken } = response.data
authStore.setTokens({ access_token: newAccessToken, refresh_token: newRefreshToken })
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
return api(originalRequest)
} catch (refreshError) {
console.error('Token refresh failed:', refreshError)
authStore.clearTokens()
await router.push('/auth/login')
return Promise.reject(refreshError)
} finally {
authStore.isRefreshing = false
refreshPromise = null
}
}
return Promise.reject(error)

View File

@ -12,6 +12,7 @@ export interface AuthState {
name: string
id?: string | number
is_guest?: boolean
avatarUrl?: string
} | null
}
@ -20,6 +21,8 @@ export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(localStorage.getItem('token'))
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
const user = ref<AuthState['user']>(null)
const isRefreshing = ref(false)
const fetchingUser = ref(false)
// Getters
const isAuthenticated = computed(() => !!accessToken.value)
@ -48,6 +51,8 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
isRefreshing.value = false
fetchingUser.value = false
} catch (error) {
console.error('Error clearing tokens:', error)
}
@ -63,6 +68,13 @@ export const useAuthStore = defineStore('auth', () => {
clearTokens()
return null
}
// Prevent multiple simultaneous user fetch calls
if (fetchingUser.value) {
return user.value
}
fetchingUser.value = true
try {
const response = await api.get(API_ENDPOINTS.USERS.PROFILE)
setUser(response.data)
@ -72,25 +84,25 @@ export const useAuthStore = defineStore('auth', () => {
if (error.isAxiosError && error.response) {
const status = error.response.status
if (status === 401 || status === 403) {
// Authentication error from the server, clear tokens.
console.error('Authentication error fetching user, clearing tokens:', error)
clearTokens()
// Only clear tokens if we're not currently refreshing
// The API interceptor will handle the refresh
if (!isRefreshing.value) {
console.error('Authentication error fetching user, tokens will be handled by interceptor:', error)
// Don't clear tokens here - let the API interceptor handle it
// This prevents race conditions where tokens get cleared while refresh is happening
}
} else {
// Other HTTP error, log it but don't clear tokens.
// The user might be null, but the token remains for other cached calls.
console.error('HTTP error fetching user, token preserved:', error)
}
} else {
// Network error (offline) or other non-HTTP error.
// Log the error but preserve tokens.
// This allows the app to function with cached data if available.
console.error('Network or other error fetching user, token preserved:', error)
}
// In all error cases where tokens are not cleared, return null for the user object.
// The existing user object (if any) will remain until explicitly cleared or overwritten.
// If the intention is to clear the user object on any fetch error, uncomment the next line:
// setUser(null);
return null
} finally {
fetchingUser.value = false
}
}
@ -136,6 +148,8 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated,
getUser,
isGuest,
isRefreshing,
fetchingUser,
setTokens,
clearTokens,
setUser,

View File

@ -1,12 +1,21 @@
export interface Item {
id: number
name: string
quantity?: number | null
is_complete: boolean
price?: string | null // String representation of Decimal
list_id: number
category_id?: number | null
created_at: string
updated_at: string
version: number
export interface UserReference {
id: number;
name: string;
}
export interface Item {
id: number;
name: string;
quantity?: number | null;
is_complete: boolean;
price?: string | null; // String representation of Decimal
list_id: number;
category_id?: number | null;
created_at: string;
updated_at: string;
version: number;
added_by_id?: number;
completed_by_id?: number | null;
added_by_user?: { id: number; name: string };
completed_by_user?: { id: number; name: string } | null;
}