![google-labs-jules[bot]](/assets/img/avatar_default.png)
This commit completes the internationalization (i18n) for several key pages within the frontend application. The following pages have been updated to support multiple languages: - AccountPage.vue - SignupPage.vue - ListDetailPage.vue (including items, OCR, expenses, and cost summary) - MyChoresPage.vue - PersonalChoresPage.vue - IndexPage.vue Key changes include: - Extraction of all user-facing strings from these Vue components. - Addition of new translation keys and their English values to `fe/src/i18n/en.json`. - Modification of the Vue components to use the Vue I18n plugin's `$t()` (template) and `t()` (script) functions for displaying translated strings. - Dynamic messages, notifications, and form validation messages are now also internationalized. - The language files `de.json`, `es.json`, and `fr.json` have been updated with the new keys, using the English text as placeholders for future translation. This effort significantly expands the i18n coverage of the application, making it more accessible to a wider audience.
1620 lines
53 KiB
Vue
1620 lines
53 KiB
Vue
<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">
|
||
<!-- 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">{{ $t('listDetailPage.buttons.costSummary') }}
|
||
</VButton>
|
||
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">{{ $t('listDetailPage.buttons.addViaOcr') }}</VButton>
|
||
<VBadge :text="list.group_id ? $t('listDetailPage.badges.groupList') : $t('listDetailPage.badges.personalList')" :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="$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 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="$t('listDetailPage.items.editItemAriaLabel')">
|
||
<VIcon name="edit" />
|
||
</button>
|
||
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
|
||
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
|
||
<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="$t('listDetailPage.items.pricePlaceholder')" 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="$t('listDetailPage.items.addItemForm.itemNameSrLabel')" :label-sr-only="true">
|
||
<VInput v-model="newItem.name" :placeholder="$t('listDetailPage.items.addItemForm.placeholder')" required ref="itemNameInputRef" />
|
||
</VFormField>
|
||
<VFormField :label="$t('listDetailPage.items.addItemForm.quantitySrLabel')" :label-sr-only="true" class="w-24 shrink-0">
|
||
<VInput type="number" :model-value="newItem.quantity || ''" @update:modelValue="newItem.quantity = $event"
|
||
:placeholder="$t('listDetailPage.items.addItemForm.quantityPlaceholder')" min="1" />
|
||
</VFormField>
|
||
<VButton type="submit" :disabled="addingItem" class="shrink-0">
|
||
<VSpinner v-if="addingItem" size="sm" />
|
||
<span v-else>{{ $t('listDetailPage.buttons.addItem') }}</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">{{ $t('listDetailPage.expensesSection.title') }}</h2>
|
||
<button class="neo-action-button" @click="showCreateExpenseForm = true">
|
||
<svg class="icon">
|
||
<use xlink:href="#icon-plus" />
|
||
</svg>
|
||
{{ $t('listDetailPage.expensesSection.addExpenseButton') }}
|
||
</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>{{ $t('listDetailPage.expensesSection.loading') }}</p>
|
||
</div>
|
||
<div v-else-if="listDetailStore.error" class="neo-error-state">
|
||
<p>{{ listDetailStore.error }}</p> <!-- Assuming listDetailStore.error is a backend message or already translated if generic -->
|
||
<button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">{{ $t('listDetailPage.expensesSection.retryButton') }}</button>
|
||
</div>
|
||
<div v-else-if="!expenses || expenses.length === 0" class="neo-empty-state">
|
||
<p>{{ $t('listDetailPage.expensesSection.emptyState') }}</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">
|
||
{{ $t('listDetailPage.expensesSection.paidBy') }} <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID:
|
||
${expense.paid_by_user_id}` }}</strong>
|
||
{{ $t('listDetailPage.expensesSection.onDate') }} {{ 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> {{ $t('listDetailPage.expensesSection.owes') }} {{
|
||
formatCurrency(split.owed_amount) }}
|
||
<span class="neo-expense-status" :class="getStatusClass(split.status)">
|
||
{{ getSplitStatusText(split.status) }}
|
||
</span>
|
||
</div>
|
||
<div class="neo-split-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>
|
||
<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">
|
||
{{ $t('listDetailPage.expensesSection.settleShareButton') }}
|
||
</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">
|
||
{{ $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>
|
||
</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="$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>
|
||
|
||
<!-- Confirmation Dialog -->
|
||
<VModal v-model="showConfirmDialogState" :title="$t('listDetailPage.modals.confirmation.title')" @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">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
|
||
<VButton variant="primary" @click="handleConfirmedAction">{{ $t('listDetailPage.buttons.confirm') }}</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>
|
||
|
||
<!-- Edit Item Dialog -->
|
||
<VModal v-model="showEditDialog" :title="$t('listDetailPage.modals.editItem.title')" @update:modelValue="!$event && closeEditDialog()">
|
||
<template #default>
|
||
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.nameLabel')" class="mb-4">
|
||
<VInput type="text" id="editItemName" v-model="editingItem.name" required />
|
||
</VFormField>
|
||
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.quantityLabel')">
|
||
<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">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
|
||
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">{{ $t('listDetailPage.buttons.saveChanges') }}
|
||
</VButton>
|
||
</template>
|
||
</VModal>
|
||
|
||
<VAlert v-if="!list && !pageInitialLoad" type="info" :message="$t('listDetailPage.errors.genericLoadFailure')" />
|
||
</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'; // 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
|
||
|
||
const { t } = useI18n();
|
||
|
||
// 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.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 apiErrorMessage = err instanceof Error ? err.message : String(err);
|
||
const fallbackErrorMessage = t('listDetailPage.errors.fetchFailed');
|
||
if (!list.value) {
|
||
error.value = apiErrorMessage || fallbackErrorMessage;
|
||
} else {
|
||
notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage: apiErrorMessage }), 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 onAddItem = async () => {
|
||
if (!list.value || !newItem.value.name.trim()) {
|
||
notificationStore.addNotification({ message: t('listDetailPage.notifications.enterItemName'), type: 'warning' });
|
||
if (itemNameInputRef.value?.$el) {
|
||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||
}
|
||
return;
|
||
}
|
||
addingItem.value = true;
|
||
|
||
if (!isOnline.value) {
|
||
const offlinePayload: any = { // Define explicit type later if needed
|
||
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(), // Temporary ID for offline
|
||
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, // Assuming initial version
|
||
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;
|
||
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); // Optimistic UI
|
||
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();
|
||
}
|
||
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
|
||
} catch (err) {
|
||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('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;
|
||
|
||
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
|
||
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' });
|
||
} catch (err) {
|
||
item.is_complete = originalCompleteStatus; // Revert optimistic update
|
||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('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: (err instanceof Error ? err.message : String(err)) || t('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: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.deleteItemFailed'), type: 'error' });
|
||
} finally {
|
||
item.deleting = false;
|
||
}
|
||
};
|
||
|
||
const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => {
|
||
confirmDialogMessage.value = t('listDetailPage.confirmations.updateMessage', {
|
||
itemName: item.name,
|
||
status: newCompleteStatus ? t('listDetailPage.confirmations.statusComplete') : t('listDetailPage.confirmations.statusIncomplete')
|
||
});
|
||
pendingAction.value = () => updateItem(item, newCompleteStatus);
|
||
showConfirmDialogState.value = true;
|
||
};
|
||
|
||
const confirmDeleteItem = (item: ItemWithUI) => {
|
||
confirmDialogMessage.value = t('listDetailPage.confirmations.deleteMessage', { itemName: item.name });
|
||
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;
|
||
confirmDialogMessage.value = ''; // Clear message
|
||
};
|
||
|
||
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 = (err instanceof Error ? err.message : String(err)) || t('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: (err instanceof Error ? err.message : String(err)) || t('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 = (err instanceof Error ? err.message : String(err)) || t('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 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 || showConfirmDialogState.value || showEditDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
if (itemNameInputRef.value?.$el) { // Focus the add item input
|
||
(itemNameInputRef.value.$el as HTMLElement).focus();
|
||
}
|
||
}
|
||
});
|
||
|
||
let touchStartX = 0;
|
||
const SWIPE_THRESHOLD = 50; // Pixels
|
||
|
||
const handleTouchStart = (event: TouchEvent) => {
|
||
touchStartX = event.changedTouches[0].clientX;
|
||
};
|
||
|
||
const handleTouchMove = (event: TouchEvent, item: ItemWithUI) => {
|
||
// This function might be used for swipe-to-reveal actions in the future
|
||
// For now, it's a placeholder or can be removed if not used.
|
||
};
|
||
|
||
const handleTouchEnd = (event: TouchEvent, item: ItemWithUI) => {
|
||
// This function might be used for swipe-to-reveal actions in the future
|
||
// For now, it's a placeholder or can be removed if not used.
|
||
};
|
||
|
||
|
||
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 editItem = (item: Item) => {
|
||
editingItem.value = { ...item }; // Clone item for editing
|
||
showEditDialog.value = true;
|
||
};
|
||
|
||
const closeEditDialog = () => {
|
||
showEditDialog.value = false;
|
||
editingItem.value = null;
|
||
};
|
||
|
||
const handleConfirmEdit = async () => {
|
||
if (!editingItem.value || !list.value) return;
|
||
|
||
const itemToUpdate = editingItem.value; // Already a clone
|
||
|
||
try {
|
||
const response = await apiClient.put(
|
||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(itemToUpdate.id)),
|
||
{
|
||
name: itemToUpdate.name,
|
||
quantity: itemToUpdate.quantity?.toString(), // Ensure quantity is string or null
|
||
version: itemToUpdate.version
|
||
}
|
||
);
|
||
|
||
const updatedItemFromApi = response.data as Item;
|
||
const index = list.value.items.findIndex(i => i.id === updatedItemFromApi.id);
|
||
if (index !== -1) {
|
||
list.value.items[index] = processListItems([updatedItemFromApi])[0];
|
||
}
|
||
|
||
notificationStore.addNotification({
|
||
message: 'Item updated successfully',
|
||
type: 'success'
|
||
});
|
||
closeEditDialog();
|
||
} catch (err) {
|
||
notificationStore.addNotification({
|
||
message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'),
|
||
type: 'error'
|
||
});
|
||
}
|
||
};
|
||
|
||
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));
|
||
}
|
||
};
|
||
|
||
</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>
|