Enhance ListDetailPage with collapsible expense items and improved UI
This commit is contained in:
parent
fc09848a33
commit
0aa88d0af7
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user