Enhance ListDetailPage with collapsible expense items and improved UI

This commit is contained in:
mohamad 2025-06-07 15:26:46 +02:00
parent fc09848a33
commit 0aa88d0af7

View File

@ -41,6 +41,10 @@
size="sm">{{ size="sm">{{
$t('listDetailPage.buttons.addViaOcr') }} $t('listDetailPage.buttons.addViaOcr') }}
</button> </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>
</div> </div>
<p v-if="list.description" class="neo-description-internal">{{ list.description }}</p> <p v-if="list.description" class="neo-description-internal">{{ list.description }}</p>
@ -161,16 +165,9 @@
@click.stop /> @click.stop />
</label> </label>
</li> </li>
</div>
<!-- Expenses Section --> <!-- Expenses Section -->
<section v-if="list && !itemsAreLoading" class="neo-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"> <VCard v-if="listDetailStore.isLoading && expenses.length === 0" class="py-10 text-center">
<VSpinner :label="$t('listDetailPage.expensesSection.loading')" size="lg" /> <VSpinner :label="$t('listDetailPage.expensesSection.loading')" size="lg" />
</VCard> </VCard>
@ -186,46 +183,79 @@
empty-icon="receipt" :empty-title="$t('listDetailPage.expensesSection.emptyStateTitle')" empty-icon="receipt" :empty-title="$t('listDetailPage.expensesSection.emptyStateTitle')"
:empty-message="$t('listDetailPage.expensesSection.emptyStateMessage')" class="mt-4"> :empty-message="$t('listDetailPage.expensesSection.emptyStateMessage')" class="mt-4">
</VCard> </VCard>
<div v-else> <div v-else class="neo-expense-list">
<div v-for="expense in expenses" :key="expense.id" class="neo-expense-card"> <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"> <div class="neo-expense-header">
{{ expense.description }} - {{ formatCurrency(expense.total_amount) }} {{ 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)"> <span class="neo-expense-status" :class="getStatusClass(expense.overall_settlement_status)">
{{ getOverallExpenseStatusText(expense.overall_settlement_status) }} {{ getOverallExpenseStatusText(expense.overall_settlement_status) }}
</span> </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>
<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>
<!-- Collapsible content -->
<div v-if="isExpenseExpanded(expense.id)" class="neo-splits-container">
<div class="neo-splits-list"> <div class="neo-splits-list">
<div v-for="split in expense.splits" :key="split.id" class="neo-split-item"> <div v-for="split in expense.splits" :key="split.id" class="neo-split-item">
<div class="neo-split-details"> <div class="split-col split-user">
<strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong> {{ <strong>{{ split.user?.name || split.user?.email || `User ID: ${split.user_id}` }}</strong>
$t('listDetailPage.expensesSection.owes') }} {{ </div>
formatCurrency(split.owed_amount) }} <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)"> <span class="neo-expense-status" :class="getStatusClass(split.status)">
{{ getSplitStatusText(split.status) }} {{ getSplitStatusText(split.status) }}
</span> </span>
</div> </div>
<div class="neo-split-details"> <div class="split-col split-paid-info">
<div v-if="split.paid_at" class="paid-details">
{{ $t('listDetailPage.expensesSection.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }} {{ $t('listDetailPage.expensesSection.paidAmount') }} {{ getPaidAmountForSplitDisplay(split) }}
<span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new <span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new
Date(split.paid_at).toLocaleDateString() }}</span> Date(split.paid_at).toLocaleDateString() }}</span>
</div> </div>
<button v-if="split.user_id === authStore.user?.id && split.status !== ExpenseSplitStatusEnum.PAID" </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)" class="btn btn-sm btn-primary" @click="openSettleShareModal(expense, split)"
:disabled="isSettlementLoading"> :disabled="isSettlementLoading">
{{ $t('listDetailPage.expensesSection.settleShareButton') }} {{ $t('listDetailPage.expensesSection.settleShareButton') }}
</button> </button>
</div>
<ul v-if="split.settlement_activities && split.settlement_activities.length > 0" <ul v-if="split.settlement_activities && split.settlement_activities.length > 0"
class="neo-settlement-activities"> class="neo-settlement-activities">
<li v-for="activity in split.settlement_activities" :key="activity.id"> <li v-for="activity in split.settlement_activities" :key="activity.id">
{{ $t('listDetailPage.expensesSection.activityLabel') }} {{ formatCurrency(activity.amount_paid) }} {{ $t('listDetailPage.expensesSection.activityLabel') }} {{
formatCurrency(activity.amount_paid) }}
{{ {{
$t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User $t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{ new ${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{ new
@ -236,7 +266,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
</div>
<!-- Create Expense Form --> <!-- Create Expense Form -->
<CreateExpenseForm v-if="showCreateExpenseForm" :list-id="list?.id" :group-id="list?.group_id ?? undefined" <CreateExpenseForm v-if="showCreateExpenseForm" :list-id="list?.id" :group-id="list?.group_id ?? undefined"
@ -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> </script>
<style scoped> <style scoped>
/* Existing styles */ /* Existing styles */
.neo-expenses-section { .neo-expenses-section {
margin-top: 3rem; padding: 0;
padding: 1.2rem; margin-top: 1.2rem;
border: 3px solid #111;
border-radius: 18px;
background: var(--light);
box-shadow: 6px 6px 0 #111;
} }
.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; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.5rem; transition: background-color 0.2s ease;
flex-wrap: wrap; }
.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; gap: 1rem;
} }
.neo-expenses-title { .expense-icon-container {
font-size: 1.75rem; color: #d99a53;
font-weight: 900;
color: #111;
margin-bottom: 0.75rem;
} }
.neo-expense-card { .expense-text-content {
background: var(--light); display: flex;
border: 3px solid #111; flex-direction: column;
border-radius: 18px; }
box-shadow: 6px 6px 0 #111;
margin-bottom: 2rem; .expense-side-content {
padding: 1.2rem; display: flex;
align-items: center;
gap: 1rem;
}
.expense-toggle-icon {
color: #888;
transition: transform 0.3s ease;
} }
.neo-expense-header { .neo-expense-header {
font-size: 1.3rem; font-size: 1.1rem;
font-weight: 700; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.1rem;
} }
.neo-expense-details, .neo-expense-details,
.neo-split-details { .neo-split-details {
font-size: 0.95rem; font-size: 0.9rem;
color: #333; color: #555;
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
@ -1345,6 +1425,7 @@ const handleDragEnd = async (evt: any) => {
vertical-align: baseline; vertical-align: baseline;
border-radius: 0.375rem; border-radius: 0.375rem;
margin-left: 0.5rem; margin-left: 0.5rem;
color: #22c55e;
} }
.status-unpaid { .status-unpaid {
@ -1362,21 +1443,63 @@ const handleDragEnd = async (evt: any) => {
color: #22c55e; color: #22c55e;
} }
.neo-splits-container {
padding: 0.5rem 1.2rem 1.2rem;
background-color: rgba(255, 255, 255, 0.5);
}
.neo-splits-list { .neo-splits-list {
margin-top: 1rem; margin-top: 0rem;
padding-left: 1rem; padding-left: 0;
border-left: 2px solid #eee; border-left: none;
} }
.neo-split-item { .neo-split-item {
padding: 0.5rem 0; padding: 0.75rem 0;
border-bottom: 1px dashed #f0f0f0; 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 { .neo-split-item:last-child {
border-bottom: none; 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 { .neo-settlement-activities {
font-size: 0.8em; font-size: 0.8em;
color: #555; color: #555;
@ -1396,8 +1519,9 @@ const handleDragEnd = async (evt: any) => {
} }
.page-padding { .page-padding {
padding: 1rem;
padding-inline: 0; padding-inline: 0;
padding-block-start: 1rem;
padding-block-end: 5rem;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -1490,13 +1614,14 @@ const handleDragEnd = async (evt: any) => {
.neo-item-list { .neo-item-list {
list-style: none; list-style: none;
padding: 1.2rem; padding: 1.2rem;
padding-inline: 0;
margin-bottom: 0; margin-bottom: 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
background: var(--light); background: var(--light);
} }
.neo-list-item { .neo-list-item {
padding: 1rem 1.2rem; padding: 1rem 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }