Merge pull request 'ph5' (#68) from ph5 into prod

Reviewed-on: #68
This commit is contained in:
mo 2025-06-10 08:17:31 +02:00
commit 6e812431c8
23 changed files with 2387 additions and 1603 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

@ -0,0 +1,142 @@
<template>
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.costSummary.title')"
@update:modelValue="$emit('update:modelValue', false)" size="lg">
<template #default>
<div v-if="loading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.costSummary')" />
</div>
<VAlert v-else-if="error" type="error" :message="error" />
<div v-else-if="summary">
<div class="mb-3 cost-overview">
<p><strong>{{ $t('listDetailPage.modals.costSummary.totalCostLabel') }}</strong> {{
formatCurrency(summary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.modals.costSummary.equalShareLabel') }}</strong> {{
formatCurrency(summary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.modals.costSummary.participantsLabel') }}</strong> {{
summary.num_participating_users }}</p>
</div>
<h4>{{ $t('listDetailPage.modals.costSummary.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>{{ $t('listDetailPage.modals.costSummary.tableHeaders.user') }}</th>
<th class="text-right">{{
$t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue')
}}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance')
}}</th>
</tr>
</thead>
<tbody>
<tr v-for="userShare in summary.user_balances" :key="userShare.user_id">
<td>{{ userShare.user_identifier }}</td>
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right">
<VBadge :text="formatCurrency(userShare.balance)"
:variant="parseFloat(String(userShare.balance)) >= 0 ? 'settled' : 'pending'" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-else>{{ $t('listDetailPage.modals.costSummary.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="$emit('update:modelValue', false)">{{ $t('listDetailPage.buttons.close')
}}</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import VModal from '@/components/valerie/VModal.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VBadge from '@/components/valerie/VBadge.vue';
import VButton from '@/components/valerie/VButton.vue';
interface UserCostShare {
user_id: number;
user_identifier: string;
items_added_value: string | number;
amount_due: string | number;
balance: string | number;
}
interface ListCostSummaryData {
list_id: number;
list_name: string;
total_list_cost: string | number;
num_participating_users: number;
equal_share_per_user: string | number;
user_balances: UserCostShare[];
}
defineProps({
modelValue: {
type: Boolean,
required: true,
},
summary: {
type: Object as PropType<ListCostSummaryData | null>,
default: null,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String as PropType<string | null>,
default: null,
},
});
defineEmits(['update:modelValue']);
const { t } = useI18n();
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)}`;
};
</script>
<style scoped>
.cost-overview p {
margin-bottom: 0.5rem;
}
.table-container {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.table th {
text-align: left;
font-weight: 600;
background-color: #f8f9fa;
}
.text-right {
text-align: right;
}
</style>

View File

@ -0,0 +1,384 @@
<template>
<section class="neo-expenses-section">
<VCard v-if="isLoading && expenses.length === 0" class="py-10 text-center">
<VSpinner :label="$t('listDetailPage.expensesSection.loading')" size="lg" />
</VCard>
<VAlert v-else-if="error && expenses.length === 0" type="error" class="mt-4">
<p>{{ error }}</p>
<template #actions>
<VButton @click="$emit('retry-fetch')">
{{ $t('listDetailPage.expensesSection.retryButton') }}
</VButton>
</template>
</VAlert>
<VCard v-else-if="(!expenses || expenses.length === 0) && !isLoading" variant="empty-state" empty-icon="receipt"
:empty-title="$t('listDetailPage.expensesSection.emptyStateTitle')"
:empty-message="$t('listDetailPage.expensesSection.emptyStateMessage')" class="mt-4">
</VCard>
<div v-else class="neo-expense-list">
<div v-for="expense in expenses" :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('listDetailPage.expensesSection.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>
<!-- Collapsible content -->
<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 || `User ID: ${split.user_id}`
}}</strong>
</div>
<div class="split-col split-owes">
{{ $t('listDetailPage.expensesSection.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('listDetailPage.expensesSection.paidAmount') }} {{
getPaidAmountForSplitDisplay(split)
}}
<span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new
Date(split.paid_at).toLocaleDateString() }}</span>
</div>
</div>
<div class="split-col split-action">
<button
v-if="split.user_id === currentUserId && split.status !== ExpenseSplitStatusEnum.PAID"
class="btn btn-sm btn-primary" @click="$emit('settle-share', expense, split)"
:disabled="isSettlementLoading">
{{ $t('listDetailPage.expensesSection.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('listDetailPage.expensesSection.activityLabel') }} {{
formatCurrency(activity.amount_paid) }}
{{
$t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{
new
Date(activity.paid_at).toLocaleDateString() }}
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Expense, ExpenseSplit } from '@/types/expense';
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
import { useListDetailStore } from '@/stores/listDetailStore';
import VCard from '@/components/valerie/VCard.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VButton from '@/components/valerie/VButton.vue';
const props = defineProps({
expenses: {
type: Array as PropType<Expense[]>,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
error: {
type: String as PropType<string | null>,
default: null,
},
currentUserId: {
type: Number as PropType<number | null>,
required: true,
},
isSettlementLoading: {
type: Boolean,
default: false,
}
});
defineEmits(['retry-fetch', 'settle-share']);
const { t } = useI18n();
const listDetailStore = useListDetailStore();
const expandedExpenses = ref<Set<number>>(new Set());
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 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 getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
const amount = listDetailStore.getPaidAmountForSplit(split.id);
return formatCurrency(amount);
};
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
switch (status) {
case ExpenseSplitStatusEnum.PAID: return t('listDetailPage.status.paid');
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallyPaid');
case ExpenseSplitStatusEnum.UNPAID: return t('listDetailPage.status.unpaid');
default: return t('listDetailPage.status.unknown');
}
};
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
switch (status) {
case ExpenseOverallStatusEnum.PAID: return t('listDetailPage.status.settled');
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallySettled');
case ExpenseOverallStatusEnum.UNPAID: return t('listDetailPage.status.unsettled');
default: return t('listDetailPage.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 '';
};
</script>
<style scoped>
.neo-expenses-section {
padding: 0;
margin-top: 1.2rem;
}
.neo-expense-list {
background-color: rgb(255, 248, 240);
border-radius: 12px;
overflow: hidden;
border: 1px solid #f0e5d8;
}
.neo-expense-item-wrapper {
border-bottom: 1px solid #f0e5d8;
}
.neo-expense-item-wrapper:last-child {
border-bottom: none;
}
.neo-expense-item {
padding: 1rem 1.2rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s ease;
}
.neo-expense-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.neo-expense-item.is-expanded .expense-toggle-icon {
transform: rotate(180deg);
}
.expense-main-content {
display: flex;
align-items: center;
gap: 1rem;
}
.expense-icon-container {
color: #d99a53;
}
.expense-text-content {
display: flex;
flex-direction: column;
}
.expense-side-content {
display: flex;
align-items: center;
gap: 1rem;
}
.expense-toggle-icon {
color: #888;
transition: transform 0.3s ease;
}
.neo-expense-header {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.1rem;
}
.neo-expense-details,
.neo-split-details {
font-size: 0.9rem;
color: #555;
margin-bottom: 0.3rem;
}
.neo-expense-details strong,
.neo-split-details strong {
color: #111;
}
.neo-expense-status {
display: inline-block;
padding: 0.25em 0.6em;
font-size: 0.85em;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.375rem;
margin-left: 0.5rem;
color: #22c55e;
}
.status-unpaid {
background-color: #fee2e2;
color: #dc2626;
}
.status-partially_paid {
background-color: #ffedd5;
color: #f97316;
}
.status-paid {
background-color: #dcfce7;
color: #22c55e;
}
.neo-splits-container {
padding: 0.5rem 1.2rem 1.2rem;
background-color: rgba(255, 255, 255, 0.5);
}
.neo-splits-list {
margin-top: 0rem;
padding-left: 0;
border-left: none;
}
.neo-split-item {
padding: 0.75rem 0;
border-bottom: 1px dashed #f0e5d8;
display: grid;
grid-template-areas:
"user owes status paid action"
"activities activities activities activities activities";
grid-template-columns: 1.5fr 1fr 1fr 1.5fr auto;
gap: 0.5rem 1rem;
align-items: center;
}
.neo-split-item:last-child {
border-bottom: none;
}
.split-col.split-user {
grid-area: user;
}
.split-col.split-owes {
grid-area: owes;
}
.split-col.split-status {
grid-area: status;
}
.split-col.split-paid-info {
grid-area: paid;
}
.split-col.split-action {
grid-area: action;
justify-self: end;
}
.split-col.neo-settlement-activities {
grid-area: activities;
font-size: 0.8em;
color: #555;
padding-left: 1em;
list-style-type: disc;
margin-top: 0.5em;
}
.neo-settlement-activities {
font-size: 0.8em;
color: #555;
padding-left: 1em;
list-style-type: disc;
margin-top: 0.5em;
}
.neo-settlement-activities li {
margin-top: 0.2em;
}
</style>

View File

@ -0,0 +1,255 @@
<template>
<div class="neo-item-list-cotainer">
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
: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 || 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"
: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)"
@update:editName="item.editName = $event" @update:editQuantity="item.editQuantity = $event"
@update:editCategoryId="item.editCategoryId = $event"
@update:priceInput="item.priceInput = $event" />
</template>
</draggable>
</div>
<!-- New Add Item LI, integrated into the list -->
<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"
:placeholder="$t('listDetailPage.items.addItemForm.placeholder')" ref="itemNameInputRef"
:value="newItem.name"
@input="$emit('update:newItemName', ($event.target as HTMLInputElement).value)"
@keyup.enter="$emit('add-item')" @blur="handleNewItemBlur" @click.stop />
<VSelect
:model-value="newItem.category_id === null || newItem.category_id === undefined ? '' : newItem.category_id"
@update:modelValue="$emit('update:newItemCategoryId', $event === '' ? null : $event)"
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
</label>
</li>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import draggable from 'vuedraggable';
import { useI18n } from 'vue-i18n';
import ListItem from './ListItem.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import type { Item } from '@/types/item';
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
isEditing?: boolean;
editName?: string;
editQuantity?: number | string | null;
editCategoryId?: number | null;
showFirework?: boolean;
group_id?: number;
}
const props = defineProps({
items: {
type: Array as PropType<ItemWithUI[]>,
required: true,
},
isOnline: {
type: Boolean,
required: true,
},
supermarktMode: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array as PropType<{ label: string; value: number | null }[]>,
required: true,
},
newItem: {
type: Object as PropType<{ name: string; category_id?: number | null }>,
required: true,
},
categories: {
type: Array as PropType<{ id: number; name: string }[]>,
required: true,
}
});
const emit = defineEmits([
'delete-item',
'checkbox-change',
'update-price',
'start-edit',
'save-edit',
'cancel-edit',
'add-item',
'handle-drag-end',
'update:newItemName',
'update:newItemCategoryId',
]);
const { t } = useI18n();
const itemNameInputRef = ref<HTMLInputElement | null>(null);
const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
...opt,
value: opt.value === null ? '' : opt.value
})));
const groupedItems = computed(() => {
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
props.items.forEach(item => {
const categoryId = item.category_id;
const category = props.categories.find(c => c.id === categoryId);
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
if (!groups[categoryName]) {
groups[categoryName] = { categoryName, items: [] };
}
groups[categoryName].items.push(item);
});
return Object.values(groups);
});
const handleDragEnd = (evt: any) => {
// We need to find the original item and its new global index
const item = evt.item.__vue__.$props.item;
let newIndex = 0;
let found = false;
for (const group of groupedItems.value) {
if (found) break;
for (const i of group.items) {
if (i.id === item.id) {
found = true;
break;
}
newIndex++;
}
}
// Create a new event object with the necessary info
const newEvt = {
item,
newIndex: newIndex,
oldIndex: evt.oldIndex, // This oldIndex is relative to the group
};
emit('handle-drag-end', newEvt);
};
const handleNewItemBlur = (event: FocusEvent) => {
const inputElement = event.target as HTMLInputElement;
if (inputElement.value.trim()) {
emit('add-item');
}
};
const focusNewItemInput = () => {
itemNameInputRef.value?.focus();
}
defineExpose({
focusNewItemInput
});
</script>
<style scoped>
.neo-checkbox-label {
display: flex;
align-items: center;
gap: 1rem;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 1.2rem;
padding-inline: 0;
margin-bottom: 0;
border-bottom: 1px solid #eee;
background: var(--light);
}
.new-item-input-container {
list-style: none !important;
padding-inline: 3rem;
padding-bottom: 1.2rem;
}
.new-item-input-container .neo-checkbox-label {
width: 100%;
}
.neo-new-item-input {
all: unset;
height: 100%;
width: 100%;
font-size: 1.05rem;
font-weight: 500;
color: #444;
padding: 0.2rem 0;
border-bottom: 1px dashed #ccc;
transition: border-color 0.2s ease;
}
.neo-new-item-input:focus {
border-bottom-color: var(--secondary);
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 400;
}
.sortable-ghost {
opacity: 0.5;
background: #f0f0f0;
}
.sortable-drag {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.category-group {
margin-bottom: 1.5rem;
}
.category-header {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
padding: 0 1.2rem;
}
.category-group.highlight .neo-list-item:not(.is-complete) {
background-color: #e6f7ff;
}
.w-40 {
width: 20%;
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<li class="neo-list-item"
: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 && !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>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<!-- Content when NOT editing -->
<template v-if="!item.isEditing">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete"
@change="$emit('checkbox-change', item, ($event.target as HTMLInputElement).checked)" />
<div class="checkbox-content">
<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"
step="0.01" @blur="$emit('update-price', item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</label>
<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"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<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">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
<!-- Content WHEN editing -->
<template v-else>
<div class="inline-edit-form flex-grow flex items-center gap-2">
<VInput type="text" :model-value="item.editName ?? ''"
@update:modelValue="$emit('update:editName', $event)" required class="flex-grow" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VInput type="number" :model-value="item.editQuantity || ''"
@update:modelValue="$emit('update:editQuantity', $event)" min="1" class="w-20" size="sm"
@keydown.enter.prevent="$emit('save-edit', item)"
@keydown.esc.prevent="$emit('cancel-edit', item)" />
<VSelect :model-value="categoryModel" @update:modelValue="categoryModel = $event"
:options="safeCategoryOptions" placeholder="Category" class="w-40" size="sm" />
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-save-button" @click.stop="$emit('save-edit', item)"
:aria-label="$t('listDetailPage.buttons.saveChanges')">
<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">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
<button class="neo-icon-button neo-cancel-button" @click.stop="$emit('cancel-edit', item)"
:aria-label="$t('listDetailPage.buttons.cancel')">
<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="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="$emit('delete-item', item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<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">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</template>
</div>
</li>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Item } from '@/types/item';
import VInput from '@/components/valerie/VInput.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import { useOfflineStore } from '@/stores/offline';
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
isEditing?: boolean;
editName?: string;
editQuantity?: number | string | null;
editCategoryId?: number | null;
showFirework?: boolean;
}
const props = defineProps({
item: {
type: Object as PropType<ItemWithUI>,
required: true,
},
isOnline: {
type: Boolean,
required: true,
},
categoryOptions: {
type: Array as PropType<{ label: string; value: number | null }[]>,
required: true,
},
supermarktMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'delete-item',
'checkbox-change',
'update-price',
'start-edit',
'save-edit',
'cancel-edit',
'update:editName',
'update:editQuantity',
'update:editCategoryId',
'update:priceInput'
]);
const { t } = useI18n();
const offlineStore = useOfflineStore();
const safeCategoryOptions = computed(() => props.categoryOptions.map(opt => ({
...opt,
value: opt.value === null ? '' : opt.value
})));
const categoryModel = computed({
get: () => props.item.editCategoryId === null || props.item.editCategoryId === undefined ? '' : props.item.editCategoryId,
set: (value) => {
emit('update:editCategoryId', value === '' ? null : value);
}
});
const isItemPendingSync = computed(() => {
return offlineStore.pendingActions.some(action => {
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
const payload = action.payload as { listId: string; itemId: string };
return payload.itemId === String(props.item.id);
}
return false;
});
});
const onPriceInput = (value: string | number) => {
emit('update:priceInput', value);
}
</script>
<style scoped>
.neo-list-item {
padding: 1rem 0;
border-bottom: 1px solid #eee;
transition: background-color 0.2s ease;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-list-item:hover {
background-color: #f8f8f8;
}
@media (max-width: 600px) {
.neo-list-item {
padding: 0.75rem 1rem;
}
}
.item-pending-sync {
/* You can add specific styling for pending items, e.g., a subtle glow or background */
}
.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;
}
.neo-list-item:hover .neo-item-actions {
opacity: 1;
}
.inline-edit-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-grow: 1;
}
.neo-icon-button {
padding: 0.5rem;
border-radius: 4px;
color: #666;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
}
.neo-icon-button:hover {
background: #f0f0f0;
color: #333;
}
.neo-edit-button {
color: #3b82f6;
}
.neo-edit-button:hover {
background: #eef7fd;
color: #2563eb;
}
.neo-delete-button {
color: #ef4444;
}
.neo-delete-button:hover {
background: #fee2e2;
color: #dc2626;
}
.neo-save-button {
color: #22c55e;
}
.neo-save-button:hover {
background: #dcfce7;
color: #16a34a;
}
.neo-cancel-button {
color: #ef4444;
}
.neo-cancel-button:hover {
background: #fee2e2;
color: #dc2626;
}
/* 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;
}
.neo-checkbox-label input[type="checkbox"] {
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"]::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;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
}
.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;
}
.neo-price-input {
display: inline-flex;
align-items: center;
margin-left: 0.5rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.neo-list-item:hover .neo-price-input {
opacity: 1;
}
.drag-handle {
cursor: grab;
padding: 0.5rem;
color: #666;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.neo-list-item:hover .drag-handle {
opacity: 0.5;
}
.drag-handle:hover {
opacity: 1 !important;
color: #333;
}
.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

@ -0,0 +1,114 @@
<template>
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.ocr.title')"
@update:modelValue="$emit('update:modelValue', $event)">
<template #default>
<div v-if="ocrLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.ocrProcessing')" />
</div>
<VList v-else-if="ocrItems.length > 0">
<VListItem v-for="(ocrItem, index) in ocrItems" :key="index">
<div class="flex items-center gap-2">
<VInput type="text" v-model="ocrItem.name" class="flex-grow" required />
<VButton variant="danger" size="sm" :icon-only="true" iconLeft="trash"
@click="ocrItems.splice(index, 1)" />
</div>
</VListItem>
</VList>
<VFormField v-else :label="$t('listDetailPage.modals.ocr.uploadLabel')"
:error-message="ocrError || undefined">
<VInput type="file" id="ocrFile" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"
:model-value="''" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="$emit('update:modelValue', false)">{{ $t('listDetailPage.buttons.cancel')
}}</VButton>
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="confirmAddItems"
:disabled="isAdding">
<VSpinner v-if="isAdding" size="sm" />
{{ $t('listDetailPage.buttons.addItems') }}
</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import { getApiErrorMessage } from '@/utils/errors';
import VModal from '@/components/valerie/VModal.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
import VFormField from '@/components/valerie/VFormField.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
isAdding: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'add-items']);
const { t } = useI18n();
const ocrLoading = ref(false);
const ocrItems = ref<{ name: string }[]>([]);
const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<InstanceType<typeof VInput> | null>(null);
const handleOcrFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
handleOcrUpload(target.files[0]);
}
};
const handleOcrUpload = async (file: File) => {
if (!file) return;
ocrLoading.value = true;
ocrError.value = null;
ocrItems.value = [];
try {
const formData = new FormData();
formData.append('image_file', file);
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
ocrItems.value = response.data.extracted_items
.map((nameStr: string) => ({ name: nameStr.trim() }))
.filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) {
ocrError.value = t('listDetailPage.errors.ocrNoItems');
}
} catch (err) {
ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed', t);
} finally {
ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value?.$el) {
const input = ocrFileInputRef.value.$el.querySelector ? ocrFileInputRef.value.$el.querySelector('input') : ocrFileInputRef.value.$el;
if (input) input.value = '';
}
}
};
const confirmAddItems = () => {
emit('add-items', ocrItems.value);
};
watch(() => props.modelValue, (newVal) => {
if (newVal) {
ocrItems.value = [];
ocrError.value = null;
}
});
</script>

View File

@ -0,0 +1,73 @@
<template>
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.settleShare.title')"
@update:modelValue="$emit('update:modelValue', false)" size="md">
<template #default>
<div v-if="isLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.settlement')" />
</div>
<div v-else>
<p>
{{ $t('listDetailPage.modals.settleShare.settleAmountFor', { userName: userName }) }}
</p>
<VFormField :label="$t('listDetailPage.modals.settleShare.amountLabel')"
:error-message="error || undefined">
<VInput type="number" :model-value="amount" @update:modelValue="$emit('update:amount', $event)"
required />
</VFormField>
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="$emit('update:modelValue', false)">
{{ $t('listDetailPage.modals.settleShare.cancelButton') }}
</VButton>
<VButton variant="primary" @click="$emit('confirm')" :disabled="isLoading">
{{ $t('listDetailPage.modals.settleShare.confirmButton') }}
</VButton>
</template>
</VModal>
</template>
<script setup lang="ts">
import { computed, defineProps, defineEmits } from 'vue';
import type { PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ExpenseSplit } from '@/types/expense';
import VModal from '@/components/valerie/VModal.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VButton from '@/components/valerie/VButton.vue';
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
split: {
type: Object as PropType<ExpenseSplit | null>,
required: true,
},
amount: {
type: String,
required: true,
},
error: {
type: String as PropType<string | null>,
default: null,
},
isLoading: {
type: Boolean,
default: false,
}
});
defineEmits(['update:modelValue', 'update:amount', 'confirm']);
const { t } = useI18n();
const userName = computed(() => {
if (!props.split) return '';
return props.split.user?.name || props.split.user?.email || `User ID: ${props.split.user_id}`;
});
</script>

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>

File diff suppressed because it is too large Load Diff

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;
}

31
fe/src/utils/errors.ts Normal file
View File

@ -0,0 +1,31 @@
// Helper to extract user-friendly error messages from API responses
export const getApiErrorMessage = (err: unknown, fallbackMessageKey: string, t: (key: string, ...args: any[]) => string): string => {
if (err && typeof err === 'object') {
// Check for FastAPI/DRF-style error response
if ('response' in err && err.response && typeof err.response === 'object' && 'data' in err.response && err.response.data) {
const errorData = err.response.data as any; // Type assertion for easier access
if (typeof errorData.detail === 'string') {
return errorData.detail;
}
if (typeof errorData.message === 'string') { // Common alternative
return errorData.message;
}
// FastAPI validation errors often come as an array of objects
if (Array.isArray(errorData.detail) && errorData.detail.length > 0) {
const firstError = errorData.detail[0];
if (typeof firstError.msg === 'string' && typeof firstError.type === 'string') {
return firstError.msg; // Simpler: just the message
}
}
if (typeof errorData === 'string') { // Sometimes data itself is the error string
return errorData;
}
}
// Standard JavaScript Error object
if (err instanceof Error && err.message) {
return err.message;
}
}
// Fallback to a translated message
return t(fallbackMessageKey);
};