From 0aa88d0af7253f204b1ee0afd28d828dbba9028d Mon Sep 17 00:00:00 2001 From: mohamad <mohamad> Date: Sat, 7 Jun 2025 15:26:46 +0200 Subject: [PATCH] Enhance ListDetailPage with collapsible expense items and improved UI --- fe/src/pages/ListDetailPage.vue | 333 ++++++++++++++++++++++---------- 1 file changed, 229 insertions(+), 104 deletions(-) diff --git a/fe/src/pages/ListDetailPage.vue b/fe/src/pages/ListDetailPage.vue index 17644f9..12d2455 100644 --- a/fe/src/pages/ListDetailPage.vue +++ b/fe/src/pages/ListDetailPage.vue @@ -41,6 +41,10 @@ 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> @@ -161,82 +165,110 @@ @click.stop /> </label> </li> - </div> - <!-- Expenses Section --> - <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="btn btn-sm btn-primary" @click="showCreateExpenseForm = true" icon-left="plus"> - {{ $t('listDetailPage.expensesSection.addExpenseButton') }} - </button> - </div> - <VCard v-if="listDetailStore.isLoading && expenses.length === 0" class="py-10 text-center"> - <VSpinner :label="$t('listDetailPage.expensesSection.loading')" size="lg" /> - </VCard> - <VAlert v-else-if="listDetailStore.error && expenses.length === 0" type="error" class="mt-4"> - <p>{{ listDetailStore.error }}</p> - <template #actions> - <VButton @click="listDetailStore.fetchListWithExpenses(String(list?.id))"> - {{ $t('listDetailPage.expensesSection.retryButton') }} - </VButton> - </template> - </VAlert> - <VCard v-else-if="(!expenses || expenses.length === 0) && !listDetailStore.isLoading" variant="empty-state" - empty-icon="receipt" :empty-title="$t('listDetailPage.expensesSection.emptyStateTitle')" - :empty-message="$t('listDetailPage.expensesSection.emptyStateMessage')" class="mt-4"> - </VCard> - <div v-else> - <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) }} + <!-- Expenses Section --> + <section v-if="list && !itemsAreLoading" class="neo-expenses-section"> + <VCard v-if="listDetailStore.isLoading && expenses.length === 0" class="py-10 text-center"> + <VSpinner :label="$t('listDetailPage.expensesSection.loading')" size="lg" /> + </VCard> + <VAlert v-else-if="listDetailStore.error && expenses.length === 0" type="error" class="mt-4"> + <p>{{ listDetailStore.error }}</p> + <template #actions> + <VButton @click="listDetailStore.fetchListWithExpenses(String(list?.id))"> + {{ $t('listDetailPage.expensesSection.retryButton') }} + </VButton> + </template> + </VAlert> + <VCard v-else-if="(!expenses || expenses.length === 0) && !listDetailStore.isLoading" variant="empty-state" + empty-icon="receipt" :empty-title="$t('listDetailPage.expensesSection.emptyStateTitle')" + :empty-message="$t('listDetailPage.expensesSection.emptyStateMessage')" class="mt-4"> + </VCard> + <div v-else class="neo-expense-list"> + <div v-for="expense in expenses" :key="expense.id" class="neo-expense-item-wrapper"> + <div class="neo-expense-item" @click="toggleExpense(expense.id)" + :class="{ 'is-expanded': isExpenseExpanded(expense.id) }"> + <div class="expense-main-content"> + <div class="expense-icon-container"> + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <line x1="12" x2="12" y1="2" y2="22"></line> + <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path> + </svg> + </div> + <div class="expense-text-content"> + <div class="neo-expense-header"> + {{ expense.description }} + </div> + <div class="neo-expense-details"> + {{ formatCurrency(expense.total_amount) }} — + {{ $t('listDetailPage.expensesSection.paidBy') }} <strong>{{ expense.paid_by_user?.name || + expense.paid_by_user?.email }}</strong> + </div> + </div> + </div> + <div class="expense-side-content"> + <span class="neo-expense-status" :class="getStatusClass(expense.overall_settlement_status)"> + {{ getOverallExpenseStatusText(expense.overall_settlement_status) }} </span> + <div class="expense-toggle-icon"> + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + class="feather feather-chevron-down"> + <polyline points="6 9 12 15 18 9"></polyline> + </svg> + </div> </div> - <div 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> + + <!-- Collapsible content --> + <div v-if="isExpenseExpanded(expense.id)" class="neo-splits-container"> + <div class="neo-splits-list"> + <div v-for="split in expense.splits" :key="split.id" class="neo-split-item"> + <div class="split-col split-user"> + <strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> + </div> + <div class="split-col split-owes"> + {{ $t('listDetailPage.expensesSection.owes') }} <strong>{{ + formatCurrency(split.owed_amount) }}</strong> + </div> + <div class="split-col split-status"> + <span class="neo-expense-status" :class="getStatusClass(split.status)"> + {{ getSplitStatusText(split.status) }} + </span> + </div> + <div class="split-col split-paid-info"> + <div v-if="split.paid_at" class="paid-details"> + {{ $t('listDetailPage.expensesSection.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }} + <span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new + Date(split.paid_at).toLocaleDateString() }}</span> + </div> + </div> + <div class="split-col split-action"> + <button + v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID" + class="btn btn-sm btn-primary" @click="openSettleShareModal(expense, split)" + :disabled="isSettlementLoading"> + {{ $t('listDetailPage.expensesSection.settleShareButton') }} + </button> + </div> + <ul v-if="split.settlement_activities && split.settlement_activities.length > 0" + class="neo-settlement-activities"> + <li v-for="activity in split.settlement_activities" :key="activity.id"> + {{ $t('listDetailPage.expensesSection.activityLabel') }} {{ + formatCurrency(activity.amount_paid) }} + {{ + $t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User + ${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{ new + Date(activity.paid_at).toLocaleDateString() }} + </li> + </ul> + </div> </div> - <button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID" - class="btn btn-sm btn-primary" @click="openSettleShareModal(expense, split)" - :disabled="isSettlementLoading"> - {{ $t('listDetailPage.expensesSection.settleShareButton') }} - </button> - <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> + </section> + </div> <!-- Create Expense Form --> <CreateExpenseForm v-if="showCreateExpenseForm" :list-id="list?.id" :group-id="list?.group_id ?? undefined" @@ -346,9 +378,9 @@ <template #footer> <VButton variant="neutral" @click="closeSettleShareModal">{{ $t('listDetailPage.settleShareModal.cancelButton') - }}</VButton> + }}</VButton> <VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton') - }}</VButton> + }}</VButton> </template> </VModal> @@ -1277,55 +1309,103 @@ const handleDragEnd = async (evt: any) => { } }; +const expandedExpenses = ref<Set<number>>(new Set()); + +const toggleExpense = (expenseId: number) => { + const newSet = new Set(expandedExpenses.value); + if (newSet.has(expenseId)) { + newSet.delete(expenseId); + } else { + // Optional: collapse others when one is opened + // newSet.clear(); + newSet.add(expenseId); + } + expandedExpenses.value = newSet; +}; + +const isExpenseExpanded = (expenseId: number) => { + return expandedExpenses.value.has(expenseId); +}; + </script> <style scoped> /* Existing styles */ .neo-expenses-section { - margin-top: 3rem; - padding: 1.2rem; - border: 3px solid #111; - border-radius: 18px; - background: var(--light); - box-shadow: 6px 6px 0 #111; + padding: 0; + margin-top: 1.2rem; } -.neo-expenses-header { +.neo-expense-list { + background-color: rgb(255, 248, 240); + /* Container for expense items */ + border-radius: 12px; + overflow: hidden; + border: 1px solid #f0e5d8; +} + +.neo-expense-item-wrapper { + border-bottom: 1px solid #f0e5d8; +} + +.neo-expense-item-wrapper:last-child { + border-bottom: none; +} + +.neo-expense-item { + padding: 1rem 1.2rem; + cursor: pointer; display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; - flex-wrap: wrap; + transition: background-color 0.2s ease; +} + +.neo-expense-item:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +.neo-expense-item.is-expanded .expense-toggle-icon { + transform: rotate(180deg); +} + +.expense-main-content { + display: flex; + align-items: center; gap: 1rem; } -.neo-expenses-title { - font-size: 1.75rem; - font-weight: 900; - color: #111; - margin-bottom: 0.75rem; +.expense-icon-container { + color: #d99a53; } -.neo-expense-card { - background: var(--light); - border: 3px solid #111; - border-radius: 18px; - box-shadow: 6px 6px 0 #111; - margin-bottom: 2rem; - padding: 1.2rem; +.expense-text-content { + display: flex; + flex-direction: column; +} + +.expense-side-content { + display: flex; + align-items: center; + gap: 1rem; +} + +.expense-toggle-icon { + color: #888; + transition: transform 0.3s ease; } .neo-expense-header { - font-size: 1.3rem; - font-weight: 700; - margin-bottom: 0.5rem; + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.1rem; } .neo-expense-details, .neo-split-details { - font-size: 0.95rem; - color: #333; + font-size: 0.9rem; + color: #555; margin-bottom: 0.3rem; } @@ -1345,6 +1425,7 @@ const handleDragEnd = async (evt: any) => { vertical-align: baseline; border-radius: 0.375rem; margin-left: 0.5rem; + color: #22c55e; } .status-unpaid { @@ -1362,21 +1443,63 @@ const handleDragEnd = async (evt: any) => { color: #22c55e; } +.neo-splits-container { + padding: 0.5rem 1.2rem 1.2rem; + background-color: rgba(255, 255, 255, 0.5); +} + .neo-splits-list { - margin-top: 1rem; - padding-left: 1rem; - border-left: 2px solid #eee; + margin-top: 0rem; + padding-left: 0; + border-left: none; } .neo-split-item { - padding: 0.5rem 0; - border-bottom: 1px dashed #f0f0f0; + padding: 0.75rem 0; + border-bottom: 1px dashed #f0e5d8; + display: grid; + grid-template-areas: + "user owes status paid action" + "activities activities activities activities activities"; + grid-template-columns: 1.5fr 1fr 1fr 1.5fr auto; + gap: 0.5rem 1rem; + align-items: center; } .neo-split-item:last-child { border-bottom: none; } +.split-col.split-user { + grid-area: user; +} + +.split-col.split-owes { + grid-area: owes; +} + +.split-col.split-status { + grid-area: status; +} + +.split-col.split-paid-info { + grid-area: paid; +} + +.split-col.split-action { + grid-area: action; + justify-self: end; +} + +.split-col.neo-settlement-activities { + grid-area: activities; + font-size: 0.8em; + color: #555; + padding-left: 1em; + list-style-type: disc; + margin-top: 0.5em; +} + .neo-settlement-activities { font-size: 0.8em; color: #555; @@ -1396,8 +1519,9 @@ const handleDragEnd = async (evt: any) => { } .page-padding { - padding: 1rem; padding-inline: 0; + padding-block-start: 1rem; + padding-block-end: 5rem; max-width: 1200px; margin: 0 auto; } @@ -1490,13 +1614,14 @@ const handleDragEnd = async (evt: any) => { .neo-item-list { list-style: none; padding: 1.2rem; + padding-inline: 0; margin-bottom: 0; border-bottom: 1px solid #eee; background: var(--light); } .neo-list-item { - padding: 1rem 1.2rem; + padding: 1rem 0; border-bottom: 1px solid #eee; transition: background-color 0.2s ease; }