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) }} &mdash;
+                      {{ $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;
 }