mitlist/fe/src/pages/ListsPage.vue
Mohamad 5c882996a9
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s
Enhance financials API and list expense retrieval
- Updated the `check_list_access_for_financials` function to allow access for list creators and members.
- Refactored the `list_expenses` endpoint to support filtering by `list_id`, `group_id`, and `isRecurring`, providing more flexible expense retrieval options.
- Introduced a new `read_list_expenses` endpoint to fetch expenses associated with a specific list, ensuring proper permission checks.
- Enhanced expense retrieval logic in the `get_expenses_for_list` and `get_user_accessible_expenses` functions to include settlement activities.
- Updated frontend API configuration to reflect new endpoint paths and ensure consistency across the application.
2025-06-04 17:50:19 +02:00

902 lines
22 KiB
Vue

<template>
<main class="container page-padding">
<!-- <h1 class="mb-3">{{ pageTitle }}</h1> -->
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
</template>
</VAlert>
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="t(noListsMessageKey)">
<template #default>
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
<p v-else>{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
</template>
<template #empty-actions>
<VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
{{ t('listsPage.createNewListButton') }}
</VButton>
</template>
</VCard>
<div v-else-if="loading && lists.length === 0" class="loading-placeholder">
{{ t('listsPage.loadingLists') }}
</div>
<div v-else>
<div class="neo-lists-grid">
<div v-for="list in lists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
:data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
:data-item-temp-id="item.tempId" :class="{ 'is-updating': item.updating }">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
:disabled="item.id === undefined && item.tempId !== undefined" />
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
item.name }}</span>
</label>
</li>
<li class="neo-list-item new-item-input-container">
<label class="neo-checkbox-label">
<input type="checkbox" disabled />
<input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')" ref="newItemInputRefs"
:data-list-id="list.id" @keyup.enter="addNewItem(list, $event)"
@blur="handleNewItemBlur(list, $event)" @click.stop />
</label>
</li>
</ul>
</div>
<div class="neo-create-list-card" @click="showCreateModal = true" ref="createListCardRef">
{{ t('listsPage.createCard.title') }}
</div>
</div>
</div>
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed
import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed
import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
const { t } = useI18n();
interface List {
id: number;
name: string;
description?: string;
is_complete: boolean;
updated_at: string;
created_by_id: number;
group_id?: number | null;
created_at: string;
version: number;
items: Item[];
}
interface Group {
id: number;
name: string;
}
interface Item {
id: number | string;
tempId?: string;
name: string;
quantity?: string | number;
is_complete: boolean;
price?: number | null;
version: number;
updating?: boolean;
created_at?: string;
updated_at: string;
}
const props = defineProps<{
groupId?: number | string;
}>();
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const error = ref<string | null>(null);
const lists = ref<(List & { items: Item[] })[]>([]);
const allFetchedGroups = ref<Group[]>([]);
const currentViewedGroup = ref<Group | null>(null);
const showCreateModal = ref(false);
const newItemInputRefs = ref<HTMLInputElement[]>([]);
const currentGroupId = computed<number | null>(() => {
const idFromProp = props.groupId;
const idFromRoute = route.params.groupId;
if (idFromProp) {
return typeof idFromProp === 'string' ? parseInt(idFromProp, 10) : idFromProp;
}
if (idFromRoute) {
return parseInt(idFromRoute as string, 10);
}
return null;
});
const fetchCurrentViewGroupName = async () => {
if (!currentGroupId.value) {
currentViewedGroup.value = null;
return;
}
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
if (found) {
currentViewedGroup.value = found;
return;
}
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)));
currentViewedGroup.value = response.data as Group;
} catch (err) {
console.error(`Failed to fetch group name for ID ${currentGroupId.value}:`, err);
currentViewedGroup.value = null;
}
};
const pageTitle = computed(() => {
if (currentGroupId.value) {
return currentViewedGroup.value
? t('listsPage.pageTitle.forGroup', { groupName: currentViewedGroup.value.name })
: t('listsPage.pageTitle.forGroupId', { groupId: currentGroupId.value });
}
return t('listsPage.pageTitle.myLists');
});
const noListsMessageKey = computed(() => {
if (currentGroupId.value) {
return 'listsPage.emptyState.noListsForGroup';
}
return 'listsPage.emptyState.noListsYet';
});
const fetchAllAccessibleGroups = async () => {
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
allFetchedGroups.value = (response.data as Group[]);
} catch (err) {
console.error('Failed to fetch groups for modal:', err);
// Not critical for page load, modal might not show groups
}
};
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const loadCachedData = () => {
const now = Date.now();
if (cachedLists.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
lists.value = JSON.parse(JSON.stringify(cachedLists.value));
loading.value = false;
}
};
const fetchLists = async () => {
error.value = null;
try {
const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
: API_ENDPOINTS.LISTS.BASE;
const response = await apiClient.get(endpoint);
lists.value = (response.data as (List & { items: Item[] })[]).map(list => ({
...list,
items: list.items || []
}));
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
console.error(error.value, err);
if (cachedLists.value.length === 0) lists.value = [];
}
};
const fetchListsAndGroups = async () => {
loading.value = true;
try {
await Promise.all([
fetchLists(),
fetchAllAccessibleGroups()
]);
await fetchCurrentViewGroupName();
} catch (err) {
console.error('Error in fetchListsAndGroups:', err);
} finally {
loading.value = false;
}
};
const availableGroupsForModal = computed(() => {
return allFetchedGroups.value.map(group => ({
label: group.name,
value: group.id,
}));
});
const getGroupName = (groupId?: number | null): string | undefined => {
if (!groupId) return undefined;
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
}
const onListCreated = (newList: List & { items: Item[] }) => {
lists.value.push({
...newList,
items: newList.items || []
});
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
// Consider animating new list card in if desired
};
const toggleItem = async (list: List, item: Item) => {
if (typeof item.id === 'string' && item.id.startsWith('temp-')) {
return;
}
const originalIsComplete = item.is_complete;
item.is_complete = !item.is_complete;
item.updating = true;
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.id), String(item.id)),
{
is_complete: item.is_complete,
version: item.version,
name: item.name,
quantity: item.quantity,
price: item.price
}
);
item.version++;
} catch (err) {
item.is_complete = originalIsComplete;
console.error('Failed to update item:', err);
const itemElement = document.querySelector(`.neo-list-item[data-item-id="${item.id}"]`);
if (itemElement) {
itemElement.classList.add('error-flash');
setTimeout(() => itemElement.classList.remove('error-flash'), 800);
}
} finally {
item.updating = false;
}
};
const addNewItem = async (list: List, event: Event) => {
const inputElement = event.target as HTMLInputElement;
const itemName = inputElement.value.trim();
if (!itemName) {
if (event.type === 'blur') inputElement.value = '';
return;
}
const localTempId = `temp-${Date.now()}`;
const newItem: Item = {
id: localTempId,
tempId: localTempId,
name: itemName,
is_complete: false,
version: 0,
updating: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
list.items.push(newItem);
const originalInputValue = inputElement.value;
inputElement.value = '';
inputElement.disabled = true;
await nextTick();
const newItemLiElement = document.querySelector(`.neo-list-item[data-item-temp-id="${localTempId}"]`);
if (newItemLiElement) {
newItemLiElement.classList.add('item-appear');
}
try {
const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(list.id)), {
name: itemName,
is_complete: false,
});
const addedItemFromServer = response.data as Item;
const itemIndex = list.items.findIndex(i => i.tempId === localTempId);
if (itemIndex !== -1) {
list.items.splice(itemIndex, 1, {
...newItem,
...addedItemFromServer,
updating: false,
tempId: undefined
});
}
if (event.type === 'keyup' && (event as KeyboardEvent).key === 'Enter') {
inputElement.disabled = false;
inputElement.focus();
} else {
inputElement.disabled = false;
}
} catch (err) {
console.error('Failed to add new item:', err);
list.items = list.items.filter(i => i.tempId !== localTempId);
inputElement.value = originalInputValue;
inputElement.disabled = false;
inputElement.style.transition = 'border-color 0.5s ease';
inputElement.style.borderColor = 'red';
setTimeout(() => {
inputElement.style.borderColor = '#ccc';
}, 500);
}
};
const handleNewItemBlur = (list: List, event: Event) => {
const inputElement = event.target as HTMLInputElement;
if (inputElement.value.trim()) {
addNewItem(list, event);
}
};
const navigateToList = (listId: number) => {
const selectedList = lists.value.find(l => l.id === listId);
if (selectedList) {
const listShell = {
id: selectedList.id,
name: selectedList.name,
description: selectedList.description,
group_id: selectedList.group_id,
};
sessionStorage.setItem('listDetailShell', JSON.stringify(listShell));
}
router.push({ name: 'ListDetail', params: { id: listId } }); // Ensure 'ListDetail' route exists
};
const prefetchListDetails = async (listId: number) => {
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId)));
const fullListData = response.data;
sessionStorage.setItem(`listDetailFull_${listId}`, JSON.stringify(fullListData));
} catch (err) {
console.warn('Pre-fetch failed:', err);
}
};
let intersectionObserver: IntersectionObserver | null = null;
const setupIntersectionObserver = () => {
if (intersectionObserver) intersectionObserver.disconnect();
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const listId = entry.target.getAttribute('data-list-id');
if (listId) {
const cachedFullData = sessionStorage.getItem(`listDetailFull_${listId}`);
if (!cachedFullData) {
prefetchListDetails(Number(listId));
}
}
}
});
}, {
rootMargin: '100px 0px',
threshold: 0.01
});
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
intersectionObserver!.observe(card);
});
});
};
const touchActiveListId = ref<number | null>(null);
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
const handleTouchEnd = () => { touchActiveListId.value = null; };
onMounted(() => {
loadCachedData();
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
}
});
});
watch(currentGroupId, () => {
loadCachedData();
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
}
});
});
watch(() => lists.value.length, (newLength, oldLength) => {
if (newLength > 0 && oldLength === 0 && !loading.value) {
setupIntersectionObserver();
}
if (newLength > 0) {
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
if (intersectionObserver) {
intersectionObserver.observe(card);
}
});
});
}
});
onUnmounted(() => {
if (intersectionObserver) {
intersectionObserver.disconnect();
}
});
</script>
<style scoped>
/* Ensure --light is defined in your global styles or here, e.g., :root { --light: #fff; } */
.loading-placeholder {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
color: #555;
}
.page-padding {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.neo-lists-grid {
columns: 3 500px;
column-gap: 2rem;
margin-bottom: 2rem;
}
.neo-list-card,
.neo-create-list-card {
break-inside: avoid;
border-radius: 18px;
box-shadow: 6px 6px 0 var(--dark);
width: 100%;
margin: 0 0 2rem 0;
background: var(--light);
display: flex;
flex-direction: column;
border: 3px solid var(--dark);
padding: 1.5rem;
cursor: pointer;
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
-webkit-tap-highlight-color: transparent;
}
.neo-list-card:hover {
transform: translateY(-4px);
box-shadow: 6px 10px 0 var(--dark);
/* border-color: var(--secondary); */
}
.neo-list-card.touch-active {
transform: scale(0.97);
box-shadow: 3px 3px 0 var(--dark);
transition: transform 0.1s ease-out, box-shadow 0.1s ease-out;
}
.neo-list-header {
font-weight: 900;
font-size: 1.25rem;
margin-bottom: 0.5rem;
letter-spacing: 0.5px;
color: var(--dark);
}
.neo-list-desc {
font-size: 1rem;
color: var(--dark);
opacity: 0.7;
margin-bottom: 1.2rem;
font-weight: 500;
line-height: 1.4;
}
.neo-item-list {
list-style: none;
padding: 0;
margin: 0;
}
.neo-list-item {
margin-bottom: 0.8rem;
font-size: 1.05rem;
font-weight: 600;
display: flex;
align-items: center;
position: relative;
}
.neo-list-item.is-updating .checkbox-text-span {
opacity: 0.6;
}
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: fit-content;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 18px;
width: 18px;
outline: none;
border: 2px solid var(--dark);
margin: 0;
cursor: pointer;
background: var(--light);
border-radius: 4px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
background: var(--light);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
height: 2px;
background: var(--primary);
border-radius: 2px;
opacity: 0;
transition: opacity 0.2s ease;
}
.neo-checkbox-label input[type="checkbox"]::before {
width: 0px;
right: 55%;
transform-origin: right bottom;
}
.neo-checkbox-label input[type="checkbox"]::after {
width: 0px;
left: 45%;
transform-origin: left bottom;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
background: var(--light);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]:checked::before {
opacity: 1;
animation: check-01 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
animation: check-02 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.checkbox-text-span {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::before,
.checkbox-text-span::after {
content: "";
position: absolute;
left: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::before {
height: 2px;
width: 8px;
top: 50%;
transform: translateY(-50%);
background: var(--secondary);
border-radius: 2px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-text-span::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
top: 50%;
left: 130%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
transform: translateX(4px);
}
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after {
animation: firework 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.15s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.7;
text-decoration: line-through var(--dark);
}
.new-item-input-container .neo-checkbox-label {
width: 100%;
}
.neo-new-item-input {
all: unset;
width: 100%;
font-size: 1.05rem;
font-weight: 500;
color: #444;
padding: 0.2rem 0;
border-bottom: 1px dashed #ccc;
transition: border-color 0.2s ease;
}
.neo-new-item-input:focus {
border-bottom-color: var(--secondary);
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 400;
}
.neo-new-item-input:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: transparent;
}
.neo-create-list-card {
border: 3px dashed var(--dark);
background: var(--light);
padding: 2.5rem 0;
text-align: center;
font-weight: 900;
font-size: 1.1rem;
color: var(--dark);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
margin-bottom: 2.5rem;
transition: all 0.15s ease-out;
}
.neo-create-list-card:hover {
background: var(--light);
transform: translateY(-3px) scale(1.01);
box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.05);
/* border-color: var(--secondary); */
color: var(--primary);
}
@media (max-width: 900px) {
.neo-lists-grid {
columns: 2 260px;
column-gap: 1.2rem;
}
.neo-list-card,
.neo-create-list-card {
margin-bottom: 1.2rem;
padding-left: 1rem;
padding-right: 1rem;
}
}
@media (max-width: 600px) {
.page-padding {
padding: 0.5rem;
}
.neo-lists-grid {
columns: 1 280px;
column-gap: 1rem;
}
.neo-list-card {
margin-bottom: 1rem;
padding: 1rem;
font-size: 1rem;
min-height: 80px;
}
.neo-list-header {
font-size: 1.1rem;
margin-bottom: 0.3rem;
}
.neo-list-desc {
font-size: 0.9rem;
margin-bottom: 0.8rem;
}
.neo-checkbox-label input[type="checkbox"] {
width: 1.4em;
height: 1.4em;
}
.neo-list-item {
/* padding: 0.8rem 0; */
/* Removed as margin-bottom is used */
margin-bottom: 0.7rem;
/* Adjusted for mobile */
}
}
@keyframes check-01 {
0% {
width: 4px;
top: auto;
transform: rotate(0);
}
50% {
width: 0px;
top: auto;
transform: rotate(0);
}
51% {
width: 0px;
top: 8px;
transform: rotate(45deg);
}
100% {
width: 6px;
top: 8px;
transform: rotate(45deg);
}
}
@keyframes check-02 {
0% {
width: 4px;
top: auto;
transform: rotate(0);
}
50% {
width: 0px;
top: auto;
transform: rotate(0);
}
51% {
width: 0px;
top: 8px;
transform: rotate(-45deg);
}
100% {
width: 11px;
top: 8px;
transform: rotate(-45deg);
}
}
@keyframes firework {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(0.5);
box-shadow:
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent),
0 0 0 0 var(--accent);
}
50% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.2);
box-shadow:
0 -15px 0 0 var(--accent),
14px -8px 0 0 var(--accent),
14px 8px 0 0 var(--accent),
0 15px 0 0 var(--accent),
-14px 8px 0 0 var(--accent),
-14px -8px 0 0 var(--accent);
}
}
@keyframes error-flash {
0% {
background-color: var(--danger);
opacity: 0.2;
}
100% {
background-color: transparent;
opacity: 0;
}
}
@keyframes item-appear {
0% {
opacity: 0;
transform: translateY(-15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.error-flash {
animation: error-flash 0.8s ease-out forwards;
}
.item-appear {
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
</style>