
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
2088 lines
68 KiB
Vue
2088 lines
68 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 class="supermarkt-mode-toggle">
|
||
<label>
|
||
Supermarkt Mode
|
||
<VToggleSwitch v-model="supermarktMode" />
|
||
</label>
|
||
</div>
|
||
<VProgressBar v-if="supermarktMode" :value="itemCompletionProgress" class="mt-4" />
|
||
</div>
|
||
<!-- End Integrated Header -->
|
||
|
||
<div v-for="group in groupedItems" :key="group.categoryName" class="category-group"
|
||
:class="{ 'highlight': supermarktMode && group.items.some(i => i.is_complete) }">
|
||
<h3 v-if="group.items.length" class="category-header">{{ group.categoryName }}</h3>
|
||
<draggable v-model="group.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)" />
|
||
<VSelect :model-value="item.editCategoryId" @update:modelValue="item.editCategoryId = $event"
|
||
:options="categoryOptions" placeholder="Category" class="w-40" size="sm" />
|
||
</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>
|
||
</div>
|
||
|
||
<!-- 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 />
|
||
<VSelect v-model="newItem.category_id" :options="categoryOptions" placeholder="Category" class="w-40"
|
||
size="sm" />
|
||
</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.modals.costSummary.totalCostLabel') }}</strong> {{
|
||
formatCurrency(listCostSummary.total_list_cost) }}</p>
|
||
<p><strong>{{ $t('listDetailPage.modals.costSummary.equalShareLabel') }}</strong> {{
|
||
formatCurrency(listCostSummary.equal_share_per_user) }}</p>
|
||
<p><strong>{{ $t('listDetailPage.modals.costSummary.participantsLabel') }}</strong> {{
|
||
listCostSummary.num_participating_users }}</p>
|
||
</div>
|
||
<h4>{{ $t('listDetailPage.modals.costSummary.userBalancesHeader') }}</h4>
|
||
<div class="table-container mt-2">
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>{{ $t('listDetailPage.modals.costSummary.tableHeaders.user') }}</th>
|
||
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}
|
||
</th>
|
||
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue') }}</th>
|
||
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="userShare in 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.modals.costSummary.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.modals.settleShare.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.modals.settleShare.settleAmountFor', {
|
||
userName: selectedSplitForSettlement?.user?.name
|
||
|| selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}`
|
||
}) }}</p>
|
||
<VFormField :label="$t('listDetailPage.modals.settleShare.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.modals.settleShare.cancelButton')
|
||
}}</VButton>
|
||
<VButton variant="primary" @click="handleConfirmSettle">{{
|
||
$t('listDetailPage.modals.settleShare.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, useRouter } from 'vue-router';
|
||
import { useI18n } from 'vue-i18n';
|
||
import { apiClient, API_ENDPOINTS } from '@/services/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 VProgressBar from '@/components/valerie/VProgressBar.vue';
|
||
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
|
||
import draggable from 'vuedraggable';
|
||
import { useCategoryStore } from '@/stores/categoryStore';
|
||
import { storeToRefs } from 'pinia';
|
||
import ExpenseCard from '@/components/ExpenseCard.vue';
|
||
|
||
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
|
||
editCategoryId?: number | null; // Temporary category for inline editing
|
||
showFirework?: boolean; // For firework animation
|
||
}
|
||
|
||
interface ListStatus {
|
||
updated_at: string;
|
||
item_count: number;
|
||
}
|
||
|
||
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 lastItemCount = ref<number | null>(null);
|
||
|
||
const supermarktMode = ref(false);
|
||
|
||
const categoryStore = useCategoryStore();
|
||
const { categories } = storeToRefs(categoryStore);
|
||
|
||
const newItem = ref<{ name: string; quantity?: number | string; category_id?: number | null }>({ name: '', category_id: null });
|
||
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null);
|
||
|
||
const categoryOptions = computed(() => {
|
||
return [
|
||
{ label: 'No Category', value: null },
|
||
...categories.value.map(c => ({ label: c.name, value: c.id })),
|
||
];
|
||
});
|
||
|
||
// 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);
|
||
|
||
const itemCompletionProgress = computed(() => {
|
||
if (!list.value?.items.length) return 0;
|
||
const completedCount = list.value.items.filter(i => i.is_complete).length;
|
||
return (completedCount / list.value.items.length) * 100;
|
||
});
|
||
|
||
// 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;
|
||
lastItemCount.value = rawList.items.length;
|
||
|
||
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.STATUS(String(list.value.id)));
|
||
const { updated_at: newListUpdatedAt, item_count: newItemCount } = response.data as ListStatus;
|
||
|
||
if (
|
||
(lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
|
||
(lastItemCount.value !== null && newItemCount !== lastItemCount.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;
|
||
|
||
const optimisticItem: ItemWithUI = {
|
||
id: Date.now(),
|
||
name: itemName,
|
||
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
|
||
is_complete: false,
|
||
price: null,
|
||
version: 1,
|
||
category_id: newItem.value.category_id,
|
||
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 = '';
|
||
newItem.value.category_id = null;
|
||
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);
|
||
}
|
||
}
|
||
if (newItem.value.category_id) {
|
||
offlinePayload.category_id = newItem.value.category_id;
|
||
}
|
||
|
||
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,
|
||
category_id: newItem.value.category_id,
|
||
}
|
||
);
|
||
|
||
const addedItem = response.data as Item;
|
||
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) {
|
||
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(() => {
|
||
if (list.value && list.value.items.find(i => i.id === item.id)) {
|
||
item.showFirework = false;
|
||
}
|
||
}, 700);
|
||
}
|
||
};
|
||
|
||
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' });
|
||
triggerFirework();
|
||
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();
|
||
} catch (err) {
|
||
item.is_complete = originalCompleteStatus;
|
||
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;
|
||
item.updating = true;
|
||
const originalPrice = item.price;
|
||
const originalPriceInput = item.priceInput;
|
||
item.price = newPrice?.toString() || null;
|
||
|
||
if (!isOnline.value) {
|
||
offlineStore.addAction({
|
||
type: 'update_list_item',
|
||
payload: {
|
||
listId: String(list.value.id),
|
||
itemId: String(item.id),
|
||
data: {
|
||
price: newPrice ?? null,
|
||
completed: item.is_complete
|
||
},
|
||
version: item.version
|
||
}
|
||
});
|
||
item.updating = false;
|
||
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
|
||
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;
|
||
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];
|
||
|
||
if (!isOnline.value) {
|
||
offlineStore.addAction({
|
||
type: 'delete_list_item',
|
||
payload: {
|
||
listId: String(list.value.id),
|
||
itemId: String(item.id)
|
||
}
|
||
});
|
||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||
item.deleting = false;
|
||
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;
|
||
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();
|
||
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) {
|
||
(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;
|
||
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
|
||
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
|
||
if (inputElement) (inputElement as HTMLInputElement).value = '';
|
||
} else if (ocrFileInputRef.value) {
|
||
(ocrFileInputRef.value as any).value = '';
|
||
}
|
||
}
|
||
};
|
||
|
||
const addOcrItems = async () => {
|
||
if (!list.value || !ocrItems.value.length) return;
|
||
addingOcrItems.value = true;
|
||
let successCount = 0;
|
||
try {
|
||
for (const item of ocrItems.value) {
|
||
if (!item.name.trim()) continue;
|
||
const response = await apiClient.post(
|
||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||
{ name: item.name, quantity: "1" }
|
||
);
|
||
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 '';
|
||
};
|
||
|
||
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||
const activeElement = document.activeElement;
|
||
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
|
||
return;
|
||
}
|
||
if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
if (itemNameInputRef.value?.$el) {
|
||
(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');
|
||
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');
|
||
}
|
||
}
|
||
|
||
// Fetch categories relevant to the list (either personal or group)
|
||
categoryStore.fetchCategories(list.value?.group_id);
|
||
|
||
fetchListDetails().then(() => {
|
||
startPolling();
|
||
});
|
||
const routeParamsId = route.params.id;
|
||
listDetailStore.fetchListWithExpenses(String(routeParamsId));
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
stopPolling();
|
||
});
|
||
|
||
const startItemEdit = (item: ItemWithUI) => {
|
||
list.value?.items.forEach(i => { if (i.id !== item.id) i.isEditing = false; });
|
||
item.isEditing = true;
|
||
item.editName = item.name;
|
||
item.editQuantity = item.quantity ?? '';
|
||
item.editCategoryId = item.category_id;
|
||
};
|
||
|
||
const cancelItemEdit = (item: ItemWithUI) => {
|
||
item.isEditing = false;
|
||
};
|
||
|
||
const saveItemEdit = async (item: ItemWithUI) => {
|
||
if (!list.value || !item.editName || String(item.editName).trim() === '') {
|
||
notificationStore.addNotification({
|
||
message: t('listDetailPage.notifications.enterItemName'),
|
||
type: 'warning'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
name: String(item.editName).trim(),
|
||
quantity: item.editQuantity ? String(item.editQuantity) : null,
|
||
version: item.version,
|
||
category_id: item.editCategoryId,
|
||
};
|
||
|
||
item.updating = true;
|
||
|
||
try {
|
||
const response = await apiClient.put(
|
||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||
payload
|
||
);
|
||
|
||
const updatedItemFromApi = response.data as Item;
|
||
item.name = updatedItemFromApi.name;
|
||
item.quantity = updatedItemFromApi.quantity;
|
||
item.version = updatedItemFromApi.version;
|
||
item.is_complete = updatedItemFromApi.is_complete;
|
||
item.price = updatedItemFromApi.price;
|
||
item.updated_at = updatedItemFromApi.updated_at;
|
||
item.category_id = updatedItemFromApi.category_id;
|
||
|
||
item.isEditing = false;
|
||
notificationStore.addNotification({
|
||
message: t('listDetailPage.notifications.itemUpdatedSuccess'),
|
||
type: 'success'
|
||
});
|
||
|
||
} catch (err) {
|
||
notificationStore.addNotification({
|
||
message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'),
|
||
type: 'error'
|
||
});
|
||
} 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.modals.settleShare.errors.enterAmount');
|
||
return false;
|
||
}
|
||
const amount = new Decimal(settleAmount.value);
|
||
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
|
||
settleAmountError.value = t('listDetailPage.modals.settleShare.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.modals.settleShare.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
|
||
return false;
|
||
}
|
||
} else {
|
||
settleAmountError.value = t('listDetailPage.modals.settleShare.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];
|
||
const item = list.value.items[evt.newIndex];
|
||
const newPosition = evt.newIndex + 1;
|
||
|
||
try {
|
||
await apiClient.put(
|
||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||
{ position: newPosition, version: item.version }
|
||
);
|
||
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) {
|
||
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 {
|
||
newSet.add(expenseId);
|
||
}
|
||
expandedExpenses.value = newSet;
|
||
};
|
||
|
||
const isExpenseExpanded = (expenseId: number) => {
|
||
return expandedExpenses.value.has(expenseId);
|
||
};
|
||
|
||
const groupedItems = computed(() => {
|
||
if (!list.value?.items) return [];
|
||
const groups: Record<string, { categoryName: string; items: ItemWithUI[] }> = {};
|
||
|
||
list.value.items.forEach(item => {
|
||
const categoryId = item.category_id;
|
||
const category = categories.value.find(c => c.id === categoryId);
|
||
const categoryName = category ? category.name : t('listDetailPage.items.noCategory');
|
||
|
||
if (!groups[categoryName]) {
|
||
groups[categoryName] = { categoryName, items: [] };
|
||
}
|
||
groups[categoryName].items.push(item);
|
||
});
|
||
|
||
return Object.values(groups);
|
||
});
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.neo-expenses-section {
|
||
padding: 0;
|
||
margin-top: 1.2rem;
|
||
}
|
||
|
||
.neo-expense-list {
|
||
background-color: rgb(255, 248, 240);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
border: 1px solid #f0e5d8;
|
||
}
|
||
|
||
.neo-expense-item-wrapper {
|
||
border-bottom: 1px solid #f0e5d8;
|
||
}
|
||
|
||
.neo-expense-item-wrapper:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.neo-expense-item {
|
||
padding: 1rem 1.2rem;
|
||
cursor: pointer;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.neo-expense-item:hover {
|
||
background-color: rgba(0, 0, 0, 0.02);
|
||
}
|
||
|
||
.neo-expense-item.is-expanded .expense-toggle-icon {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.expense-main-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.expense-icon-container {
|
||
color: #d99a53;
|
||
}
|
||
|
||
.expense-text-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.expense-side-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.expense-toggle-icon {
|
||
color: #888;
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.neo-expense-header {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
margin-bottom: 0.1rem;
|
||
}
|
||
|
||
.neo-expense-details,
|
||
.neo-split-details {
|
||
font-size: 0.9rem;
|
||
color: #555;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.neo-expense-details strong,
|
||
.neo-split-details strong {
|
||
color: #111;
|
||
}
|
||
|
||
.neo-expense-status {
|
||
display: inline-block;
|
||
padding: 0.25em 0.6em;
|
||
font-size: 0.85em;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
vertical-align: baseline;
|
||
border-radius: 0.375rem;
|
||
margin-left: 0.5rem;
|
||
color: #22c55e;
|
||
}
|
||
|
||
.status-unpaid {
|
||
background-color: #fee2e2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.status-partially_paid {
|
||
background-color: #ffedd5;
|
||
color: #f97316;
|
||
}
|
||
|
||
.status-paid {
|
||
background-color: #dcfce7;
|
||
color: #22c55e;
|
||
}
|
||
|
||
.neo-splits-container {
|
||
padding: 0.5rem 1.2rem 1.2rem;
|
||
background-color: rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
.neo-splits-list {
|
||
margin-top: 0rem;
|
||
padding-left: 0;
|
||
border-left: none;
|
||
}
|
||
|
||
.neo-split-item {
|
||
padding: 0.75rem 0;
|
||
border-bottom: 1px dashed #f0e5d8;
|
||
display: grid;
|
||
grid-template-areas:
|
||
"user owes status paid action"
|
||
"activities activities activities activities activities";
|
||
grid-template-columns: 1.5fr 1fr 1fr 1.5fr auto;
|
||
gap: 0.5rem 1rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.neo-split-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.split-col.split-user {
|
||
grid-area: user;
|
||
}
|
||
|
||
.split-col.split-owes {
|
||
grid-area: owes;
|
||
}
|
||
|
||
.split-col.split-status {
|
||
grid-area: status;
|
||
}
|
||
|
||
.split-col.split-paid-info {
|
||
grid-area: paid;
|
||
}
|
||
|
||
.split-col.split-action {
|
||
grid-area: action;
|
||
justify-self: end;
|
||
}
|
||
|
||
.split-col.neo-settlement-activities {
|
||
grid-area: activities;
|
||
font-size: 0.8em;
|
||
color: #555;
|
||
padding-left: 1em;
|
||
list-style-type: disc;
|
||
margin-top: 0.5em;
|
||
}
|
||
|
||
.neo-settlement-activities {
|
||
font-size: 0.8em;
|
||
color: #555;
|
||
padding-left: 1em;
|
||
list-style-type: disc;
|
||
margin-top: 0.5em;
|
||
}
|
||
|
||
.neo-settlement-activities li {
|
||
margin-top: 0.2em;
|
||
}
|
||
|
||
.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: 5px;
|
||
top: 1px;
|
||
width: 6px;
|
||
height: 12px;
|
||
border: solid var(--primary);
|
||
border-width: 0 3px 3px 0;
|
||
transform: rotate(45deg) scale(0);
|
||
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
|
||
transition-property: transform, opacity;
|
||
}
|
||
|
||
.neo-checkbox-label input[type="checkbox"]:checked {
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.neo-checkbox-label input[type="checkbox"]:checked::after {
|
||
opacity: 1;
|
||
transform: rotate(45deg) scale(1);
|
||
}
|
||
|
||
.checkbox-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.checkbox-text-span {
|
||
position: relative;
|
||
transition: color 0.4s ease, opacity 0.4s ease;
|
||
width: fit-content;
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
.category-group {
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.category-header {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.category-group.highlight .neo-list-item:not(.is-complete) {
|
||
background-color: #e6f7ff;
|
||
}
|
||
</style>
|