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">{{
$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;
}