
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m30s
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.
1058 lines
33 KiB
Vue
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> |