mitlist/fe/src/pages/ListDetailPage.vue
mohamad 448a0705d2
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m30s
feat: Implement comprehensive roadmap for feature updates and enhancements
This commit introduces a detailed roadmap for implementing various features, focusing on backend and frontend improvements. Key additions include:

- New database schema designs for financial audit logging, archiving lists, and categorizing items.
- Backend logic for financial audit logging, archiving functionality, and chore subtasks.
- Frontend UI updates for archiving lists, managing categories, and enhancing the chore interface.
- Introduction of a guest user flow and integration of Redis for caching to improve performance.

These changes aim to enhance the application's functionality, user experience, and maintainability.
2025-06-10 08:16:55 +02:00

1058 lines
33 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-wrapper" :class="{ 'supermarket-fullscreen': supermarktMode }">
<!-- Integrated Header -->
<div class="neo-list-card-header" v-show="!supermarktMode">
<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>
<!-- Supermarket Mode Header -->
<div v-if="supermarktMode" class="supermarket-header">
<div class="supermarket-title-row">
<VHeading :level="1" :text="list.name" class="supermarket-title" />
<button class="supermarket-exit-btn" @click="supermarktMode = false"
:aria-label="$t('listDetailPage.buttons.exitSupermarketMode')">
<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="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<VProgressBar :value="itemCompletionProgress" class="supermarket-progress" />
</div>
<!-- End Integrated Header -->
<ItemsList v-if="list" ref="itemsListRef" :items="list.items" :is-online="isOnline"
:supermarkt-mode="supermarktMode" :category-options="categoryOptions" :new-item="newItem"
:categories="categories" @delete-item="deleteItem" @checkbox-change="handleCheckboxChange"
@update-price="updateItemPrice" @start-edit="startItemEdit" @save-edit="saveItemEdit"
@cancel-edit="cancelItemEdit" @add-item="onAddItem" @handle-drag-end="handleDragEnd"
@update:newItemName="newItem.name = $event"
@update:newItemCategoryId="($event) => newItem.category_id = $event === null ? null : Number($event)" />
<!-- Expenses Section -->
<ExpenseSection v-if="list && !itemsAreLoading && !supermarktMode" :expenses="expenses"
:is-loading="listDetailStore.isLoading" :error="listDetailStore.error"
:current-user-id="authStore.user ? Number(authStore.user.id) : null"
:is-settlement-loading="isSettlementLoading"
@retry-fetch="listDetailStore.fetchListWithExpenses(String(list?.id))" @settle-share="openSettleShareModal" />
</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 -->
<OcrDialog v-model="showOcrDialogState" :is-adding="addingOcrItems" @add-items="addOcrItems" />
<!-- Cost Summary Dialog -->
<CostSummaryDialog v-model="showCostSummaryDialog" :summary="listCostSummary" :loading="costSummaryLoading"
:error="costSummaryError" />
<!-- Settle Share Modal -->
<SettleShareModal v-model="showSettleModal" :split="selectedSplitForSettlement" v-model:amount="settleAmount"
:error="settleAmountError" :is-loading="isSettlementLoading" @confirm="handleConfirmSettle" />
<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 { useAuthStore } from '@/stores/auth';
import { Decimal } from 'decimal.js';
import type { SettlementActivityCreate } from '@/types/expense';
import SettleShareModal from '@/components/list-detail/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 VProgressBar from '@/components/valerie/VProgressBar.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
import { useCategoryStore } from '@/stores/categoryStore';
import { storeToRefs } from 'pinia';
import ItemsList from '@/components/list-detail/ItemsList.vue';
import ExpenseSection from '@/components/list-detail/ExpenseSection.vue';
import OcrDialog from '@/components/list-detail/OcrDialog.vue';
import CostSummaryDialog from '@/components/list-detail/CostSummaryDialog.vue';
import { getApiErrorMessage } from '@/utils/errors';
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;
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
group_id?: number;
}
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;
}
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 itemsListRef = ref<InstanceType<typeof ItemsList> | null>(null);
const categoryOptions = computed(() => {
type CategoryOption = { id: number; name: string };
return [
{ label: 'No Category', value: null },
...categories.value.map((c: CategoryOption) => ({ label: c.name, value: c.id })),
];
});
// OCR
const showOcrDialogState = ref(false);
const addingOcrItems = ref(false);
// Cost Summary
const showCostSummaryDialog = ref(false);
const listCostSummary = ref<any | 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 selectedSplitForSettlement = ref<ExpenseSplit | 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;
category_id?: number | null;
}
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', t);
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 onAddItem = async () => {
const itemName = newItem.value.name.trim();
if (!list.value || !itemName) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.enterItemName'), type: 'warning' });
itemsListRef.value?.focusNewItemInput();
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;
itemsListRef.value?.focusNewItemInput();
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', t),
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', t), 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', t), 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', t), type: 'error' });
} finally {
item.deleting = false;
}
};
const handleCheckboxChange = (item: ItemWithUI, checked: boolean) => {
updateItem(item, checked);
};
const handleDragEnd = async (evt: any) => {
if (!list.value) return;
const { item, newIndex } = evt;
// Find the current index in the flat list to see if it actually moved
const oldIndex = list.value.items.findIndex(i => i.id === item.id);
if (oldIndex === newIndex) return;
const originalList = [...list.value.items];
// Reorder the list optimistically
const movedItem = originalList.splice(oldIndex, 1)[0];
originalList.splice(newIndex, 0, movedItem);
list.value.items = originalList;
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ position: newIndex + 1, 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; // Revert on failure
notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed', t),
type: 'error'
});
}
};
const openOcrDialog = () => {
showOcrDialogState.value = true;
};
const addOcrItems = async (items: { name: string }[]) => {
if (!list.value || !items.length) return;
addingOcrItems.value = true;
let successCount = 0;
try {
for (const item of items) {
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' });
}
showOcrDialogState.value = false;
} catch (err) {
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.addOcrItemsFailed', t), 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', t);
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 { getExpenses: expenses, currentList: listFromStore } = storeToRefs(listDetailStore);
const allFetchedGroups = ref<Group[]>([]);
const getGroupName = (groupId: number): string => {
const group = allFetchedGroups.value.find((g: Group) => g.id === groupId);
return group?.name || `Group ${groupId}`;
};
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
// Escape key to exit supermarket mode
if (event.key === 'Escape' && supermarktMode.value) {
event.preventDefault();
supermarktMode.value = false;
return;
}
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 || supermarktMode.value) {
return;
}
event.preventDefault();
if (itemsListRef.value?.focusNewItemInput) {
itemsListRef.value.focusNewItemInput();
}
}
});
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', t),
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;
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 handleConfirmSettle = async () => {
if (!selectedSplitForSettlement.value || !authStore.user?.id) {
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(list.value?.id),
expense_split_id: selectedSplitForSettlement.value.id,
activity_data: activityData,
});
if (success) {
notificationStore.addNotification({ message: t('listDetailPage.notifications.settleShareSuccess'), type: 'success' });
showSettleModal.value = false;
} 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));
}
// Also, update the main expenses from the store
if (listDetailStore.currentList?.id) {
listDetailStore.fetchListWithExpenses(String(listDetailStore.currentList.id));
}
};
</script>
<style scoped>
.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-wrapper {
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;
}
.supermarkt-mode-toggle {
margin-top: 1rem;
}
/* Supermarket Fullscreen Mode */
.supermarket-fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 1000 !important;
background: white !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
overflow-y: auto !important;
}
.supermarket-header {
position: sticky;
top: 0;
background: white;
border-bottom: 2px solid #111;
padding: 1rem 1.5rem;
z-index: 100;
}
.supermarket-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.supermarket-title {
font-size: 2rem;
font-weight: 800;
margin: 0;
}
.supermarket-exit-btn {
background: #ef4444;
color: white;
border: 2px solid #111;
border-radius: 8px;
padding: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 3px 3px 0 #111;
}
.supermarket-exit-btn:hover {
background: #dc2626;
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 #111;
}
.supermarket-exit-btn:active {
transform: translate(1px, 1px);
box-shadow: 1px 1px 0 #111;
}
.supermarket-progress {
margin: 0;
}
/* Adjust ItemsList for supermarket mode */
.supermarket-fullscreen .neo-item-list-cotainer {
border: none !important;
box-shadow: none !important;
border-radius: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.supermarket-fullscreen .category-header {
font-size: 1.8rem;
font-weight: 800;
padding: 1rem 1.5rem;
margin: 0;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
position: sticky;
top: 120px;
/* Account for supermarket header */
z-index: 90;
}
.supermarket-fullscreen .neo-list-item {
padding: 1.5rem;
font-size: 1.2rem;
border-bottom: 1px solid #eee;
}
.supermarket-fullscreen .neo-checkbox-label {
gap: 1.5rem;
}
.supermarket-fullscreen .neo-checkbox-label input[type="checkbox"] {
width: 24px;
height: 24px;
}
.supermarket-fullscreen .new-item-input-container {
padding: 1.5rem;
background: #f8f9fa;
border-top: 2px solid #111;
position: sticky;
bottom: 0;
z-index: 95;
}
</style>