2023 lines
67 KiB
Vue
2023 lines
67 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">
|
||
<!-- 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) }} —
|
||
{{ $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>
|