mitlist/fe/src/pages/ListDetailPage.vue

2023 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<main class="neo-container page-padding">
<div v-if="pageInitialLoad && !list && !error" class="text-center py-10">
<VSpinner :label="$t('listDetailPage.loading.list')" size="lg" />
</div>
<VAlert v-else-if="error && !list" type="error" :message="error" class="mb-4">
<template #actions>
<VButton @click="fetchListDetails">{{ $t('listDetailPage.retryButton') }}</VButton>
</template>
</VAlert>
<template v-else-if="list">
<!-- Items List Section -->
<VCard v-if="itemsAreLoading" class="py-10 text-center mt-4">
<VSpinner :label="$t('listDetailPage.loading.items')" size="lg" />
</VCard>
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard"
:empty-title="$t('listDetailPage.items.emptyState.title')"
:empty-message="$t('listDetailPage.items.emptyState.message')" class="mt-4" />
<div v-else class="neo-item-list-container">
<!-- Integrated Header -->
<div class="neo-list-card-header">
<div class="neo-list-header-main">
<div class="neo-list-title-group">
<VHeading :level="1" :text="list.name" class="neo-title" />
<div class="item-badge ml-2" :class="list.group_id ? 'accent' : 'settled'">
{{ list.group_id ? $t('listDetailPage.badges.groupList', { groupName: getGroupName(list.group_id) }) :
$t('listDetailPage.badges.personalList') }}
</div>
</div>
<div class="neo-header-actions">
<button class="btn btn-sm btn-primary" @click="showCostSummaryDialog = true" :disabled="!isOnline"
icon-left="clipboard" size="sm">{{
$t('listDetailPage.buttons.costSummary') }}
</button>
<button class="btn btn-sm btn-primary" @click="openOcrDialog" :disabled="!isOnline" icon-left="plus"
size="sm">{{
$t('listDetailPage.buttons.addViaOcr') }}
</button>
<button class="btn btn-sm btn-primary" @click="showCreateExpenseForm = true" :disabled="!isOnline"
icon-left="plus" size="sm">
{{ $t('listDetailPage.expensesSection.addExpenseButton') }}
</button>
</div>
</div>
<p v-if="list.description" class="neo-description-internal">{{ list.description }}</p>
</div>
<!-- End Integrated Header -->
<draggable v-model="list.items" item-key="id" handle=".drag-handle" @end="handleDragEnd" :disabled="!isOnline"
class="neo-item-list">
<template #item="{ element: item }">
<li class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete, 'item-pending-sync': isItemPendingSync(item) }">
<div class="neo-item-content">
<!-- Drag Handle -->
<div class="drag-handle" v-if="isOnline">
<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="handleCheckboxChange(item, $event)" />
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">
{{ item.name }}
</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''"
@update:modelValue="item.priceInput = $event"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24" step="0.01"
@blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="startItemEdit(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="deleteItem(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="item.editName = $event"
required class="flex-grow" size="sm" @keydown.enter.prevent="saveItemEdit(item)"
@keydown.esc.prevent="cancelItemEdit(item)" />
<VInput type="number" :model-value="item.editQuantity || ''"
@update:modelValue="item.editQuantity = $event" min="1" class="w-20" size="sm"
@keydown.enter.prevent="saveItemEdit(item)" @keydown.esc.prevent="cancelItemEdit(item)" />
</div>
<div class="neo-item-actions">
<button class="neo-icon-button neo-save-button" @click.stop="saveItemEdit(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="cancelItemEdit(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="deleteItem(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>
</draggable>
<!-- New Add Item LI, integrated into the list -->
<li class="neo-list-item new-item-input-container">
<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"
:data-list-id="list?.id" @keyup.enter="onAddItem" @blur="handleNewItemBlur" v-model="newItem.name"
@click.stop />
</label>
</li>
<!-- Expenses Section -->
<section v-if="list && !itemsAreLoading" class="neo-expenses-section">
<VCard v-if="listDetailStore.isLoading && expenses.length === 0" class="py-10 text-center">
<VSpinner :label="$t('listDetailPage.expensesSection.loading')" size="lg" />
</VCard>
<VAlert v-else-if="listDetailStore.error && expenses.length === 0" type="error" class="mt-4">
<p>{{ listDetailStore.error }}</p>
<template #actions>
<VButton @click="listDetailStore.fetchListWithExpenses(String(list?.id))">
{{ $t('listDetailPage.expensesSection.retryButton') }}
</VButton>
</template>
</VAlert>
<VCard v-else-if="(!expenses || expenses.length === 0) && !listDetailStore.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 === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
class="btn btn-sm btn-primary" @click="openSettleShareModal(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>
</div>
<!-- Create Expense Form -->
<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="$t('listDetailPage.modals.ocr.title')"
@update:modelValue="!$event && closeOcrDialog()">
<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="closeOcrDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="addOcrItems"
:disabled="addingOcrItems">
<VSpinner v-if="addingOcrItems" size="sm" :label="$t('listDetailPage.loading.addingOcrItems')" /> {{
$t('listDetailPage.buttons.addItems') }}
</VButton>
</template>
</VModal>
<!-- Cost Summary Dialog -->
<VModal v-model="showCostSummaryDialog" :title="$t('listDetailPage.modals.costSummary.title')"
@update:modelValue="showCostSummaryDialog = false" size="lg">
<template #default>
<div v-if="costSummaryLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.costSummary')" />
</div>
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{
formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{
formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{
listCostSummary.num_participating_users }}</p>
</div>
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.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>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}
</VButton>
</template>
</VModal>
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" :title="$t('listDetailPage.settleShareModal.title')"
@update:modelValue="!$event && closeSettleShareModal()" size="md">
<template #default>
<div v-if="isSettlementLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.settlement')" />
</div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else>
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', {
userName: selectedSplitForSettlement?.user?.name
|| selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}`
}) }}</p>
<VFormField :label="$t('listDetailPage.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('listDetailPage.settleShareModal.cancelButton')
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
}}</VButton>
</template>
</VModal>
<VAlert v-if="!list && !pageInitialLoad" type="info" :message="$t('listDetailPage.errors.genericLoadFailure')" />
</template>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
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';
import draggable from 'vuedraggable';
const { t } = useI18n();
// Helper to extract user-friendly error messages from API responses
const getApiErrorMessage = (err: unknown, fallbackMessageKey: 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') {
// Construct a message like "Field 'fieldname': error message"
// const field = firstError.loc && firstError.loc.length > 1 ? firstError.loc[1] : 'Input';
// return `${field}: ${firstError.msg}`;
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);
};
// UI-specific properties that we add to items
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
isEditing?: boolean; // For inline editing state
editName?: string; // Temporary name for inline editing
editQuantity?: number | string | null; // Temporary quantity for inline editing
showFirework?: boolean; // For firework animation
}
interface List {
id: number;
name: string;
description?: string;
is_complete: boolean;
items: ItemWithUI[];
version: number;
updated_at: string;
group_id?: number;
}
interface Group {
id: number;
name: string;
}
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 | string }>({ name: '' });
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
// 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,
});
// 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 - Refs for modal edit removed
// const showEditDialog = ref(false);
// const editingItem = ref<Item | null>(null);
// onClickOutside for ocrModalRef, costSummaryModalRef, etc. are removed as VModal handles this.
// Define a more specific type for the offline item payload
interface OfflineCreateItemPayload {
name: string;
quantity?: string | number; // Align with the target type from the linter error
}
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,
showFirework: false // Initialize firework state
}));
};
const fetchListDetails = async () => {
if (pageInitialLoad.value) {
pageInitialLoad.value = false;
}
itemsAreLoading.value = true;
const routeId = String(route.params.id);
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
try {
let response;
if (cachedFullData) {
response = { data: JSON.parse(cachedFullData) };
sessionStorage.removeItem(`listDetailFull_${routeId}`);
} else {
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
}
const rawList = response.data as ListWithExpenses;
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 = getApiErrorMessage(err, 'listDetailPage.errors.fetchFailed');
if (!list.value) {
error.value = errorMessage;
} else {
notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage }), type: 'error' });
}
} finally {
itemsAreLoading.value = 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 handleNewItemBlur = (event: Event) => {
const inputElement = event.target as HTMLInputElement;
if (inputElement.value.trim()) {
newItem.value.name = inputElement.value.trim();
onAddItem();
}
};
const onAddItem = async () => {
const itemName = newItem.value.name.trim();
if (!list.value || !itemName) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.enterItemName'), type: 'warning' });
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
return;
}
addingItem.value = true;
// Create optimistic item
const optimisticItem: ItemWithUI = {
id: Date.now(), // Temporary ID
name: itemName,
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
};
// Add item optimistically to the list
list.value.items.push(optimisticItem);
// Clear input immediately for better UX
newItem.value.name = '';
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
if (!isOnline.value) {
const offlinePayload: OfflineCreateItemPayload = {
name: itemName
};
const rawQuantity = newItem.value.quantity;
if (rawQuantity !== undefined && String(rawQuantity).trim() !== '') {
const numAttempt = Number(rawQuantity);
if (!isNaN(numAttempt)) {
offlinePayload.quantity = numAttempt;
} else {
offlinePayload.quantity = String(rawQuantity);
}
}
offlineStore.addAction({
type: 'create_list_item',
payload: {
listId: String(list.value.id),
itemData: offlinePayload
}
});
addingItem.value = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
return;
}
try {
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{
name: itemName,
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null
}
);
const addedItem = response.data as Item;
// Replace optimistic item with real item from server
const index = list.value.items.findIndex(i => i.id === optimisticItem.id);
if (index !== -1) {
list.value.items[index] = processListItems([addedItem])[0];
}
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
} catch (err) {
// Remove optimistic item on error
list.value.items = list.value.items.filter(i => i.id !== optimisticItem.id);
notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.addItemFailed'),
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;
const triggerFirework = () => {
if (newCompleteStatus && !originalCompleteStatus) {
item.showFirework = true;
setTimeout(() => {
// Check if item still exists and is part of the current list before resetting
if (list.value && list.value.items.find(i => i.id === item.id)) {
item.showFirework = false;
}
}, 700); // Duration of firework animation (must match CSS)
}
};
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;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
triggerFirework(); // Trigger firework for offline success
return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
triggerFirework(); // Trigger firework for online success
} catch (err) {
item.is_complete = originalCompleteStatus; // Revert optimistic update
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), 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; // No change
item.updating = true;
const originalPrice = item.price;
const originalPriceInput = item.priceInput;
item.price = newPrice?.toString() || null; // Optimistic update
if (!isOnline.value) {
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice ?? null, // Ensure null is sent if cleared
completed: item.is_complete // Keep completion status
},
version: item.version
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
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++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.price = originalPrice; // Revert optimistic update
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemPriceFailed'), type: 'error' });
} finally {
item.updating = false;
}
};
const deleteItem = async (item: ItemWithUI) => {
if (!list.value) return;
item.deleting = true;
const originalItems = [...list.value.items]; // For potential revert
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); // Optimistic UI
item.deleting = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
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);
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
} catch (err) {
list.value.items = originalItems; // Revert optimistic UI
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.deleteItemFailed'), type: 'error' });
} finally {
item.deleting = false;
}
};
const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => {
updateItem(item, newCompleteStatus);
};
const confirmDeleteItem = (item: ItemWithUI) => {
deleteItem(item);
};
const openOcrDialog = () => {
ocrItems.value = [];
ocrError.value = null;
resetOcrFileDialog(); // From useFileDialog
showOcrDialogState.value = true;
nextTick(() => {
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) { // 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 = t('listDetailPage.errors.ocrNoItems');
}
} catch (err) {
ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed');
} finally {
ocrLoading.value = false;
// Reset file input
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) { // Native input
(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" } // Default quantity 1
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
successCount++;
}
if (successCount > 0) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemsAddedSuccessOcr', { count: successCount }), type: 'success' });
}
closeOcrDialog();
} catch (err) {
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.addOcrItemsFailed'), 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 = getApiErrorMessage(err, 'listDetailPage.errors.loadCostSummaryFailed');
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 allFetchedGroups = ref<Group[]>([]);
const getGroupName = (groupId: number): string => {
const group = allFetchedGroups.value.find((g: Group) => g.id === groupId);
return group?.name || `Group ${groupId}`;
};
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 '';
};
// 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; // Don't interfere with typing
}
// Check if any modal is open, if so, don't trigger
if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
return;
}
event.preventDefault();
if (itemNameInputRef.value?.$el) { // Focus the add item input
(itemNameInputRef.value.$el as HTMLElement).focus();
}
}
});
onMounted(() => {
pageInitialLoad.value = true;
itemsAreLoading.value = false;
error.value = null;
if (!route.params.id) {
error.value = t('listDetailPage.errors.fetchFailed'); // Generic error if no ID
pageInitialLoad.value = false;
listDetailStore.setError(t('listDetailPage.errors.fetchFailed'));
return;
}
const listShellJSON = sessionStorage.getItem('listDetailShell');
const routeId = String(route.params.id);
if (listShellJSON) {
const shellData = JSON.parse(listShellJSON);
if (shellData.id === parseInt(routeId, 10)) {
list.value = {
id: shellData.id,
name: shellData.name,
description: shellData.description,
is_complete: false,
items: [],
version: 0,
updated_at: new Date().toISOString(),
group_id: shellData.group_id,
};
pageInitialLoad.value = false;
} else {
sessionStorage.removeItem('listDetailShell');
}
}
fetchListDetails().then(() => {
startPolling();
});
const routeParamsId = route.params.id;
listDetailStore.fetchListWithExpenses(String(routeParamsId));
});
onUnmounted(() => {
stopPolling();
});
const startItemEdit = (item: ItemWithUI) => {
// Ensure other items are not in edit mode (optional, but good for UX)
list.value?.items.forEach(i => { if (i.id !== item.id) i.isEditing = false; });
item.isEditing = true;
item.editName = item.name;
item.editQuantity = item.quantity ?? ''; // Use empty string for VInput if null/undefined
};
const cancelItemEdit = (item: ItemWithUI) => {
item.isEditing = false;
// editName and editQuantity are transient, no need to reset them to anything,
// as they are re-initialized in startItemEdit.
};
const saveItemEdit = async (item: ItemWithUI) => {
if (!list.value || !item.editName || String(item.editName).trim() === '') {
notificationStore.addNotification({
message: t('listDetailPage.notifications.enterItemName'), // Re-use existing translation
type: 'warning'
});
return;
}
const payload = {
name: String(item.editName).trim(),
quantity: item.editQuantity ? String(item.editQuantity) : null,
version: item.version,
// Ensure completed status is preserved if it's part of the update endpoint implicitly or explicitly
// If your API updates 'completed' status too, you might need to send item.is_complete
// For now, assuming API endpoint for item update only takes name, quantity, version.
};
item.updating = true; // Use existing flag for visual feedback
try {
const response = await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
payload
);
const updatedItemFromApi = response.data as Item;
// Update the original item with new data from API
item.name = updatedItemFromApi.name;
item.quantity = updatedItemFromApi.quantity;
item.version = updatedItemFromApi.version;
item.is_complete = updatedItemFromApi.is_complete; // Ensure this is updated if API returns it
item.price = updatedItemFromApi.price; // And price
item.updated_at = updatedItemFromApi.updated_at;
item.isEditing = false; // Exit edit mode
notificationStore.addNotification({
message: t('listDetailPage.notifications.itemUpdatedSuccess'), // Re-use
type: 'success'
});
} catch (err) {
notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), // Re-use
type: 'error'
});
// Optionally, keep item.isEditing = true so user can correct or cancel
} finally {
item.updating = false;
}
};
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
if (split.user_id !== authStore.user?.id) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.cannotSettleOthersShares'), 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 = t('listDetailPage.settleShareModal.errors.enterAmount');
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
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')))) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false;
}
} else {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
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: t('listDetailPage.notifications.settlementDataMissing'), type: 'error' });
return;
}
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(),
};
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: t('listDetailPage.notifications.settleShareSuccess'), type: 'success' });
closeSettleShareModal();
} else {
notificationStore.addNotification({ message: listDetailStore.error || t('listDetailPage.notifications.settleShareFailed'), type: 'error' });
}
};
const handleExpenseCreated = (expense: any) => {
if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(list.value.id));
}
};
const handleCheckboxChange = (item: ItemWithUI, event: Event) => {
const target = event.target as HTMLInputElement;
if (target) {
updateItem(item, target.checked);
}
};
const handleDragEnd = async (evt: any) => {
if (!list.value || evt.oldIndex === evt.newIndex) return;
const originalList = [...list.value.items]; // Store original order
const item = list.value.items[evt.newIndex];
const newPosition = evt.newIndex + 1; // Assuming backend uses 1-based indexing for position
try {
// The v-model on draggable has already updated the list.value.items order optimistically.
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ position: newPosition, version: item.version }
);
// On success, we need to update the version of the moved item
const updatedItemInList = list.value.items.find(i => i.id === item.id);
if (updatedItemInList) {
updatedItemInList.version++;
}
notificationStore.addNotification({
message: t('listDetailPage.notifications.itemReorderedSuccess'),
type: 'success'
});
} catch (err) {
// Revert the order on error
list.value.items = originalList;
notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'),
type: 'error'
});
}
};
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 {
// Optional: collapse others when one is opened
// newSet.clear();
newSet.add(expenseId);
}
expandedExpenses.value = newSet;
};
const isExpenseExpanded = (expenseId: number) => {
return expandedExpenses.value.has(expenseId);
};
</script>
<style scoped>
/* Existing styles */
.neo-expenses-section {
padding: 0;
margin-top: 1.2rem;
}
.neo-expense-list {
background-color: rgb(255, 248, 240);
/* Container for expense items */
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;
}
.neo-container {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.page-padding {
padding-inline: 0;
padding-block-start: 1rem;
padding-block-end: 5rem;
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;
}
.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;
flex-wrap: wrap;
}
.neo-description-internal {
font-size: 1.2rem;
color: #555;
margin-top: 0.75rem;
margin-bottom: 1rem;
}
.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-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-list-card-header {
padding: 1rem 1.2rem;
border-bottom: 1px solid #eee;
}
.neo-list-header-main {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.neo-list-title-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.neo-item-list {
list-style: none;
padding: 1.2rem;
padding-inline: 0;
margin-bottom: 0;
border-bottom: 1px solid #eee;
background: var(--light);
}
.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 {}
.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;
-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: 6px;
top: 2px;
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;
}
/* 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);
}
/* Firework particle container */
.checkbox-text-span::after {
content: '';
position: absolute;
width: 6px;
height: 6px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
}
/* Selector fixed to target span correctly */
.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-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::after {
animation: firework-refined 0.7s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.2s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
/* Static strikethrough for items loaded as complete */
.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;
}
@keyframes firework-refined {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(0.5);
box-shadow: 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(2);
box-shadow: 0 -20px 0 0 var(--accent), 20px 0px 0 0 var(--accent), 0 20px 0 0 var(--accent), -20px 0px 0 0 var(--accent), 14px -14px 0 0 var(--accent), 14px 14px 0 0 var(--accent), -14px 14px 0 0 var(--accent), -14px -14px 0 0 var(--accent);
}
}
/* Update price input styling */
.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;
}
.neo-price-input input {
border: 1px dashed #ccc;
border-radius: 4px;
padding: 0.2rem 0.4rem;
font-size: 0.9rem;
color: #666;
background: transparent;
transition: all 0.2s ease;
width: 70px;
}
.neo-price-input input:focus {
border-color: var(--secondary);
outline: none;
background: var(--light);
}
/* New item input styling */
.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;
}
.neo-new-item-input:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: transparent;
}
/* Add item appear animation */
@keyframes item-appear {
0% {
opacity: 0;
transform: translateY(-15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.item-appear {
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.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;
}
.inline-edit-form .VInput_root {
margin-bottom: 0;
}
@media (max-width: 600px) {
.neo-price-input input {
width: 60px;
}
}
.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;
}
/* Update neo-item-content to accommodate drag handle */
.neo-item-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 0.5rem;
}
/* Add styles for dragging state */
.sortable-ghost {
opacity: 0.5;
background: #f0f0f0;
}
.sortable-drag {
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>