
- Added `motion` and `framer-motion` packages to `package.json` and `package-lock.json`. - Updated API base URL in `api-config.ts` to point to the local development environment. - Refactored `ListDetailPage.vue` to enhance item rendering and interaction, replacing `VListItem` with a custom list structure. - Improved `ListsPage.vue` to handle loading states and item addition more effectively, including better handling of temporary item IDs. These changes aim to improve the user experience and maintainability of the application.
1619 lines
50 KiB
Vue
1619 lines
50 KiB
Vue
<template>
|
||
<main class="neo-container page-padding">
|
||
<div v-if="pageInitialLoad && !list && !error" class="text-center py-10">
|
||
<VSpinner label="Loading list..." size="lg" />
|
||
</div>
|
||
|
||
<VAlert v-else-if="error && !list" type="error" :message="error" class="mb-4">
|
||
<template #actions>
|
||
<VButton @click="fetchListDetails">Retry</VButton>
|
||
</template>
|
||
</VAlert>
|
||
|
||
<template v-else-if="list">
|
||
<!-- Header -->
|
||
<div class="neo-list-header">
|
||
<VHeading :level="1" :text="list.name" class="mb-3 neo-title" />
|
||
<div class="neo-header-actions">
|
||
<VButton @click="showCostSummaryDialog = true" :disabled="!isOnline" icon-left="clipboard">Cost Summary
|
||
</VButton>
|
||
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">Add via OCR</VButton>
|
||
<VBadge :text="list.group_id ? 'Group List' : 'Personal List'" :variant="list.group_id ? 'accent' : 'settled'"
|
||
class="neo-status" />
|
||
</div>
|
||
</div>
|
||
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
|
||
|
||
<!-- Items List Section -->
|
||
<VCard v-if="itemsAreLoading" class="py-10 text-center mt-4">
|
||
<VSpinner label="Loading items..." size="lg" />
|
||
</VCard>
|
||
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard"
|
||
empty-title="No Items Yet!" empty-message="Add some items using the form below." class="mt-4" />
|
||
<div v-else class="neo-item-list-container mt-4">
|
||
<ul class="neo-item-list">
|
||
<li v-for="item in list.items" :key="item.id" class="neo-list-item"
|
||
:class="{ 'bg-gray-100 opacity-70': item.is_complete }">
|
||
<div class="neo-item-content">
|
||
<label class="neo-checkbox-label" @click.stop>
|
||
<input type="checkbox" :checked="item.is_complete"
|
||
@change="(e) => confirmUpdateItem(item, (e.target as HTMLInputElement)?.checked ?? false)" />
|
||
<span class="item-name" :class="{ 'line-through': item.is_complete }">{{ item.name }}</span>
|
||
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
|
||
</label>
|
||
<div class="neo-item-actions">
|
||
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
|
||
<VIcon name="edit" />
|
||
</button>
|
||
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
|
||
:disabled="item.deleting" aria-label="Delete item">
|
||
<VIcon name="trash" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="item.is_complete" class="neo-price-input">
|
||
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="item.priceInput = $event"
|
||
placeholder="Price" size="sm" class="w-24" step="0.01" @blur="updateItemPrice(item)"
|
||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Add New Item Form -->
|
||
<form @submit.prevent="onAddItem" class="add-item-form mt-4 p-4 border rounded-lg shadow flex items-center gap-2">
|
||
<VIcon name="plus-circle" class="text-gray-400 shrink-0" />
|
||
<VFormField class="flex-grow" label="New item name" :label-sr-only="true">
|
||
<VInput v-model="newItem.name" placeholder="Add a new item" required ref="itemNameInputRef" />
|
||
</VFormField>
|
||
<VFormField label="Quantity" :label-sr-only="true" class="w-24 shrink-0">
|
||
<VInput type="number" :model-value="newItem.quantity || ''" @update:modelValue="newItem.quantity = $event"
|
||
placeholder="Qty" min="1" />
|
||
</VFormField>
|
||
<VButton type="submit" :disabled="addingItem" class="shrink-0">
|
||
<VSpinner v-if="addingItem" size="sm" />
|
||
<span v-else>Add</span>
|
||
</VButton>
|
||
</form>
|
||
</template>
|
||
|
||
<!-- Expenses Section (Original Content - Part 3 will refactor this) -->
|
||
<section v-if="list && !itemsAreLoading" class="neo-expenses-section">
|
||
<div class="neo-expenses-header">
|
||
<h2 class="neo-expenses-title">Expenses</h2>
|
||
<button class="neo-action-button" @click="showCreateExpenseForm = true">
|
||
<svg class="icon">
|
||
<use xlink:href="#icon-plus" />
|
||
</svg>
|
||
Add Expense
|
||
</button>
|
||
</div>
|
||
<div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state">
|
||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||
<p>Loading expenses...</p>
|
||
</div>
|
||
<div v-else-if="listDetailStore.error" class="neo-error-state">
|
||
<p>{{ listDetailStore.error }}</p>
|
||
<button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">Retry</button>
|
||
</div>
|
||
<div v-else-if="!expenses || expenses.length === 0" class="neo-empty-state">
|
||
<p>No expenses recorded for this list yet.</p>
|
||
</div>
|
||
<div v-else>
|
||
<div v-for="expense in expenses" :key="expense.id" class="neo-expense-card">
|
||
<div class="neo-expense-header">
|
||
{{ expense.description }} - {{ formatCurrency(expense.total_amount) }}
|
||
<span class="neo-expense-status" :class="getStatusClass(expense.overall_settlement_status)">
|
||
{{ getOverallExpenseStatusText(expense.overall_settlement_status) }}
|
||
</span>
|
||
</div>
|
||
<div class="neo-expense-details">
|
||
Paid by: <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID:
|
||
${expense.paid_by_user_id}` }}</strong>
|
||
on {{ new Date(expense.expense_date).toLocaleDateString() }}
|
||
</div>
|
||
|
||
<div class="neo-splits-list">
|
||
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
|
||
<div class="neo-split-details">
|
||
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> owes {{
|
||
formatCurrency(split.owed_amount) }}
|
||
<span class="neo-expense-status" :class="getStatusClass(split.status)">
|
||
{{ getSplitStatusText(split.status) }}
|
||
</span>
|
||
</div>
|
||
<div class="neo-split-details">
|
||
Paid: {{ getPaidAmountForSplitDisplay(split) }}
|
||
<span v-if="split.paid_at"> on {{ new Date(split.paid_at).toLocaleDateString() }}</span>
|
||
</div>
|
||
<button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
|
||
class="neo-button neo-button-primary" @click="openSettleShareModal(expense, split)"
|
||
:disabled="isSettlementLoading">
|
||
Settle My Share
|
||
</button>
|
||
<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">
|
||
Activity: {{ formatCurrency(activity.amount_paid) }} by {{ activity.payer?.name || `User
|
||
${activity.paid_by_user_id}` }} on {{ new Date(activity.paid_at).toLocaleDateString() }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Create Expense Form (Original Content) -->
|
||
<CreateExpenseForm v-if="showCreateExpenseForm" :list-id="list?.id" :group-id="list?.group_id ?? undefined"
|
||
@close="showCreateExpenseForm = false" @created="handleExpenseCreated" />
|
||
|
||
<!-- OCR Dialog -->
|
||
<VModal v-model="showOcrDialogState" title="Add Items via OCR" @update:modelValue="!$event && closeOcrDialog()">
|
||
<template #default>
|
||
<div v-if="ocrLoading" class="text-center">
|
||
<VSpinner label="Processing image..." />
|
||
</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="Upload Image" :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="closeOcrDialog">Cancel</VButton>
|
||
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="addOcrItems"
|
||
:disabled="addingOcrItems">
|
||
<VSpinner v-if="addingOcrItems" size="sm" /> Add Items
|
||
</VButton>
|
||
</template>
|
||
</VModal>
|
||
|
||
<!-- Confirmation Dialog -->
|
||
<VModal v-model="showConfirmDialogState" title="Confirmation" @update:modelValue="!$event && cancelConfirmation()"
|
||
size="sm">
|
||
<template #default>
|
||
<div class="text-center">
|
||
<VIcon name="alert-triangle" size="lg" class="text-yellow-500 mb-2" />
|
||
<p>{{ confirmDialogMessage }}</p>
|
||
</div>
|
||
</template>
|
||
<template #footer>
|
||
<VButton variant="neutral" @click="cancelConfirmation">Cancel</VButton>
|
||
<VButton variant="primary" @click="handleConfirmedAction">Confirm</VButton>
|
||
</template>
|
||
</VModal>
|
||
|
||
<!-- Cost Summary Dialog -->
|
||
<VModal v-model="showCostSummaryDialog" title="List Cost Summary" @update:modelValue="showCostSummaryDialog = false"
|
||
size="lg">
|
||
<template #default>
|
||
<div v-if="costSummaryLoading" class="text-center">
|
||
<VSpinner label="Loading summary..." />
|
||
</div>
|
||
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
|
||
<div v-else-if="listCostSummary">
|
||
<div class="mb-3 cost-overview">
|
||
<p><strong>Total List Cost:</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
|
||
<p><strong>Equal Share Per User:</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
|
||
<p><strong>Participating Users:</strong> {{ listCostSummary.num_participating_users }}</p>
|
||
</div>
|
||
<h4>User Balances</h4>
|
||
<div class="table-container mt-2">
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>User</th>
|
||
<th class="text-right">Items Added Value</th>
|
||
<th class="text-right">Amount Due</th>
|
||
<th class="text-right">Balance</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="userShare in listCostSummary.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>No cost summary available.</p>
|
||
</template>
|
||
<template #footer>
|
||
<VButton variant="primary" @click="showCostSummaryDialog = false">Close</VButton>
|
||
</template>
|
||
</VModal>
|
||
|
||
<!-- Settle Share Modal -->
|
||
<VModal v-model="showSettleModal" title="Settle Share" @update:modelValue="!$event && closeSettleShareModal()"
|
||
size="md">
|
||
<template #default>
|
||
<div v-if="isSettlementLoading" class="text-center">
|
||
<VSpinner label="Processing settlement..." />
|
||
</div>
|
||
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
|
||
<div v-else>
|
||
<p>Settle amount for {{ selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email ||
|
||
`User ID: ${selectedSplitForSettlement?.user_id}` }}:</p>
|
||
<VFormField label="Amount" :error-message="settleAmountError || undefined">
|
||
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
|
||
</VFormField>
|
||
</div>
|
||
</template>
|
||
<template #footer>
|
||
<VButton variant="neutral" @click="closeSettleShareModal">Cancel</VButton>
|
||
<VButton variant="primary" @click="handleConfirmSettle">Confirm</VButton>
|
||
</template>
|
||
</VModal>
|
||
|
||
<!-- Edit Item Dialog -->
|
||
<VModal v-model="showEditDialog" title="Edit Item" @update:modelValue="!$event && closeEditDialog()">
|
||
<template #default>
|
||
<VFormField v-if="editingItem" label="Item Name" class="mb-4">
|
||
<VInput type="text" id="editItemName" v-model="editingItem.name" required />
|
||
</VFormField>
|
||
<VFormField v-if="editingItem" label="Quantity">
|
||
<VInput type="number" id="editItemQuantity" :model-value="editingItem.quantity || ''"
|
||
@update:modelValue="editingItem.quantity = $event" min="1" />
|
||
</VFormField>
|
||
</template>
|
||
<template #footer>
|
||
<VButton variant="neutral" @click="closeEditDialog">Cancel</VButton>
|
||
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">Save Changes
|
||
</VButton>
|
||
</template>
|
||
</VModal>
|
||
|
||
<VAlert v-if="!list && !pageInitialLoad" type="info" message="Group not found or an error occurred." />
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
|
||
import { useRoute } from 'vue-router';
|
||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Keep for item management
|
||
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; // onClickOutside removed
|
||
import { useNotificationStore } from '@/stores/notifications';
|
||
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
||
import { useListDetailStore } from '@/stores/listDetailStore';
|
||
import type { ListWithExpenses } from '@/types/list';
|
||
import type { Expense, ExpenseSplit } from '@/types/expense';
|
||
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import { Decimal } from 'decimal.js';
|
||
import type { SettlementActivityCreate } from '@/types/expense';
|
||
import SettleShareModal from '@/components/SettleShareModal.vue';
|
||
import CreateExpenseForm from '@/components/CreateExpenseForm.vue';
|
||
import type { Item } from '@/types/item';
|
||
import VHeading from '@/components/valerie/VHeading.vue';
|
||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||
import VAlert from '@/components/valerie/VAlert.vue';
|
||
import VButton from '@/components/valerie/VButton.vue';
|
||
import VBadge from '@/components/valerie/VBadge.vue';
|
||
import VIcon from '@/components/valerie/VIcon.vue';
|
||
import VModal from '@/components/valerie/VModal.vue';
|
||
import VFormField from '@/components/valerie/VFormField.vue';
|
||
import VInput from '@/components/valerie/VInput.vue';
|
||
import VList from '@/components/valerie/VList.vue';
|
||
import VListItem from '@/components/valerie/VListItem.vue';
|
||
import VCheckbox from '@/components/valerie/VCheckbox.vue';
|
||
// VTextarea and VSelect are not used in this part of the refactor for ListDetailPage
|
||
|
||
|
||
// UI-specific properties that we add to items
|
||
interface ItemWithUI extends Item {
|
||
updating: boolean;
|
||
deleting: boolean;
|
||
priceInput: string | number | null;
|
||
swiped: boolean;
|
||
}
|
||
|
||
interface List {
|
||
id: number;
|
||
name: string;
|
||
description?: string;
|
||
is_complete: boolean;
|
||
items: ItemWithUI[];
|
||
version: number;
|
||
updated_at: string;
|
||
group_id?: number;
|
||
}
|
||
|
||
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[];
|
||
}
|
||
|
||
const route = useRoute();
|
||
const { isOnline } = useNetwork();
|
||
const notificationStore = useNotificationStore();
|
||
const offlineStore = useOfflineStore();
|
||
const list = ref<List | null>(null);
|
||
const pageInitialLoad = ref(true); // True until shell is loaded or first fetch begins
|
||
const itemsAreLoading = ref(false); // True when items are actively being fetched/processed
|
||
const error = ref<string | null>(null); // For page-level errors
|
||
const addingItem = ref(false);
|
||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||
const lastListUpdate = ref<string | null>(null);
|
||
const lastItemUpdate = ref<string | null>(null);
|
||
|
||
const newItem = ref<{ name: string; quantity?: number }>({ name: '' });
|
||
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed type
|
||
|
||
// OCR
|
||
const showOcrDialogState = ref(false);
|
||
// const ocrModalRef = ref<HTMLElement | null>(null); // Removed
|
||
const ocrLoading = ref(false);
|
||
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
|
||
const addingOcrItems = ref(false);
|
||
const ocrError = ref<string | null>(null);
|
||
const ocrFileInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed to VInput ref type
|
||
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
|
||
accept: 'image/*',
|
||
multiple: false,
|
||
});
|
||
|
||
|
||
// Confirmation Dialog
|
||
const showConfirmDialogState = ref(false);
|
||
// const confirmModalRef = ref<HTMLElement | null>(null); // Removed
|
||
const confirmDialogMessage = ref('');
|
||
const pendingAction = ref<(() => Promise<void>) | null>(null);
|
||
|
||
// Cost Summary
|
||
const showCostSummaryDialog = ref(false);
|
||
// const costSummaryModalRef = ref<HTMLElement | null>(null); // Removed
|
||
const listCostSummary = ref<ListCostSummaryData | null>(null);
|
||
const costSummaryLoading = ref(false);
|
||
const costSummaryError = ref<string | null>(null);
|
||
|
||
// Settle Share
|
||
const authStore = useAuthStore();
|
||
const showSettleModal = ref(false);
|
||
// const settleModalRef = ref<HTMLElement | null>(null); // Removed
|
||
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
|
||
const parentExpenseOfSelectedSplit = ref<Expense | null>(null);
|
||
const settleAmount = ref<string>('');
|
||
const settleAmountError = ref<string | null>(null);
|
||
const isSettlementLoading = computed(() => listDetailStore.isSettlingSplit);
|
||
|
||
// Create Expense
|
||
const showCreateExpenseForm = ref(false);
|
||
|
||
// Edit Item
|
||
const showEditDialog = ref(false);
|
||
// const editModalRef = ref<HTMLElement | null>(null); // Removed
|
||
const editingItem = ref<Item | null>(null);
|
||
|
||
// onClickOutside for ocrModalRef, costSummaryModalRef, etc. are removed as VModal handles this.
|
||
|
||
|
||
const formatCurrency = (value: string | number | undefined | null): string => {
|
||
if (value === undefined || value === null) return '$0.00';
|
||
// Ensure that string "0.00" or "0" are handled correctly before parseFloat
|
||
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 processListItems = (items: Item[]): ItemWithUI[] => {
|
||
return items.map(item => ({
|
||
...item,
|
||
updating: false,
|
||
deleting: false,
|
||
priceInput: item.price || null,
|
||
swiped: false
|
||
}));
|
||
};
|
||
|
||
const fetchListDetails = async () => {
|
||
// If pageInitialLoad is still true here, it means no shell was loaded.
|
||
// The main spinner might be showing. We're about to fetch details, so turn off main spinner.
|
||
if (pageInitialLoad.value) {
|
||
pageInitialLoad.value = false;
|
||
}
|
||
itemsAreLoading.value = true;
|
||
|
||
// Check for pre-fetched full data first
|
||
const routeId = String(route.params.id);
|
||
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
|
||
|
||
try {
|
||
let response;
|
||
if (cachedFullData) {
|
||
// Use cached data
|
||
response = { data: JSON.parse(cachedFullData) };
|
||
// Clear the cache after using it
|
||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||
} else {
|
||
// Fetch fresh data
|
||
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||
}
|
||
|
||
const rawList = response.data as ListWithExpenses;
|
||
// Map API response to local List type
|
||
const localList: List = {
|
||
id: rawList.id,
|
||
name: rawList.name,
|
||
description: rawList.description ?? undefined,
|
||
is_complete: rawList.is_complete,
|
||
items: processListItems(rawList.items),
|
||
version: rawList.version,
|
||
updated_at: rawList.updated_at,
|
||
group_id: rawList.group_id ?? undefined
|
||
};
|
||
list.value = localList;
|
||
lastListUpdate.value = rawList.updated_at;
|
||
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
|
||
return item.updated_at > latest ? item.updated_at : latest;
|
||
}, '');
|
||
if (showCostSummaryDialog.value) {
|
||
await fetchListCostSummary();
|
||
}
|
||
} catch (err: unknown) {
|
||
const errorMessage = (err instanceof Error ? err.message : String(err)) || 'Failed to load list details.';
|
||
if (!list.value) { // If there was no shell AND this fetch failed
|
||
error.value = errorMessage; // This error is for the whole page
|
||
} else {
|
||
// We have a shell, but items failed to load.
|
||
// Show a notification for item loading failure. list.items will remain as per shell (empty).
|
||
notificationStore.addNotification({ message: `Failed to load items: ${errorMessage}`, type: 'error' });
|
||
}
|
||
} finally {
|
||
itemsAreLoading.value = false;
|
||
// If list is still null and no error was set (e.g. silent failure), ensure pageInitialLoad is false.
|
||
if (!list.value && !error.value) {
|
||
pageInitialLoad.value = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const checkForUpdates = async () => {
|
||
if (!list.value) return;
|
||
try {
|
||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
|
||
const { updated_at: newListUpdatedAt, items: newItems } = response.data as ListWithExpenses;
|
||
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) =>
|
||
item.updated_at > latest ? item.updated_at : latest,
|
||
'');
|
||
|
||
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
|
||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
|
||
await fetchListDetails();
|
||
}
|
||
} catch (err) {
|
||
console.warn('Polling for updates failed:', err);
|
||
}
|
||
};
|
||
|
||
const startPolling = () => {
|
||
stopPolling();
|
||
pollingInterval.value = setInterval(() => checkForUpdates(), 15000);
|
||
};
|
||
const stopPolling = () => {
|
||
if (pollingInterval.value) clearInterval(pollingInterval.value);
|
||
};
|
||
|
||
const isItemPendingSync = (item: Item) => {
|
||
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(item.id);
|
||
}
|
||
return false;
|
||
});
|
||
};
|
||
|
||
const onAddItem = async () => {
|
||
if (!list.value || !newItem.value.name.trim()) {
|
||
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
|
||
if (itemNameInputRef.value?.$el) {
|
||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||
}
|
||
return;
|
||
}
|
||
addingItem.value = true;
|
||
|
||
if (!isOnline.value) {
|
||
const offlinePayload: any = {
|
||
name: newItem.value.name
|
||
};
|
||
if (typeof newItem.value.quantity !== 'undefined') {
|
||
offlinePayload.quantity = String(newItem.value.quantity);
|
||
}
|
||
offlineStore.addAction({
|
||
type: 'create_list_item',
|
||
payload: {
|
||
listId: String(list.value.id),
|
||
itemData: offlinePayload
|
||
}
|
||
});
|
||
const optimisticItem: ItemWithUI = {
|
||
id: Date.now(),
|
||
name: newItem.value.name,
|
||
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
|
||
is_complete: false,
|
||
price: null,
|
||
version: 1,
|
||
updated_at: new Date().toISOString(),
|
||
created_at: new Date().toISOString(),
|
||
list_id: list.value.id,
|
||
updating: false,
|
||
deleting: false,
|
||
priceInput: null,
|
||
swiped: false
|
||
};
|
||
list.value.items.push(optimisticItem);
|
||
newItem.value = { name: '' };
|
||
if (itemNameInputRef.value?.$el) {
|
||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||
}
|
||
addingItem.value = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await apiClient.post(
|
||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||
{
|
||
name: newItem.value.name,
|
||
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null
|
||
}
|
||
);
|
||
const addedItem = response.data as Item;
|
||
list.value.items.push(processListItems([addedItem])[0]);
|
||
newItem.value = { name: '' };
|
||
if (itemNameInputRef.value?.$el) {
|
||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||
}
|
||
} catch (err) {
|
||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add item.', type: 'error' });
|
||
} finally {
|
||
addingItem.value = false;
|
||
}
|
||
};
|
||
|
||
const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
|
||
if (!list.value) return;
|
||
item.updating = true;
|
||
const originalCompleteStatus = item.is_complete;
|
||
item.is_complete = newCompleteStatus;
|
||
|
||
if (!isOnline.value) {
|
||
offlineStore.addAction({
|
||
type: 'update_list_item',
|
||
payload: {
|
||
listId: String(list.value.id),
|
||
itemId: String(item.id),
|
||
data: {
|
||
completed: newCompleteStatus
|
||
},
|
||
version: item.version
|
||
}
|
||
});
|
||
item.updating = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await apiClient.put(
|
||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||
{ completed: newCompleteStatus, version: item.version }
|
||
);
|
||
item.version++;
|
||
} catch (err) {
|
||
item.is_complete = originalCompleteStatus;
|
||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
|
||
} finally {
|
||
item.updating = false;
|
||
}
|
||
};
|
||
|
||
const updateItemPrice = async (item: ItemWithUI) => {
|
||
if (!list.value || !item.is_complete) return;
|
||
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
|
||
if (item.price === newPrice?.toString()) return;
|
||
item.updating = true;
|
||
const originalPrice = item.price;
|
||
const originalPriceInput = item.priceInput;
|
||
item.price = newPrice?.toString() || null;
|
||
if (!isOnline.value) {
|
||
offlineStore.addAction({
|
||
type: 'update_list_item',
|
||
payload: {
|
||
listId: String(list.value.id),
|
||
itemId: String(item.id),
|
||
data: {
|
||
price: newPrice ?? null,
|
||
completed: item.is_complete
|
||
},
|
||
version: item.version
|
||
}
|
||
});
|
||
item.updating = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await apiClient.put(
|
||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||
{ price: newPrice?.toString(), completed: item.is_complete, version: item.version }
|
||
);
|
||
item.version++;
|
||
} catch (err) {
|
||
item.price = originalPrice;
|
||
item.priceInput = originalPriceInput;
|
||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
|
||
} finally {
|
||
item.updating = false;
|
||
}
|
||
};
|
||
|
||
const deleteItem = async (item: ItemWithUI) => {
|
||
if (!list.value) return;
|
||
item.deleting = true;
|
||
|
||
if (!isOnline.value) {
|
||
offlineStore.addAction({
|
||
type: 'delete_list_item',
|
||
payload: {
|
||
listId: String(list.value.id),
|
||
itemId: String(item.id)
|
||
}
|
||
});
|
||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||
item.deleting = false;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
|
||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||
} catch (err) {
|
||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
|
||
} finally {
|
||
item.deleting = false;
|
||
}
|
||
};
|
||
|
||
const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => {
|
||
confirmDialogMessage.value = `Mark "${item.name}" as ${newCompleteStatus ? 'complete' : 'incomplete'}?`;
|
||
pendingAction.value = () => updateItem(item, newCompleteStatus);
|
||
showConfirmDialogState.value = true;
|
||
};
|
||
|
||
const confirmDeleteItem = (item: ItemWithUI) => {
|
||
confirmDialogMessage.value = `Delete "${item.name}"? This cannot be undone.`;
|
||
pendingAction.value = () => deleteItem(item);
|
||
showConfirmDialogState.value = true;
|
||
};
|
||
|
||
const handleConfirmedAction = async () => {
|
||
if (pendingAction.value) {
|
||
await pendingAction.value();
|
||
}
|
||
cancelConfirmation();
|
||
};
|
||
const cancelConfirmation = () => {
|
||
showConfirmDialogState.value = false;
|
||
pendingAction.value = null;
|
||
};
|
||
|
||
const openOcrDialog = () => {
|
||
ocrItems.value = [];
|
||
ocrError.value = null;
|
||
resetOcrFileDialog();
|
||
showOcrDialogState.value = true;
|
||
nextTick(() => {
|
||
// For VInput type file, direct .value = '' might not work or be needed.
|
||
// VInput should handle its own reset if necessary, or this ref might target the native input inside.
|
||
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) { // Assuming VInput exposes $el
|
||
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
|
||
if (inputElement) (inputElement as HTMLInputElement).value = '';
|
||
} else if (ocrFileInputRef.value) { // Fallback if ref is native input
|
||
(ocrFileInputRef.value as any).value = '';
|
||
}
|
||
});
|
||
};
|
||
const closeOcrDialog = () => {
|
||
showOcrDialogState.value = false;
|
||
ocrItems.value = [];
|
||
ocrError.value = null;
|
||
};
|
||
|
||
watch(ocrFiles, async (newFiles) => {
|
||
if (newFiles && newFiles.length > 0) {
|
||
const file = newFiles[0];
|
||
await handleOcrUpload(file);
|
||
}
|
||
});
|
||
|
||
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 = "No items extracted from the image.";
|
||
}
|
||
} catch (err) {
|
||
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
|
||
} finally {
|
||
ocrLoading.value = false;
|
||
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
|
||
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
|
||
if (inputElement) (inputElement as HTMLInputElement).value = '';
|
||
} else if (ocrFileInputRef.value) {
|
||
(ocrFileInputRef.value as any).value = '';
|
||
}
|
||
}
|
||
};
|
||
|
||
const addOcrItems = async () => {
|
||
if (!list.value || !ocrItems.value.length) return;
|
||
addingOcrItems.value = true;
|
||
let successCount = 0;
|
||
try {
|
||
for (const item of ocrItems.value) {
|
||
if (!item.name.trim()) continue;
|
||
const response = await apiClient.post(
|
||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||
{ name: item.name, quantity: "1" } // Assuming default quantity 1 for OCR items
|
||
);
|
||
const addedItem = response.data as Item;
|
||
list.value.items.push(processListItems([addedItem])[0]);
|
||
successCount++;
|
||
}
|
||
if (successCount > 0) notificationStore.addNotification({ message: `${successCount} item(s) added successfully from OCR.`, type: 'success' });
|
||
closeOcrDialog();
|
||
} catch (err) {
|
||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to add OCR items.', type: 'error' });
|
||
} finally {
|
||
addingOcrItems.value = false;
|
||
}
|
||
};
|
||
|
||
const fetchListCostSummary = async () => {
|
||
if (!list.value || list.value.id === 0) return;
|
||
costSummaryLoading.value = true;
|
||
costSummaryError.value = null;
|
||
try {
|
||
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
|
||
listCostSummary.value = response.data;
|
||
} catch (err) {
|
||
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to load cost summary.';
|
||
listCostSummary.value = null;
|
||
} finally {
|
||
costSummaryLoading.value = false;
|
||
}
|
||
};
|
||
watch(showCostSummaryDialog, (newVal) => {
|
||
if (newVal && (!listCostSummary.value || listCostSummary.value.list_id !== list.value?.id)) {
|
||
fetchListCostSummary();
|
||
}
|
||
});
|
||
|
||
// --- Expense and Settlement Status Logic ---
|
||
const listDetailStore = useListDetailStore();
|
||
const expenses = computed(() => listDetailStore.getExpenses);
|
||
|
||
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 'Paid';
|
||
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return 'Partially Paid';
|
||
case ExpenseSplitStatusEnum.UNPAID: return 'Unpaid';
|
||
default: return status;
|
||
}
|
||
};
|
||
|
||
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
|
||
switch (status) {
|
||
case ExpenseOverallStatusEnum.PAID: return 'Settled';
|
||
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return 'Partially Settled';
|
||
case ExpenseOverallStatusEnum.UNPAID: return 'Unsettled';
|
||
default: return status;
|
||
}
|
||
};
|
||
|
||
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 '';
|
||
};
|
||
|
||
// Keyboard shortcut
|
||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||
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 || showConfirmDialogState.value) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
if (itemNameInputRef.value?.$el) {
|
||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||
}
|
||
}
|
||
});
|
||
|
||
let touchStartX = 0;
|
||
const SWIPE_THRESHOLD = 50;
|
||
|
||
const handleTouchStart = (event: TouchEvent) => {
|
||
touchStartX = event.changedTouches[0].clientX;
|
||
};
|
||
|
||
const handleTouchMove = () => {
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
};
|
||
|
||
|
||
onMounted(() => {
|
||
pageInitialLoad.value = true;
|
||
itemsAreLoading.value = false;
|
||
error.value = null; // Clear stale errors on mount
|
||
|
||
if (!route.params.id) {
|
||
error.value = 'No list ID provided';
|
||
pageInitialLoad.value = false; // Stop initial load phase, show error
|
||
listDetailStore.setError('No list ID provided for expenses.'); // Set error in expense store
|
||
return;
|
||
}
|
||
|
||
// Attempt to load shell data from sessionStorage
|
||
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||
const routeId = String(route.params.id);
|
||
|
||
if (listShellJSON) {
|
||
const shellData = JSON.parse(listShellJSON);
|
||
// Ensure the shell data is for the current list
|
||
if (shellData.id === parseInt(routeId, 10)) {
|
||
list.value = {
|
||
id: shellData.id,
|
||
name: shellData.name,
|
||
description: shellData.description,
|
||
is_complete: false, // Assume not complete until full data loaded
|
||
items: [], // Start with no items, they will be fetched by fetchListDetails
|
||
version: 0, // Placeholder, will be updated
|
||
updated_at: new Date().toISOString(), // Placeholder
|
||
group_id: shellData.group_id,
|
||
};
|
||
pageInitialLoad.value = false; // Shell loaded, main page spinner can go
|
||
// Optionally, clear the sessionStorage item after use
|
||
// sessionStorage.removeItem('listDetailShell');
|
||
} else {
|
||
// Shell data is for a different list, clear it
|
||
sessionStorage.removeItem('listDetailShell');
|
||
// pageInitialLoad remains true, will be set to false by fetchListDetails
|
||
}
|
||
}
|
||
|
||
fetchListDetails().then(() => { // Fetches items
|
||
startPolling();
|
||
});
|
||
// Fetch expenses using the store when component is mounted
|
||
const routeParamsId = route.params.id;
|
||
listDetailStore.fetchListWithExpenses(String(routeParamsId));
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
stopPolling();
|
||
});
|
||
|
||
const editItem = (item: Item) => {
|
||
editingItem.value = { ...item };
|
||
showEditDialog.value = true;
|
||
};
|
||
|
||
const closeEditDialog = () => {
|
||
showEditDialog.value = false;
|
||
editingItem.value = null;
|
||
};
|
||
|
||
const handleConfirmEdit = async () => {
|
||
if (!editingItem.value || !list.value) return;
|
||
|
||
const item = editingItem.value;
|
||
const originalItem = list.value.items.find(i => i.id === item.id);
|
||
if (!originalItem) return;
|
||
|
||
try {
|
||
const response = await apiClient.put(
|
||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||
{
|
||
name: item.name,
|
||
quantity: item.quantity?.toString(),
|
||
version: item.version
|
||
}
|
||
);
|
||
|
||
// Update the item in the list
|
||
const updatedItem = response.data as Item;
|
||
const index = list.value.items.findIndex(i => i.id === item.id);
|
||
if (index !== -1) {
|
||
list.value.items[index] = processListItems([updatedItem])[0];
|
||
}
|
||
|
||
notificationStore.addNotification({
|
||
message: 'Item updated successfully',
|
||
type: 'success'
|
||
});
|
||
closeEditDialog();
|
||
} catch (err) {
|
||
notificationStore.addNotification({
|
||
message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item',
|
||
type: 'error'
|
||
});
|
||
}
|
||
};
|
||
|
||
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
|
||
if (split.user_id !== authStore.user?.id) {
|
||
notificationStore.addNotification({ message: "You can only settle your own shares.", type: 'warning' });
|
||
return;
|
||
}
|
||
selectedSplitForSettlement.value = split;
|
||
parentExpenseOfSelectedSplit.value = expense;
|
||
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(split.id));
|
||
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;
|
||
parentExpenseOfSelectedSplit.value = null;
|
||
settleAmount.value = '';
|
||
settleAmountError.value = null;
|
||
};
|
||
|
||
const validateSettleAmount = (): boolean => {
|
||
settleAmountError.value = null;
|
||
if (!settleAmount.value.trim()) {
|
||
settleAmountError.value = 'Please enter an amount.';
|
||
return false;
|
||
}
|
||
const amount = new Decimal(settleAmount.value);
|
||
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
|
||
settleAmountError.value = 'Please enter a positive amount.';
|
||
return false;
|
||
}
|
||
if (selectedSplitForSettlement.value) {
|
||
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id));
|
||
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
|
||
const remaining = owed.minus(alreadyPaid);
|
||
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) { // Epsilon for float issues
|
||
settleAmountError.value = `Amount cannot exceed remaining: ${formatCurrency(remaining.toFixed(2))}.`;
|
||
return false;
|
||
}
|
||
} else {
|
||
settleAmountError.value = 'Error: No split selected.'; // Should not happen
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id || null);
|
||
|
||
const handleConfirmSettle = async () => {
|
||
if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) {
|
||
notificationStore.addNotification({ message: 'Cannot process settlement: missing data.', type: 'error' });
|
||
return;
|
||
}
|
||
// Use settleAmount.value which is the confirmed amount (remaining amount for MVP)
|
||
const activityData: SettlementActivityCreate = {
|
||
expense_split_id: selectedSplitForSettlement.value.id,
|
||
paid_by_user_id: Number(authStore.user.id), // Convert to number
|
||
amount_paid: new Decimal(settleAmount.value).toString(),
|
||
paid_at: new Date().toISOString(),
|
||
};
|
||
|
||
const success = await listDetailStore.settleExpenseSplit({
|
||
list_id_for_refetch: String(currentListIdForRefetch.value),
|
||
expense_split_id: selectedSplitForSettlement.value.id,
|
||
activity_data: activityData,
|
||
});
|
||
|
||
if (success) {
|
||
notificationStore.addNotification({ message: 'Share settled successfully!', type: 'success' });
|
||
closeSettleShareModal();
|
||
} else {
|
||
notificationStore.addNotification({ message: listDetailStore.error || 'Failed to settle share.', type: 'error' });
|
||
}
|
||
};
|
||
|
||
const handleExpenseCreated = (expense: any) => {
|
||
// Refresh the expenses list
|
||
if (list.value?.id) {
|
||
listDetailStore.fetchListWithExpenses(String(list.value.id));
|
||
}
|
||
};
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Existing styles */
|
||
|
||
.neo-expenses-section {
|
||
margin-top: 3rem;
|
||
padding: 1.5rem;
|
||
border: 3px solid #111;
|
||
border-radius: 18px;
|
||
background: #fdfdfd;
|
||
/* Slightly different background for distinction */
|
||
box-shadow: 6px 6px 0 #111;
|
||
}
|
||
|
||
.neo-expenses-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.neo-expenses-title {
|
||
font-size: 2rem;
|
||
font-weight: 900;
|
||
margin-bottom: 1.5rem;
|
||
text-align: center;
|
||
color: #111;
|
||
}
|
||
|
||
.neo-expense-card {
|
||
background: #fff;
|
||
border: 2px solid #111;
|
||
border-radius: 12px;
|
||
margin-bottom: 1.5rem;
|
||
padding: 1rem;
|
||
box-shadow: 4px 4px 0 #ddd;
|
||
}
|
||
|
||
.neo-expense-header {
|
||
font-size: 1.3rem;
|
||
font-weight: 700;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.neo-expense-details,
|
||
.neo-split-details {
|
||
font-size: 0.95rem;
|
||
color: #333;
|
||
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;
|
||
/* Tailwind's rounded-md */
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.status-unpaid {
|
||
background-color: #fee2e2;
|
||
color: #dc2626;
|
||
/* red-100, red-600 */
|
||
}
|
||
|
||
.status-partially_paid {
|
||
background-color: #ffedd5;
|
||
color: #f97316;
|
||
/* orange-100, orange-600 */
|
||
}
|
||
|
||
.status-paid {
|
||
background-color: #dcfce7;
|
||
color: #22c55e;
|
||
/* green-100, green-600 */
|
||
}
|
||
|
||
|
||
.neo-splits-list {
|
||
margin-top: 1rem;
|
||
padding-left: 1rem;
|
||
border-left: 2px solid #eee;
|
||
}
|
||
|
||
.neo-split-item {
|
||
padding: 0.5rem 0;
|
||
border-bottom: 1px dashed #f0f0f0;
|
||
}
|
||
|
||
.neo-split-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.neo-settlement-activities {
|
||
font-size: 0.8em;
|
||
color: #555;
|
||
padding-left: 1em;
|
||
list-style-type: disc;
|
||
/* Ensure bullets are shown */
|
||
margin-top: 0.5em;
|
||
}
|
||
|
||
.neo-settlement-activities li {
|
||
margin-top: 0.2em;
|
||
}
|
||
|
||
|
||
.neo-container {
|
||
padding: 1rem;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-padding {
|
||
padding: 1rem;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.mb-3 {
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.neo-loading-state,
|
||
.neo-error-state,
|
||
.neo-empty-state {
|
||
text-align: center;
|
||
padding: 3rem 1rem;
|
||
margin: 2rem 0;
|
||
border: 3px solid #111;
|
||
border-radius: 18px;
|
||
background: #fff;
|
||
box-shadow: 6px 6px 0 #111;
|
||
}
|
||
|
||
.neo-error-state {
|
||
border-color: #e74c3c;
|
||
}
|
||
|
||
.neo-list-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.neo-title {
|
||
font-size: 2.5rem;
|
||
font-weight: 900;
|
||
margin: 0;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.neo-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.neo-description {
|
||
font-size: 1.2rem;
|
||
margin-bottom: 2rem;
|
||
color: #555;
|
||
}
|
||
|
||
.neo-status {
|
||
font-weight: 900;
|
||
font-size: 1rem;
|
||
padding: 0.4rem 1rem;
|
||
border: 3px solid #111;
|
||
border-radius: 50px;
|
||
background: var(--light);
|
||
box-shadow: 3px 3px 0 #111;
|
||
}
|
||
|
||
.neo-status-active {
|
||
background: #f7f7d4;
|
||
}
|
||
|
||
.neo-status-complete {
|
||
background: #d4f7dd;
|
||
}
|
||
|
||
.neo-list-card {
|
||
break-inside: avoid;
|
||
border-radius: 18px;
|
||
box-shadow: 6px 6px 0 #111;
|
||
width: 100%;
|
||
margin: 0 0 2rem 0;
|
||
background: var(--light);
|
||
display: flex;
|
||
flex-direction: column;
|
||
cursor: pointer;
|
||
border: 3px solid #111;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.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: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.neo-list-item {
|
||
padding: 1.2rem;
|
||
margin-bottom: 0;
|
||
border-bottom: 1px solid #eee;
|
||
background: var(--light);
|
||
transition: background-color 0.1s ease-in-out;
|
||
}
|
||
|
||
.neo-list-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.neo-list-item:hover {
|
||
background-color: #f9f9f9;
|
||
}
|
||
|
||
.neo-item-content {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.neo-checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.7em;
|
||
cursor: pointer;
|
||
flex-grow: 1;
|
||
}
|
||
|
||
.neo-checkbox-label input[type="checkbox"] {
|
||
width: 1.2em;
|
||
height: 1.2em;
|
||
accent-color: #111;
|
||
border: 2px solid #111;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.item-name {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.neo-item-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.neo-icon-button {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0.5rem;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
|
||
.neo-icon-button:active {
|
||
transform: scale(0.98);
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.neo-edit-button {
|
||
color: #3498db;
|
||
}
|
||
|
||
.neo-edit-button:hover {
|
||
background: #eef7fd;
|
||
}
|
||
|
||
.neo-delete-button {
|
||
color: #e74c3c;
|
||
}
|
||
|
||
.neo-delete-button:hover {
|
||
background: #fee;
|
||
}
|
||
|
||
.neo-price-input {
|
||
margin-top: 0.5rem;
|
||
padding-left: 2.2em;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.neo-list-item {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.neo-checkbox-label input[type="checkbox"] {
|
||
width: 1.4em;
|
||
height: 1.4em;
|
||
}
|
||
|
||
.item-name {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.neo-icon-button {
|
||
padding: 0.6rem;
|
||
}
|
||
}
|
||
|
||
/* Add smooth transitions for all interactive elements - VComponents have their own */
|
||
/* .neo-action-button,
|
||
.neo-icon-button,
|
||
.neo-checkbox-label,
|
||
.neo-add-button {
|
||
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
|
||
.neo-action-button:active,
|
||
.neo-icon-button:active,
|
||
.neo-checkbox-label:active,
|
||
.neo-add-button:active {
|
||
transform: scale(0.98);
|
||
opacity: 0.9;
|
||
} */
|
||
|
||
/* Improve scrolling performance */
|
||
.item-list-tight {
|
||
/* Assuming VList with this class */
|
||
will-change: transform;
|
||
transform: translateZ(0);
|
||
}
|
||
|
||
/* Modal styles are now handled by VModal component */
|
||
/* .modal-backdrop { ... } */
|
||
/* .modal-container { ... } */
|
||
/* .modal-header { ... } */
|
||
/* .modal-body { ... } */
|
||
/* .modal-footer { ... } */
|
||
/* .close-button { ... } */
|
||
|
||
|
||
/* Item badge styles are now handled by VBadge */
|
||
/* .item-badge { ... } */
|
||
/* .badge-settled { ... } */
|
||
/* .badge-pending { ... } */
|
||
|
||
|
||
.text-right {
|
||
text-align: right;
|
||
}
|
||
|
||
.text-center {
|
||
text-align: center;
|
||
}
|
||
|
||
/* Spinner styles are now handled by VSpinner */
|
||
/* .spinner-dots { ... } */
|
||
/* .spinner-dots span { ... } */
|
||
/* .spinner-dots-sm { ... } */
|
||
/* .spinner-dots-sm span { ... } */
|
||
/* @keyframes dot-pulse { ... } */
|
||
|
||
/* Utility classes that might still be used or can be replaced by Tailwind/global equivalents */
|
||
.flex {
|
||
display: flex;
|
||
}
|
||
|
||
.items-center {
|
||
align-items: center;
|
||
}
|
||
|
||
.justify-between {
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.gap-1 {
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.gap-2 {
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.ml-1 {
|
||
margin-left: 0.25rem;
|
||
}
|
||
|
||
.ml-2 {
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.mt-1 {
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.mt-2 {
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
.mt-4 {
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.mb-2 {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.mb-3 {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
/* Adjusted from 1.5rem to match common spacing */
|
||
.mb-4 {
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.py-10 {
|
||
padding-top: 2.5rem;
|
||
padding-bottom: 2.5rem;
|
||
}
|
||
|
||
.py-4 {
|
||
padding-top: 1rem;
|
||
padding-bottom: 1rem;
|
||
}
|
||
|
||
.p-4 {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.border {
|
||
border-width: 1px;
|
||
/* Assuming default border color from global styles or Tailwind */
|
||
}
|
||
|
||
.rounded-lg {
|
||
border-radius: 0.5rem;
|
||
}
|
||
|
||
.shadow {
|
||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||
/* Example shadow */
|
||
}
|
||
|
||
.flex-grow {
|
||
flex-grow: 1;
|
||
}
|
||
|
||
.w-24 {
|
||
width: 6rem;
|
||
}
|
||
|
||
/* Tailwind w-24 */
|
||
.text-sm {
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.text-gray-500 {
|
||
color: #6b7280;
|
||
}
|
||
|
||
/* Tailwind gray-500 */
|
||
.text-gray-400 {
|
||
color: #9ca3af;
|
||
}
|
||
|
||
/* Tailwind gray-400 */
|
||
.text-green-600 {
|
||
color: #16a34a;
|
||
}
|
||
|
||
/* Tailwind green-600 */
|
||
.text-yellow-500 {
|
||
color: #eab308;
|
||
}
|
||
|
||
/* Tailwind yellow-500 */
|
||
.line-through {
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.opacity-50 {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.opacity-60 {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
/* Added for completed item name */
|
||
.opacity-70 {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* Added for completed item background */
|
||
.shrink-0 {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bg-gray-100 {
|
||
background-color: #f3f4f6;
|
||
}
|
||
|
||
/* Tailwind gray-100 */
|
||
|
||
/* Styles for .neo-list-card, .neo-item-list, .neo-item might be replaced by VCard/VList/VListItem defaults or props */
|
||
/* Keeping some specific styles for .neo-item-details, .item-name, etc. if they are distinct. */
|
||
.item-with-actions {
|
||
/* Custom class for VListItem if needed for specific layout */
|
||
/* Default VListItem is display:flex, so this might not be needed or just for minor tweaks */
|
||
}
|
||
</style>
|