mitlist/fe/src/pages/ListDetailPage.vue
google-labs-jules[bot] 2a2045c24a feat(i18n): Internationalize remaining app pages
This commit completes the internationalization (i18n) for several key pages
within the frontend application.

The following pages have been updated to support multiple languages:
- AccountPage.vue
- SignupPage.vue
- ListDetailPage.vue (including items, OCR, expenses, and cost summary)
- MyChoresPage.vue
- PersonalChoresPage.vue
- IndexPage.vue

Key changes include:
- Extraction of all user-facing strings from these Vue components.
- Addition of new translation keys and their English values to `fe/src/i18n/en.json`.
- Modification of the Vue components to use the Vue I18n plugin's `$t()` (template)
  and `t()` (script) functions for displaying translated strings.
- Dynamic messages, notifications, and form validation messages are now also
  internationalized.
- The language files `de.json`, `es.json`, and `fr.json` have been updated
  with the new keys, using the English text as placeholders for future
  translation.

This effort significantly expands the i18n coverage of the application,
making it more accessible to a wider audience.
2025-06-01 22:13:36 +00:00

1620 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<main class="neo-container page-padding">
<div v-if="pageInitialLoad && !list && !error" class="text-center py-10">
<VSpinner :label="$t('listDetailPage.loading.list')" size="lg" />
</div>
<VAlert v-else-if="error && !list" type="error" :message="error" class="mb-4">
<template #actions>
<VButton @click="fetchListDetails">{{ $t('listDetailPage.retryButton') }}</VButton>
</template>
</VAlert>
<template v-else-if="list">
<!-- Header -->
<div class="neo-list-header">
<VHeading :level="1" :text="list.name" class="mb-3 neo-title" />
<div class="neo-header-actions">
<VButton @click="showCostSummaryDialog = true" :disabled="!isOnline" icon-left="clipboard">{{ $t('listDetailPage.buttons.costSummary') }}
</VButton>
<VButton @click="openOcrDialog" :disabled="!isOnline" icon-left="plus">{{ $t('listDetailPage.buttons.addViaOcr') }}</VButton>
<VBadge :text="list.group_id ? $t('listDetailPage.badges.groupList') : $t('listDetailPage.badges.personalList')" :variant="list.group_id ? 'accent' : 'settled'"
class="neo-status" />
</div>
</div>
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
<!-- Items List Section -->
<VCard v-if="itemsAreLoading" class="py-10 text-center mt-4">
<VSpinner :label="$t('listDetailPage.loading.items')" size="lg" />
</VCard>
<VCard v-else-if="!itemsAreLoading && list.items.length === 0" variant="empty-state" empty-icon="clipboard"
:empty-title="$t('listDetailPage.items.emptyState.title')" :empty-message="$t('listDetailPage.items.emptyState.message')" class="mt-4" />
<div v-else class="neo-item-list-container mt-4">
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id" class="neo-list-item"
:class="{ 'bg-gray-100 opacity-70': item.is_complete }">
<div class="neo-item-content">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete"
@change="(e) => confirmUpdateItem(item, (e.target as HTMLInputElement)?.checked ?? false)" />
<span class="item-name" :class="{ 'line-through': item.is_complete }">{{ item.name }}</span>
<span v-if="item.quantity" class="text-sm text-gray-500 ml-1">× {{ item.quantity }}</span>
</label>
<div class="neo-item-actions">
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" :aria-label="$t('listDetailPage.items.editItemAriaLabel')">
<VIcon name="edit" />
</button>
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" :aria-label="$t('listDetailPage.items.deleteItemAriaLabel')">
<VIcon name="trash" />
</button>
</div>
</div>
<div v-if="item.is_complete" class="neo-price-input">
<VInput type="number" :model-value="item.priceInput || ''" @update:modelValue="item.priceInput = $event"
:placeholder="$t('listDetailPage.items.pricePlaceholder')" size="sm" class="w-24" step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</li>
</ul>
</div>
<!-- Add New Item Form -->
<form @submit.prevent="onAddItem" class="add-item-form mt-4 p-4 border rounded-lg shadow flex items-center gap-2">
<VIcon name="plus-circle" class="text-gray-400 shrink-0" />
<VFormField class="flex-grow" :label="$t('listDetailPage.items.addItemForm.itemNameSrLabel')" :label-sr-only="true">
<VInput v-model="newItem.name" :placeholder="$t('listDetailPage.items.addItemForm.placeholder')" required ref="itemNameInputRef" />
</VFormField>
<VFormField :label="$t('listDetailPage.items.addItemForm.quantitySrLabel')" :label-sr-only="true" class="w-24 shrink-0">
<VInput type="number" :model-value="newItem.quantity || ''" @update:modelValue="newItem.quantity = $event"
:placeholder="$t('listDetailPage.items.addItemForm.quantityPlaceholder')" min="1" />
</VFormField>
<VButton type="submit" :disabled="addingItem" class="shrink-0">
<VSpinner v-if="addingItem" size="sm" />
<span v-else>{{ $t('listDetailPage.buttons.addItem') }}</span>
</VButton>
</form>
</template>
<!-- Expenses Section (Original Content - Part 3 will refactor this) -->
<section v-if="list && !itemsAreLoading" class="neo-expenses-section">
<div class="neo-expenses-header">
<h2 class="neo-expenses-title">{{ $t('listDetailPage.expensesSection.title') }}</h2>
<button class="neo-action-button" @click="showCreateExpenseForm = true">
<svg class="icon">
<use xlink:href="#icon-plus" />
</svg>
{{ $t('listDetailPage.expensesSection.addExpenseButton') }}
</button>
</div>
<div v-if="listDetailStore.isLoading && expenses.length === 0" class="neo-loading-state">
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>{{ $t('listDetailPage.expensesSection.loading') }}</p>
</div>
<div v-else-if="listDetailStore.error" class="neo-error-state">
<p>{{ listDetailStore.error }}</p> <!-- Assuming listDetailStore.error is a backend message or already translated if generic -->
<button class="neo-button" @click="listDetailStore.fetchListWithExpenses(String(list?.id))">{{ $t('listDetailPage.expensesSection.retryButton') }}</button>
</div>
<div v-else-if="!expenses || expenses.length === 0" class="neo-empty-state">
<p>{{ $t('listDetailPage.expensesSection.emptyState') }}</p>
</div>
<div v-else>
<div v-for="expense in expenses" :key="expense.id" class="neo-expense-card">
<div class="neo-expense-header">
{{ expense.description }} - {{ formatCurrency(expense.total_amount) }}
<span class="neo-expense-status" :class="getStatusClass(expense.overall_settlement_status)">
{{ getOverallExpenseStatusText(expense.overall_settlement_status) }}
</span>
</div>
<div class="neo-expense-details">
{{ $t('listDetailPage.expensesSection.paidBy') }} <strong>{{ expense.paid_by_user?.name || expense.paid_by_user?.email || `User ID:
${expense.paid_by_user_id}` }}</strong>
{{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(expense.expense_date).toLocaleDateString() }}
</div>
<div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="neo-split-details">
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> {{ $t('listDetailPage.expensesSection.owes') }} {{
formatCurrency(split.owed_amount) }}
<span class="neo-expense-status" :class="getStatusClass(split.status)">
{{ getSplitStatusText(split.status) }}
</span>
</div>
<div class="neo-split-details">
{{ $t('listDetailPage.expensesSection.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(split.paid_at).toLocaleDateString() }}</span>
</div>
<button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID"
class="neo-button neo-button-primary" @click="openSettleShareModal(expense, split)"
:disabled="isSettlementLoading">
{{ $t('listDetailPage.expensesSection.settleShareButton') }}
</button>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id">
{{ $t('listDetailPage.expensesSection.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ $t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{ new Date(activity.paid_at).toLocaleDateString() }}
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<!-- Create Expense Form (Original Content) -->
<CreateExpenseForm v-if="showCreateExpenseForm" :list-id="list?.id" :group-id="list?.group_id ?? undefined"
@close="showCreateExpenseForm = false" @created="handleExpenseCreated" />
<!-- OCR Dialog -->
<VModal v-model="showOcrDialogState" :title="$t('listDetailPage.modals.ocr.title')" @update:modelValue="!$event && closeOcrDialog()">
<template #default>
<div v-if="ocrLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.ocrProcessing')" />
</div>
<VList v-else-if="ocrItems.length > 0">
<VListItem v-for="(ocrItem, index) in ocrItems" :key="index">
<div class="flex items-center gap-2">
<VInput type="text" v-model="ocrItem.name" class="flex-grow" required />
<VButton variant="danger" size="sm" :icon-only="true" iconLeft="trash"
@click="ocrItems.splice(index, 1)" />
</div>
</VListItem>
</VList>
<VFormField v-else :label="$t('listDetailPage.modals.ocr.uploadLabel')" :error-message="ocrError || undefined">
<VInput type="file" id="ocrFile" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"
:model-value="''" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="closeOcrDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton v-if="ocrItems.length > 0" type="button" variant="primary" @click="addOcrItems"
:disabled="addingOcrItems">
<VSpinner v-if="addingOcrItems" size="sm" :label="$t('listDetailPage.loading.addingOcrItems')" /> {{ $t('listDetailPage.buttons.addItems') }}
</VButton>
</template>
</VModal>
<!-- Confirmation Dialog -->
<VModal v-model="showConfirmDialogState" :title="$t('listDetailPage.modals.confirmation.title')" @update:modelValue="!$event && cancelConfirmation()"
size="sm">
<template #default>
<div class="text-center">
<VIcon name="alert-triangle" size="lg" class="text-yellow-500 mb-2" />
<p>{{ confirmDialogMessage }}</p>
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="cancelConfirmation">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmedAction">{{ $t('listDetailPage.buttons.confirm') }}</VButton>
</template>
</VModal>
<!-- Cost Summary Dialog -->
<VModal v-model="showCostSummaryDialog" :title="$t('listDetailPage.modals.costSummary.title')" @update:modelValue="showCostSummaryDialog = false"
size="lg">
<template #default>
<div v-if="costSummaryLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.costSummary')" />
</div>
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{ formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{ formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{ listCostSummary.num_participating_users }}</p>
</div>
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="userShare in listCostSummary.user_balances" :key="userShare.user_id">
<td>{{ userShare.user_identifier }}</td>
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right">
<VBadge :text="formatCurrency(userShare.balance)"
:variant="parseFloat(String(userShare.balance)) >= 0 ? 'settled' : 'pending'" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}</VButton>
</template>
</VModal>
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" :title="$t('listDetailPage.settleShareModal.title')" @update:modelValue="!$event && closeSettleShareModal()"
size="md">
<template #default>
<div v-if="isSettlementLoading" class="text-center">
<VSpinner :label="$t('listDetailPage.loading.settlement')" />
</div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else>
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', { userName: selectedSplitForSettlement?.user?.name || selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}` }) }}</p>
<VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')" :error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField>
</div>
</template>
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{ $t('listDetailPage.settleShareModal.cancelButton') }}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton') }}</VButton>
</template>
</VModal>
<!-- Edit Item Dialog -->
<VModal v-model="showEditDialog" :title="$t('listDetailPage.modals.editItem.title')" @update:modelValue="!$event && closeEditDialog()">
<template #default>
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.nameLabel')" class="mb-4">
<VInput type="text" id="editItemName" v-model="editingItem.name" required />
</VFormField>
<VFormField v-if="editingItem" :label="$t('listDetailPage.modals.editItem.quantityLabel')">
<VInput type="number" id="editItemQuantity" :model-value="editingItem.quantity || ''"
@update:modelValue="editingItem.quantity = $event" min="1" />
</VFormField>
</template>
<template #footer>
<VButton variant="neutral" @click="closeEditDialog">{{ $t('listDetailPage.buttons.cancel') }}</VButton>
<VButton variant="primary" @click="handleConfirmEdit" :disabled="!editingItem?.name.trim()">{{ $t('listDetailPage.buttons.saveChanges') }}
</VButton>
</template>
</VModal>
<VAlert v-if="!list && !pageInitialLoad" type="info" :message="$t('listDetailPage.errors.genericLoadFailure')" />
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Keep for item management
import { useEventListener, useFileDialog, useNetwork } from '@vueuse/core'; // onClickOutside removed
import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
import { useListDetailStore } from '@/stores/listDetailStore';
import type { ListWithExpenses } from '@/types/list';
import type { Expense, ExpenseSplit } from '@/types/expense';
import { ExpenseOverallStatusEnum, ExpenseSplitStatusEnum } from '@/types/expense';
import { useAuthStore } from '@/stores/auth';
import { Decimal } from 'decimal.js';
import type { SettlementActivityCreate } from '@/types/expense';
import SettleShareModal from '@/components/SettleShareModal.vue';
import CreateExpenseForm from '@/components/CreateExpenseForm.vue';
import type { Item } from '@/types/item';
import VHeading from '@/components/valerie/VHeading.vue';
import VSpinner from '@/components/valerie/VSpinner.vue';
import VAlert from '@/components/valerie/VAlert.vue';
import VButton from '@/components/valerie/VButton.vue';
import VBadge from '@/components/valerie/VBadge.vue';
import VIcon from '@/components/valerie/VIcon.vue';
import VModal from '@/components/valerie/VModal.vue';
import VFormField from '@/components/valerie/VFormField.vue';
import VInput from '@/components/valerie/VInput.vue';
import VList from '@/components/valerie/VList.vue';
import VListItem from '@/components/valerie/VListItem.vue';
import VCheckbox from '@/components/valerie/VCheckbox.vue';
// VTextarea and VSelect are not used in this part of the refactor for ListDetailPage
const { t } = useI18n();
// UI-specific properties that we add to items
interface ItemWithUI extends Item {
updating: boolean;
deleting: boolean;
priceInput: string | number | null;
swiped: boolean;
}
interface List {
id: number;
name: string;
description?: string;
is_complete: boolean;
items: ItemWithUI[];
version: number;
updated_at: string;
group_id?: number;
}
interface UserCostShare {
user_id: number;
user_identifier: string;
items_added_value: string | number;
amount_due: string | number;
balance: string | number;
}
interface ListCostSummaryData {
list_id: number;
list_name: string;
total_list_cost: string | number;
num_participating_users: number;
equal_share_per_user: string | number;
user_balances: UserCostShare[];
}
const route = useRoute();
const { isOnline } = useNetwork();
const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore();
const list = ref<List | null>(null);
const pageInitialLoad = ref(true); // True until shell is loaded or first fetch begins
const itemsAreLoading = ref(false); // True when items are actively being fetched/processed
const error = ref<string | null>(null); // For page-level errors
const addingItem = ref(false);
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null);
const newItem = ref<{ name: string; quantity?: number }>({ name: '' });
const itemNameInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed type
// OCR
const showOcrDialogState = ref(false);
// const ocrModalRef = ref<HTMLElement | null>(null); // Removed
const ocrLoading = ref(false);
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
const addingOcrItems = ref(false);
const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<InstanceType<typeof VInput> | null>(null); // Changed to VInput ref type
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
accept: 'image/*',
multiple: false,
});
// Confirmation Dialog
const showConfirmDialogState = ref(false);
// const confirmModalRef = ref<HTMLElement | null>(null); // Removed
const confirmDialogMessage = ref('');
const pendingAction = ref<(() => Promise<void>) | null>(null);
// Cost Summary
const showCostSummaryDialog = ref(false);
// const costSummaryModalRef = ref<HTMLElement | null>(null); // Removed
const listCostSummary = ref<ListCostSummaryData | null>(null);
const costSummaryLoading = ref(false);
const costSummaryError = ref<string | null>(null);
// Settle Share
const authStore = useAuthStore();
const showSettleModal = ref(false);
// const settleModalRef = ref<HTMLElement | null>(null); // Removed
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
const parentExpenseOfSelectedSplit = ref<Expense | null>(null);
const settleAmount = ref<string>('');
const settleAmountError = ref<string | null>(null);
const isSettlementLoading = computed(() => listDetailStore.isSettlingSplit);
// Create Expense
const showCreateExpenseForm = ref(false);
// Edit Item
const showEditDialog = ref(false);
// const editModalRef = ref<HTMLElement | null>(null); // Removed
const editingItem = ref<Item | null>(null);
// onClickOutside for ocrModalRef, costSummaryModalRef, etc. are removed as VModal handles this.
const formatCurrency = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '$0.00';
// Ensure that string "0.00" or "0" are handled correctly before parseFloat
if (typeof value === 'string' && !value.trim()) return '$0.00';
const numValue = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(numValue) ? '$0.00' : `$${numValue.toFixed(2)}`;
};
const processListItems = (items: Item[]): ItemWithUI[] => {
return items.map(item => ({
...item,
updating: false,
deleting: false,
priceInput: item.price || null,
swiped: false
}));
};
const fetchListDetails = async () => {
if (pageInitialLoad.value) {
pageInitialLoad.value = false;
}
itemsAreLoading.value = true;
const routeId = String(route.params.id);
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
try {
let response;
if (cachedFullData) {
response = { data: JSON.parse(cachedFullData) };
sessionStorage.removeItem(`listDetailFull_${routeId}`);
} else {
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
}
const rawList = response.data as ListWithExpenses;
const localList: List = {
id: rawList.id,
name: rawList.name,
description: rawList.description ?? undefined,
is_complete: rawList.is_complete,
items: processListItems(rawList.items),
version: rawList.version,
updated_at: rawList.updated_at,
group_id: rawList.group_id ?? undefined
};
list.value = localList;
lastListUpdate.value = rawList.updated_at;
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest;
}, '');
if (showCostSummaryDialog.value) {
await fetchListCostSummary();
}
} catch (err: unknown) {
const apiErrorMessage = err instanceof Error ? err.message : String(err);
const fallbackErrorMessage = t('listDetailPage.errors.fetchFailed');
if (!list.value) {
error.value = apiErrorMessage || fallbackErrorMessage;
} else {
notificationStore.addNotification({ message: t('listDetailPage.errors.fetchItemsFailed', { errorMessage: apiErrorMessage }), type: 'error' });
}
} finally {
itemsAreLoading.value = false;
if (!list.value && !error.value) {
pageInitialLoad.value = false;
}
}
};
const checkForUpdates = async () => {
if (!list.value) return;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(list.value.id)));
const { updated_at: newListUpdatedAt, items: newItems } = response.data as ListWithExpenses;
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) =>
item.updated_at > latest ? item.updated_at : latest,
'');
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
await fetchListDetails();
}
} catch (err) {
console.warn('Polling for updates failed:', err);
}
};
const startPolling = () => {
stopPolling();
pollingInterval.value = setInterval(() => checkForUpdates(), 15000);
};
const stopPolling = () => {
if (pollingInterval.value) clearInterval(pollingInterval.value);
};
const isItemPendingSync = (item: Item) => {
return offlineStore.pendingActions.some(action => {
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
const payload = action.payload as { listId: string; itemId: string };
return payload.itemId === String(item.id);
}
return false;
});
};
const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.enterItemName'), type: 'warning' });
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
return;
}
addingItem.value = true;
if (!isOnline.value) {
const offlinePayload: any = { // Define explicit type later if needed
name: newItem.value.name
};
if (typeof newItem.value.quantity !== 'undefined') {
offlinePayload.quantity = String(newItem.value.quantity);
}
offlineStore.addAction({
type: 'create_list_item',
payload: {
listId: String(list.value.id),
itemData: offlinePayload
}
});
const optimisticItem: ItemWithUI = {
id: Date.now(), // Temporary ID for offline
name: newItem.value.name,
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
is_complete: false,
price: null,
version: 1, // Assuming initial version
updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
list_id: list.value.id,
updating: false,
deleting: false,
priceInput: null,
swiped: false
};
list.value.items.push(optimisticItem);
newItem.value = { name: '' };
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
addingItem.value = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' }); // Optimistic UI
return;
}
try {
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{
name: newItem.value.name,
quantity: newItem.value.quantity ? String(newItem.value.quantity) : null
}
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
newItem.value = { name: '' };
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addItemFailed'), type: 'error' });
} finally {
addingItem.value = false;
}
};
const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
if (!list.value) return;
item.updating = true;
const originalCompleteStatus = item.is_complete;
item.is_complete = newCompleteStatus;
if (!isOnline.value) {
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
completed: newCompleteStatus
},
version: item.version
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.is_complete = originalCompleteStatus; // Revert optimistic update
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'), type: 'error' });
} finally {
item.updating = false;
}
};
const updateItemPrice = async (item: ItemWithUI) => {
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice?.toString()) return; // No change
item.updating = true;
const originalPrice = item.price;
const originalPriceInput = item.priceInput;
item.price = newPrice?.toString() || null; // Optimistic update
if (!isOnline.value) {
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice ?? null, // Ensure null is sent if cleared
completed: item.is_complete // Keep completion status
},
version: item.version
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ price: newPrice?.toString(), completed: item.is_complete, version: item.version }
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.price = originalPrice; // Revert optimistic update
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemPriceFailed'), type: 'error' });
} finally {
item.updating = false;
}
};
const deleteItem = async (item: ItemWithUI) => {
if (!list.value) return;
item.deleting = true;
const originalItems = [...list.value.items]; // For potential revert
if (!isOnline.value) {
offlineStore.addAction({
type: 'delete_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id)
}
});
list.value.items = list.value.items.filter(i => i.id !== item.id); // Optimistic UI
item.deleting = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
return;
}
try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id);
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
} catch (err) {
list.value.items = originalItems; // Revert optimistic UI
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.deleteItemFailed'), type: 'error' });
} finally {
item.deleting = false;
}
};
const confirmUpdateItem = (item: ItemWithUI, newCompleteStatus: boolean) => {
confirmDialogMessage.value = t('listDetailPage.confirmations.updateMessage', {
itemName: item.name,
status: newCompleteStatus ? t('listDetailPage.confirmations.statusComplete') : t('listDetailPage.confirmations.statusIncomplete')
});
pendingAction.value = () => updateItem(item, newCompleteStatus);
showConfirmDialogState.value = true;
};
const confirmDeleteItem = (item: ItemWithUI) => {
confirmDialogMessage.value = t('listDetailPage.confirmations.deleteMessage', { itemName: item.name });
pendingAction.value = () => deleteItem(item);
showConfirmDialogState.value = true;
};
const handleConfirmedAction = async () => {
if (pendingAction.value) {
await pendingAction.value();
}
cancelConfirmation();
};
const cancelConfirmation = () => {
showConfirmDialogState.value = false;
pendingAction.value = null;
confirmDialogMessage.value = ''; // Clear message
};
const openOcrDialog = () => {
ocrItems.value = [];
ocrError.value = null;
resetOcrFileDialog(); // From useFileDialog
showOcrDialogState.value = true;
nextTick(() => {
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Native input
(ocrFileInputRef.value as any).value = '';
}
});
};
const closeOcrDialog = () => {
showOcrDialogState.value = false;
ocrItems.value = [];
ocrError.value = null;
};
watch(ocrFiles, async (newFiles) => {
if (newFiles && newFiles.length > 0) {
const file = newFiles[0];
await handleOcrUpload(file);
}
});
const handleOcrFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
handleOcrUpload(target.files[0]);
}
};
const handleOcrUpload = async (file: File) => {
if (!file) return;
ocrLoading.value = true;
ocrError.value = null;
ocrItems.value = [];
try {
const formData = new FormData();
formData.append('image_file', file);
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) {
ocrError.value = t('listDetailPage.errors.ocrNoItems');
}
} catch (err) {
ocrError.value = (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.ocrFailed');
} finally {
ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Native input
(ocrFileInputRef.value as any).value = '';
}
}
};
const addOcrItems = async () => {
if (!list.value || !ocrItems.value.length) return;
addingOcrItems.value = true;
let successCount = 0;
try {
for (const item of ocrItems.value) {
if (!item.name.trim()) continue;
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: item.name, quantity: "1" } // Default quantity 1
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
successCount++;
}
if (successCount > 0) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemsAddedSuccessOcr', { count: successCount }), type: 'success' });
}
closeOcrDialog();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.addOcrItemsFailed'), type: 'error' });
} finally {
addingOcrItems.value = false;
}
};
const fetchListCostSummary = async () => {
if (!list.value || list.value.id === 0) return;
costSummaryLoading.value = true;
costSummaryError.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.COSTS.LIST_SUMMARY(list.value.id));
listCostSummary.value = response.data;
} catch (err) {
costSummaryError.value = (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.loadCostSummaryFailed');
listCostSummary.value = null;
} finally {
costSummaryLoading.value = false;
}
};
watch(showCostSummaryDialog, (newVal) => {
if (newVal && (!listCostSummary.value || listCostSummary.value.list_id !== list.value?.id)) {
fetchListCostSummary();
}
});
// --- Expense and Settlement Status Logic ---
const listDetailStore = useListDetailStore();
const expenses = computed(() => listDetailStore.getExpenses);
const getPaidAmountForSplitDisplay = (split: ExpenseSplit): string => {
const amount = listDetailStore.getPaidAmountForSplit(split.id);
return formatCurrency(amount);
};
const getSplitStatusText = (status: ExpenseSplitStatusEnum): string => {
switch (status) {
case ExpenseSplitStatusEnum.PAID: return t('listDetailPage.status.paid');
case ExpenseSplitStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallyPaid');
case ExpenseSplitStatusEnum.UNPAID: return t('listDetailPage.status.unpaid');
default: return t('listDetailPage.status.unknown');
}
};
const getOverallExpenseStatusText = (status: ExpenseOverallStatusEnum): string => {
switch (status) {
case ExpenseOverallStatusEnum.PAID: return t('listDetailPage.status.settled');
case ExpenseOverallStatusEnum.PARTIALLY_PAID: return t('listDetailPage.status.partiallySettled');
case ExpenseOverallStatusEnum.UNPAID: return t('listDetailPage.status.unsettled');
default: return t('listDetailPage.status.unknown');
}
};
const getStatusClass = (status: ExpenseSplitStatusEnum | ExpenseOverallStatusEnum): string => {
if (status === ExpenseSplitStatusEnum.PAID || status === ExpenseOverallStatusEnum.PAID) return 'status-paid';
if (status === ExpenseSplitStatusEnum.PARTIALLY_PAID || status === ExpenseOverallStatusEnum.PARTIALLY_PAID) return 'status-partially_paid';
if (status === ExpenseSplitStatusEnum.UNPAID || status === ExpenseOverallStatusEnum.UNPAID) return 'status-unpaid';
return '';
};
// Keyboard shortcut
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return; // Don't interfere with typing
}
// Check if any modal is open, if so, don't trigger
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value || showEditDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
return;
}
event.preventDefault();
if (itemNameInputRef.value?.$el) { // Focus the add item input
(itemNameInputRef.value.$el as HTMLElement).focus();
}
}
});
let touchStartX = 0;
const SWIPE_THRESHOLD = 50; // Pixels
const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX;
};
const handleTouchMove = (event: TouchEvent, item: ItemWithUI) => {
// This function might be used for swipe-to-reveal actions in the future
// For now, it's a placeholder or can be removed if not used.
};
const handleTouchEnd = (event: TouchEvent, item: ItemWithUI) => {
// This function might be used for swipe-to-reveal actions in the future
// For now, it's a placeholder or can be removed if not used.
};
onMounted(() => {
pageInitialLoad.value = true;
itemsAreLoading.value = false;
error.value = null;
if (!route.params.id) {
error.value = t('listDetailPage.errors.fetchFailed'); // Generic error if no ID
pageInitialLoad.value = false;
listDetailStore.setError(t('listDetailPage.errors.fetchFailed'));
return;
}
const listShellJSON = sessionStorage.getItem('listDetailShell');
const routeId = String(route.params.id);
if (listShellJSON) {
const shellData = JSON.parse(listShellJSON);
if (shellData.id === parseInt(routeId, 10)) {
list.value = {
id: shellData.id,
name: shellData.name,
description: shellData.description,
is_complete: false,
items: [],
version: 0,
updated_at: new Date().toISOString(),
group_id: shellData.group_id,
};
pageInitialLoad.value = false;
} else {
sessionStorage.removeItem('listDetailShell');
}
}
fetchListDetails().then(() => {
startPolling();
});
const routeParamsId = route.params.id;
listDetailStore.fetchListWithExpenses(String(routeParamsId));
});
onUnmounted(() => {
stopPolling();
});
const editItem = (item: Item) => {
editingItem.value = { ...item }; // Clone item for editing
showEditDialog.value = true;
};
const closeEditDialog = () => {
showEditDialog.value = false;
editingItem.value = null;
};
const handleConfirmEdit = async () => {
if (!editingItem.value || !list.value) return;
const itemToUpdate = editingItem.value; // Already a clone
try {
const response = await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(itemToUpdate.id)),
{
name: itemToUpdate.name,
quantity: itemToUpdate.quantity?.toString(), // Ensure quantity is string or null
version: itemToUpdate.version
}
);
const updatedItemFromApi = response.data as Item;
const index = list.value.items.findIndex(i => i.id === updatedItemFromApi.id);
if (index !== -1) {
list.value.items[index] = processListItems([updatedItemFromApi])[0];
}
notificationStore.addNotification({
message: 'Item updated successfully',
type: 'success'
});
closeEditDialog();
} catch (err) {
notificationStore.addNotification({
message: (err instanceof Error ? err.message : String(err)) || t('listDetailPage.errors.updateItemFailed'),
type: 'error'
});
}
};
const openSettleShareModal = (expense: Expense, split: ExpenseSplit) => {
if (split.user_id !== authStore.user?.id) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.cannotSettleOthersShares'), type: 'warning' });
return;
}
selectedSplitForSettlement.value = split;
parentExpenseOfSelectedSplit.value = expense;
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(split.id));
const owed = new Decimal(split.owed_amount);
const remaining = owed.minus(alreadyPaid);
settleAmount.value = remaining.toFixed(2);
settleAmountError.value = null;
showSettleModal.value = true;
};
const closeSettleShareModal = () => {
showSettleModal.value = false;
selectedSplitForSettlement.value = null;
parentExpenseOfSelectedSplit.value = null;
settleAmount.value = '';
settleAmountError.value = null;
};
const validateSettleAmount = (): boolean => {
settleAmountError.value = null;
if (!settleAmount.value.trim()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount');
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
return false;
}
if (selectedSplitForSettlement.value) {
const alreadyPaid = new Decimal(listDetailStore.getPaidAmountForSplit(selectedSplitForSettlement.value.id));
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false;
}
} else {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
return false;
}
return true;
};
const currentListIdForRefetch = computed(() => listDetailStore.currentList?.id || null);
const handleConfirmSettle = async () => {
if (!selectedSplitForSettlement.value || !authStore.user?.id || !currentListIdForRefetch.value) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.settlementDataMissing'), type: 'error' });
return;
}
const activityData: SettlementActivityCreate = {
expense_split_id: selectedSplitForSettlement.value.id,
paid_by_user_id: Number(authStore.user.id),
amount_paid: new Decimal(settleAmount.value).toString(),
paid_at: new Date().toISOString(),
};
const success = await listDetailStore.settleExpenseSplit({
list_id_for_refetch: String(currentListIdForRefetch.value),
expense_split_id: selectedSplitForSettlement.value.id,
activity_data: activityData,
});
if (success) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.settleShareSuccess'), type: 'success' });
closeSettleShareModal();
} else {
notificationStore.addNotification({ message: listDetailStore.error || t('listDetailPage.notifications.settleShareFailed'), type: 'error' });
}
};
const handleExpenseCreated = (expense: any) => {
if (list.value?.id) {
listDetailStore.fetchListWithExpenses(String(list.value.id));
}
};
</script>
<style scoped>
/* Existing styles */
.neo-expenses-section {
margin-top: 3rem;
padding: 1.5rem;
border: 3px solid #111;
border-radius: 18px;
background: #fdfdfd;
/* Slightly different background for distinction */
box-shadow: 6px 6px 0 #111;
}
.neo-expenses-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.neo-expenses-title {
font-size: 2rem;
font-weight: 900;
margin-bottom: 1.5rem;
text-align: center;
color: #111;
}
.neo-expense-card {
background: #fff;
border: 2px solid #111;
border-radius: 12px;
margin-bottom: 1.5rem;
padding: 1rem;
box-shadow: 4px 4px 0 #ddd;
}
.neo-expense-header {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.neo-expense-details,
.neo-split-details {
font-size: 0.95rem;
color: #333;
margin-bottom: 0.3rem;
}
.neo-expense-details strong,
.neo-split-details strong {
color: #111;
}
.neo-expense-status {
display: inline-block;
padding: 0.25em 0.6em;
font-size: 0.85em;
font-weight: 700;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.375rem;
/* Tailwind's rounded-md */
margin-left: 0.5rem;
}
.status-unpaid {
background-color: #fee2e2;
color: #dc2626;
/* red-100, red-600 */
}
.status-partially_paid {
background-color: #ffedd5;
color: #f97316;
/* orange-100, orange-600 */
}
.status-paid {
background-color: #dcfce7;
color: #22c55e;
/* green-100, green-600 */
}
.neo-splits-list {
margin-top: 1rem;
padding-left: 1rem;
border-left: 2px solid #eee;
}
.neo-split-item {
padding: 0.5rem 0;
border-bottom: 1px dashed #f0f0f0;
}
.neo-split-item:last-child {
border-bottom: none;
}
.neo-settlement-activities {
font-size: 0.8em;
color: #555;
padding-left: 1em;
list-style-type: disc;
/* Ensure bullets are shown */
margin-top: 0.5em;
}
.neo-settlement-activities li {
margin-top: 0.2em;
}
.neo-container {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.page-padding {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.neo-loading-state,
.neo-error-state,
.neo-empty-state {
text-align: center;
padding: 3rem 1rem;
margin: 2rem 0;
border: 3px solid #111;
border-radius: 18px;
background: #fff;
box-shadow: 6px 6px 0 #111;
}
.neo-error-state {
border-color: #e74c3c;
}
.neo-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.neo-title {
font-size: 2.5rem;
font-weight: 900;
margin: 0;
line-height: 1.2;
}
.neo-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.neo-description {
font-size: 1.2rem;
margin-bottom: 2rem;
color: #555;
}
.neo-status {
font-weight: 900;
font-size: 1rem;
padding: 0.4rem 1rem;
border: 3px solid #111;
border-radius: 50px;
background: var(--light);
box-shadow: 3px 3px 0 #111;
}
.neo-status-active {
background: #f7f7d4;
}
.neo-status-complete {
background: #d4f7dd;
}
.neo-list-card {
break-inside: avoid;
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
width: 100%;
margin: 0 0 2rem 0;
background: var(--light);
display: flex;
flex-direction: column;
cursor: pointer;
border: 3px solid #111;
overflow: hidden;
}
.neo-item-list-container {
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.neo-item-list {
list-style: none;
padding: 0;
margin: 0;
}
.neo-list-item {
padding: 1.2rem;
margin-bottom: 0;
border-bottom: 1px solid #eee;
background: var(--light);
transition: background-color 0.1s ease-in-out;
}
.neo-list-item:last-child {
border-bottom: none;
}
.neo-list-item:hover {
background-color: #f9f9f9;
}
.neo-item-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.neo-checkbox-label {
display: flex;
align-items: center;
gap: 0.7em;
cursor: pointer;
flex-grow: 1;
}
.neo-checkbox-label input[type="checkbox"] {
width: 1.2em;
height: 1.2em;
accent-color: #111;
border: 2px solid #111;
border-radius: 4px;
}
.item-name {
font-size: 1.1rem;
font-weight: 700;
}
.neo-item-actions {
display: flex;
gap: 0.5rem;
}
.neo-icon-button {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
-webkit-tap-highlight-color: transparent;
}
.neo-icon-button:active {
transform: scale(0.98);
opacity: 0.9;
}
.neo-edit-button {
color: #3498db;
}
.neo-edit-button:hover {
background: #eef7fd;
}
.neo-delete-button {
color: #e74c3c;
}
.neo-delete-button:hover {
background: #fee;
}
.neo-price-input {
margin-top: 0.5rem;
padding-left: 2.2em;
}
@media (max-width: 600px) {
.neo-list-item {
padding: 1rem;
}
.neo-checkbox-label input[type="checkbox"] {
width: 1.4em;
height: 1.4em;
}
.item-name {
font-size: 1rem;
}
.neo-icon-button {
padding: 0.6rem;
}
}
/* Add smooth transitions for all interactive elements - VComponents have their own */
/* .neo-action-button,
.neo-icon-button,
.neo-checkbox-label,
.neo-add-button {
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
-webkit-tap-highlight-color: transparent;
}
.neo-action-button:active,
.neo-icon-button:active,
.neo-checkbox-label:active,
.neo-add-button:active {
transform: scale(0.98);
opacity: 0.9;
} */
/* Improve scrolling performance */
.item-list-tight {
/* Assuming VList with this class */
will-change: transform;
transform: translateZ(0);
}
/* Modal styles are now handled by VModal component */
/* .modal-backdrop { ... } */
/* .modal-container { ... } */
/* .modal-header { ... } */
/* .modal-body { ... } */
/* .modal-footer { ... } */
/* .close-button { ... } */
/* Item badge styles are now handled by VBadge */
/* .item-badge { ... } */
/* .badge-settled { ... } */
/* .badge-pending { ... } */
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
/* Spinner styles are now handled by VSpinner */
/* .spinner-dots { ... } */
/* .spinner-dots span { ... } */
/* .spinner-dots-sm { ... } */
/* .spinner-dots-sm span { ... } */
/* @keyframes dot-pulse { ... } */
/* Utility classes that might still be used or can be replaced by Tailwind/global equivalents */
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-3 {
margin-bottom: 1rem;
}
/* Adjusted from 1.5rem to match common spacing */
.mb-4 {
margin-bottom: 1.5rem;
}
.py-10 {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.p-4 {
padding: 1rem;
}
.border {
border-width: 1px;
/* Assuming default border color from global styles or Tailwind */
}
.rounded-lg {
border-radius: 0.5rem;
}
.shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
/* Example shadow */
}
.flex-grow {
flex-grow: 1;
}
.w-24 {
width: 6rem;
}
/* Tailwind w-24 */
.text-sm {
font-size: 0.875rem;
}
.text-gray-500 {
color: #6b7280;
}
/* Tailwind gray-500 */
.text-gray-400 {
color: #9ca3af;
}
/* Tailwind gray-400 */
.text-green-600 {
color: #16a34a;
}
/* Tailwind green-600 */
.text-yellow-500 {
color: #eab308;
}
/* Tailwind yellow-500 */
.line-through {
text-decoration: line-through;
}
.opacity-50 {
opacity: 0.5;
}
.opacity-60 {
opacity: 0.6;
}
/* Added for completed item name */
.opacity-70 {
opacity: 0.7;
}
/* Added for completed item background */
.shrink-0 {
flex-shrink: 0;
}
.bg-gray-100 {
background-color: #f3f4f6;
}
/* Tailwind gray-100 */
/* Styles for .neo-list-card, .neo-item-list, .neo-item might be replaced by VCard/VList/VListItem defaults or props */
/* Keeping some specific styles for .neo-item-details, .item-name, etc. if they are distinct. */
.item-with-actions {
/* Custom class for VListItem if needed for specific layout */
/* Default VListItem is display:flex, so this might not be needed or just for minor tweaks */
}
</style>