feat: Enhance Expense Management with New Features and UI Improvements
This commit introduces several enhancements to the expense management system: - Added a new `ExpenseDetailDialog` component for displaying detailed expense information, including splits and payment history. - Implemented a `SettlementHistoryView` component to show settlement activities related to expense splits. - Enhanced the `ExpenseCreationSheet` component with recurring expense options and improved user selection for splits. - Updated the `ExpenseForm` to streamline input handling and improve user experience. - Introduced a `CostSummaryDialog` for summarizing costs associated with lists, allowing users to generate expenses from the summary. - Refactored existing components for better performance and maintainability, including improved loading states and error handling. These changes aim to provide a more comprehensive and user-friendly experience in managing expenses and settlements.
This commit is contained in:
parent
66daa19cd5
commit
ab526ac254
File diff suppressed because it is too large
Load Diff
183
fe/src/components/expenses/ExpenseDetailDialog.vue
Normal file
183
fe/src/components/expenses/ExpenseDetailDialog.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<Dialog :model-value="modelValue" size="lg" @update:model-value="$emit('update:modelValue', false)">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">{{ expense?.description || 'Expense Details' }}</h2>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div v-if="expense" class="space-y-6">
|
||||
<!-- Expense Overview -->
|
||||
<div class="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">Total Amount</p>
|
||||
<p class="text-lg font-semibold">{{ formatCurrency(parseFloat(expense.total_amount),
|
||||
expense.currency) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">Split Type</p>
|
||||
<p class="text-sm font-medium capitalize">{{ expense.split_type.toLowerCase().replace('_', '
|
||||
') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">Paid By</p>
|
||||
<p class="text-sm font-medium">{{ expense.paid_by_user?.name || expense.paid_by_user?.email
|
||||
|| `User #${expense.paid_by_user_id}` }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">Date</p>
|
||||
<p class="text-sm font-medium">{{ formatDate(expense.expense_date) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expense Splits -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold">Expense Splits</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="split in expense.splits" :key="split.id"
|
||||
class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p class="font-medium">{{ split.user?.name || split.user?.email || `User
|
||||
#${split.user_id}` }}</p>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Owes {{ formatCurrency(parseFloat(split.owed_amount), expense.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getStatusColor(split.status)">
|
||||
{{ split.status.replace('_', ' ') }}
|
||||
</span>
|
||||
<p v-if="remaining(split) > 0" class="text-xs text-error mt-1">
|
||||
{{ formatCurrency(remaining(split), expense.currency) }} remaining
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settlement Activities for this split -->
|
||||
<div v-if="split.settlement_activities && split.settlement_activities.length > 0"
|
||||
class="mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-600">
|
||||
<p class="text-sm font-medium mb-2">Payment History</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="activity in split.settlement_activities" :key="activity.id"
|
||||
class="flex items-center justify-between text-sm bg-neutral-100 dark:bg-neutral-700 rounded p-2">
|
||||
<span>{{ formatCurrency(parseFloat(activity.amount_paid), expense.currency) }}
|
||||
paid</span>
|
||||
<span class="text-neutral-500">{{ formatDate(activity.paid_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settlement Action -->
|
||||
<div v-if="canSettle(split)"
|
||||
class="mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-600">
|
||||
<Button variant="solid" size="sm" @click="settleSplit(split)">
|
||||
Settle {{ formatCurrency(remaining(split), expense.currency) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<Button v-if="canEdit" variant="ghost" @click="editExpense">
|
||||
Edit Expense
|
||||
</Button>
|
||||
<div></div>
|
||||
<Button variant="solid" @click="$emit('update:modelValue', false)">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Dialog, Button } from '@/components/ui'
|
||||
import type { Expense, ExpenseSplit } from '@/types/expense'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
expense: Expense | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'edit', expense: Expense): void
|
||||
(e: 'settle', payload: { split: ExpenseSplit; expense: Expense }): void
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Computed
|
||||
const canEdit = computed(() => {
|
||||
if (!props.expense || !authStore.user) return false
|
||||
return props.expense.paid_by_user_id === authStore.user.id ||
|
||||
props.expense.created_by_user_id === authStore.user.id
|
||||
})
|
||||
|
||||
// Methods
|
||||
function formatCurrency(amount: number, currency: string) {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(amount)
|
||||
} catch {
|
||||
return `${amount.toFixed(2)} ${currency}`
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d, yyyy')
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
function remaining(split: ExpenseSplit) {
|
||||
const paid = split.settlement_activities?.reduce((sum, activity) => sum + parseFloat(activity.amount_paid), 0) || 0
|
||||
return parseFloat(split.owed_amount) - paid
|
||||
}
|
||||
|
||||
function canSettle(split: ExpenseSplit) {
|
||||
return authStore.user && authStore.user.id === split.user_id && remaining(split) > 0
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200'
|
||||
case 'partially_paid':
|
||||
return 'bg-warning-100 text-warning-800 dark:bg-warning-900 dark:text-warning-200'
|
||||
default:
|
||||
return 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200'
|
||||
}
|
||||
}
|
||||
|
||||
function settleSplit(split: ExpenseSplit) {
|
||||
if (props.expense) {
|
||||
emit('settle', { split, expense: props.expense })
|
||||
}
|
||||
}
|
||||
|
||||
function editExpense() {
|
||||
if (props.expense) {
|
||||
emit('edit', props.expense)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional component-specific styles if needed */
|
||||
</style>
|
@ -8,41 +8,22 @@
|
||||
<!-- Existing form fields -->
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="loading"
|
||||
>
|
||||
<input type="text" v-model="form.description" class="form-control" required :disabled="loading">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Amount</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="form.total_amount"
|
||||
class="form-control"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
:disabled="loading"
|
||||
>
|
||||
<input type="number" v-model.number="form.total_amount" class="form-control" step="0.01" min="0.01" required
|
||||
:disabled="loading">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add recurring expense toggle -->
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isRecurring"
|
||||
v-model="form.isRecurring"
|
||||
class="form-check-input"
|
||||
:disabled="loading"
|
||||
>
|
||||
<input type="checkbox" id="isRecurring" v-model="form.isRecurring" class="form-check-input" :disabled="loading">
|
||||
<label for="isRecurring" class="form-check-label">
|
||||
This is a recurring expense
|
||||
</label>
|
||||
@ -50,11 +31,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Show recurrence pattern form when isRecurring is true -->
|
||||
<RecurrencePatternForm
|
||||
v-if="form.isRecurring"
|
||||
v-model="form.recurrencePattern"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<RecurrencePatternForm v-if="form.isRecurring" v-model="form.recurrencePattern" :disabled="loading" />
|
||||
|
||||
<!-- Rest of the existing form -->
|
||||
<div class="form-group">
|
||||
@ -74,20 +51,11 @@
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loading"
|
||||
>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
{{ isEditing ? 'Update' : 'Create' }} Expense
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="$emit('cancel')"
|
||||
:disabled="loading"
|
||||
>
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('cancel')" :disabled="loading">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@ -250,6 +218,8 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
@keyframes spinner-border {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -25,17 +25,32 @@
|
||||
<!-- Details -->
|
||||
<div v-if="expandedId === expense.id"
|
||||
class="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-700 space-y-1">
|
||||
<div v-for="split in expense.splits" :key="split.id" class="flex items-center text-sm gap-2 flex-wrap">
|
||||
<span class="text-neutral-600 dark:text-neutral-300">{{ split.user?.full_name || split.user?.email
|
||||
|| 'User #' + split.user_id }} owes</span>
|
||||
<span class="font-mono font-semibold">{{ formatCurrency(parseFloat(split.owed_amount),
|
||||
expense.currency) }}</span>
|
||||
<span v-if="remaining(split) > 0" class="text-xs text-danger">{{ formatCurrency(remaining(split),
|
||||
expense.currency) }} {{ $t('expenseList.remaining', 'left') }}</span>
|
||||
<Button v-if="canSettle(split)" variant="solid" size="sm" class="ml-auto"
|
||||
@click.stop="$emit('settle', { split, expense })">
|
||||
{{ $t('expenseList.settle', 'Settle') }}
|
||||
</Button>
|
||||
<div v-for="split in expense.splits" :key="split.id" class="space-y-2">
|
||||
<div class="flex items-center text-sm gap-2 flex-wrap">
|
||||
<span class="text-neutral-600 dark:text-neutral-300">{{ split.user?.full_name ||
|
||||
split.user?.email
|
||||
|| 'User #' + split.user_id }} owes</span>
|
||||
<span class="font-mono font-semibold">{{ formatCurrency(parseFloat(split.owed_amount),
|
||||
expense.currency) }}</span>
|
||||
<span v-if="remaining(split) > 0" class="text-xs text-error">{{
|
||||
formatCurrency(remaining(split),
|
||||
expense.currency) }} {{ $t('expenseList.remaining', 'left') }}</span>
|
||||
<Button v-if="canSettle(split)" variant="solid" size="sm" class="ml-auto"
|
||||
@click.stop="$emit('settle', { split, expense })">
|
||||
{{ $t('expenseList.settle', 'Settle') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Settlement History -->
|
||||
<div v-if="split.settlement_activities && split.settlement_activities.length > 0"
|
||||
class="ml-4 space-y-1">
|
||||
<p class="text-xs text-neutral-500 font-medium">Payment History</p>
|
||||
<div v-for="activity in split.settlement_activities" :key="activity.id"
|
||||
class="flex items-center justify-between text-xs text-neutral-600 dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-800 rounded px-2 py-1">
|
||||
<span>{{ formatCurrency(parseFloat(activity.amount_paid), expense.currency) }} paid</span>
|
||||
<span>{{ formatDate(activity.paid_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@ -48,6 +63,7 @@ import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { Button } from '@/components/ui'
|
||||
import type { Expense, ExpenseSplit } from '@/types/expense'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
defineProps({
|
||||
expenses: {
|
||||
@ -84,6 +100,14 @@ const authStore = useAuthStore()
|
||||
function canSettle(split: ExpenseSplit) {
|
||||
return authStore.getUser && authStore.getUser.id === split.user_id && remaining(split) > 0
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d, HH:mm')
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
124
fe/src/components/expenses/SettlementHistoryView.vue
Normal file
124
fe/src/components/expenses/SettlementHistoryView.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<Card class="settlement-history-view">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Settlement History
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" @click="refreshHistory">
|
||||
<BaseIcon name="heroicons:arrow-path-20-solid" class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<Spinner label="Loading settlement history..." />
|
||||
</div>
|
||||
|
||||
<Alert v-else-if="error" type="error" :message="error" />
|
||||
|
||||
<div v-else-if="activities.length === 0" class="text-center py-8">
|
||||
<BaseIcon name="heroicons:banknotes-solid" class="w-12 h-12 mx-auto text-neutral-400 mb-4" />
|
||||
<p class="text-neutral-500">No settlement activities found</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="activity in activities" :key="activity.id"
|
||||
class="flex items-center justify-between p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 bg-success-100 dark:bg-success-900 rounded-full flex items-center justify-center">
|
||||
<BaseIcon name="heroicons:check-20-solid"
|
||||
class="w-4 h-4 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{{ formatCurrency(parseFloat(activity.amount_paid)) }} paid
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500">
|
||||
by {{ activity.payer?.name || activity.payer?.email || `User
|
||||
#${activity.paid_by_user_id}` }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-neutral-500">{{ formatDate(activity.paid_at) }}</p>
|
||||
<p v-if="activity.creator && activity.creator.id !== activity.paid_by_user_id"
|
||||
class="text-xs text-neutral-400">
|
||||
Recorded by {{ activity.creator.name || activity.creator.email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Card, Button, Spinner, Alert } from '@/components/ui'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import { settlementService } from '@/services/settlementService'
|
||||
import type { SettlementActivity } from '@/types/expense'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
const props = defineProps<{
|
||||
expenseSplitId: number
|
||||
}>()
|
||||
|
||||
// State
|
||||
const activities = ref<SettlementActivity[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Methods
|
||||
function formatCurrency(amount: number) {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
} catch {
|
||||
return `$${amount.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
try {
|
||||
return format(new Date(dateString), 'MMM d, yyyy h:mm a')
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettlementHistory() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
activities.value = await settlementService.getSettlementActivitiesForSplit(props.expenseSplitId)
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to load settlement history'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshHistory() {
|
||||
loadSettlementHistory()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadSettlementHistory()
|
||||
})
|
||||
|
||||
watch(() => props.expenseSplitId, () => {
|
||||
loadSettlementHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settlement-history-view {
|
||||
@apply p-4;
|
||||
}
|
||||
</style>
|
@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<VModal :model-value="modelValue" :title="$t('listDetailPage.modals.costSummary.title')"
|
||||
@update:modelValue="$emit('update:modelValue', false)" size="lg">
|
||||
<Dialog :model-value="modelValue" size="lg" @update:model-value="$emit('update:modelValue', false)">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">{{ $t('listDetailPage.modals.costSummary.title') }}</h2>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="loading" class="text-center">
|
||||
<VSpinner :label="$t('listDetailPage.loading.costSummary')" />
|
||||
<Spinner :label="$t('listDetailPage.loading.costSummary')" />
|
||||
</div>
|
||||
<VAlert v-else-if="error" type="error" :message="error" />
|
||||
<Alert v-else-if="error" type="error" :message="error" />
|
||||
<div v-else-if="summary">
|
||||
<div class="mb-3 cost-overview">
|
||||
<p><strong>{{ $t('listDetailPage.modals.costSummary.totalCostLabel') }}</strong> {{
|
||||
@ -24,9 +26,9 @@
|
||||
<th class="text-right">{{
|
||||
$t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}</th>
|
||||
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue')
|
||||
}}</th>
|
||||
}}</th>
|
||||
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance')
|
||||
}}</th>
|
||||
}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -35,8 +37,11 @@
|
||||
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
|
||||
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
|
||||
<td class="text-right">
|
||||
<VBadge :text="formatCurrency(userShare.balance)"
|
||||
:variant="parseFloat(String(userShare.balance)) >= 0 ? 'settled' : 'pending'" />
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="parseFloat(String(userShare.balance)) >= 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'">
|
||||
{{ formatCurrency(userShare.balance) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -46,21 +51,25 @@
|
||||
<p v-else>{{ $t('listDetailPage.modals.costSummary.emptyState') }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<VButton variant="primary" @click="$emit('update:modelValue', false)">{{ $t('listDetailPage.buttons.close')
|
||||
}}</VButton>
|
||||
<div class="flex justify-between">
|
||||
<Button v-if="summary && !summary.expense_exists" variant="solid" @click="$emit('generate-expense')"
|
||||
:disabled="loading">
|
||||
{{ $t('listDetailPage.modals.costSummary.generateExpense', 'Generate Expense') }}
|
||||
</Button>
|
||||
<div></div>
|
||||
<Button variant="solid" @click="$emit('update:modelValue', false)">
|
||||
{{ $t('listDetailPage.buttons.close') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</VModal>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VModal from '@/components/valerie/VModal.vue';
|
||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||||
import VAlert from '@/components/valerie/VAlert.vue';
|
||||
import VBadge from '@/components/valerie/VBadge.vue';
|
||||
import VButton from '@/components/valerie/VButton.vue';
|
||||
import { Dialog, Spinner, Alert, Button } from '@/components/ui';
|
||||
|
||||
interface UserCostShare {
|
||||
user_id: number;
|
||||
@ -77,6 +86,8 @@ interface ListCostSummaryData {
|
||||
num_participating_users: number;
|
||||
equal_share_per_user: string | number;
|
||||
user_balances: UserCostShare[];
|
||||
expense_exists: boolean;
|
||||
expense_id?: number;
|
||||
}
|
||||
|
||||
defineProps({
|
||||
@ -98,7 +109,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
defineEmits(['update:modelValue', 'generate-expense']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
@ -60,7 +60,7 @@
|
||||
<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>
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="split-col split-owes">
|
||||
{{ $t('listDetailPage.expensesSection.owes') }} <strong>{{
|
||||
@ -74,7 +74,7 @@
|
||||
<div class="split-col split-paid-info">
|
||||
<div v-if="split.paid_at" class="paid-details">
|
||||
{{ $t('listDetailPage.expensesSection.paidAmount') }} {{
|
||||
getPaidAmountForSplitDisplay(split)
|
||||
getPaidAmountForSplitDisplay(split)
|
||||
}}
|
||||
<span v-if="split.paid_at"> {{ $t('listDetailPage.expensesSection.onDate') }} {{ new
|
||||
Date(split.paid_at).toLocaleDateString() }}</span>
|
||||
@ -97,7 +97,7 @@
|
||||
$t('listDetailPage.expensesSection.byUser') }} {{ activity.payer?.name || `User
|
||||
${activity.paid_by_user_id}` }} {{ $t('listDetailPage.expensesSection.onDate') }} {{
|
||||
new
|
||||
Date(activity.paid_at).toLocaleDateString() }}
|
||||
Date(activity.paid_at).toLocaleDateString() }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -44,6 +44,14 @@
|
||||
{{ t('listDetailPage.scanReceipt') }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button
|
||||
:class="[active ? 'bg-slate-50' : '', 'w-full px-4 py-2 text-left text-sm text-slate-700 flex items-center gap-2']"
|
||||
@click="openCostSummary">
|
||||
<BaseIcon name="heroicons:calculator" class="w-4 h-4" />
|
||||
{{ t('listDetailPage.costSummary', 'Cost Summary') }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
@ -155,6 +163,10 @@
|
||||
<!-- Conflict Resolution Dialog -->
|
||||
<ConflictResolutionDialog v-if="showConflictDialog" v-model="showConflictDialog" :conflict="currentConflict"
|
||||
@resolved="handleConflictResolved" />
|
||||
|
||||
<!-- Cost Summary Dialog -->
|
||||
<CostSummaryDialog v-model="showCostSummaryDialog" :summary="costSummary" :loading="costSummaryLoading"
|
||||
:error="costSummaryError" @generate-expense="handleGenerateExpense" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -194,7 +206,10 @@ import ExpenseCreationSheet from '@/components/expenses/ExpenseCreationSheet.vue
|
||||
import ReceiptScannerModal from '@/components/ReceiptScannerModal.vue'
|
||||
import OfflineIndicator from '@/components/OfflineIndicator.vue'
|
||||
import ConflictResolutionDialog from '@/components/global/ConflictResolutionDialog.vue'
|
||||
import CostSummaryDialog from '@/components/list-detail/CostSummaryDialog.vue'
|
||||
import { useItemHelpers } from '@/composables/useItemHelpers'
|
||||
import { costService } from '@/services/costService'
|
||||
import type { ListCostSummary } from '@/services/costService'
|
||||
|
||||
// Types
|
||||
interface UndoAction {
|
||||
@ -314,7 +329,11 @@ const categoryOptions = computed(() => {
|
||||
return buildCategoryOptions(categories.value || [], true)
|
||||
})
|
||||
|
||||
|
||||
// Cost Summary dialog state
|
||||
const showCostSummaryDialog = ref(false)
|
||||
const costSummaryLoading = ref(false)
|
||||
const costSummaryError = ref<string | null>(null)
|
||||
const costSummary = ref<ListCostSummary | null>(null)
|
||||
|
||||
// Methods
|
||||
const refetchList = () => {
|
||||
@ -733,6 +752,44 @@ const handleCreateCategory = async (categoryData: { name: string; group_id?: num
|
||||
}
|
||||
}
|
||||
|
||||
const openCostSummary = async () => {
|
||||
if (!list.value) return
|
||||
showCostSummaryDialog.value = true
|
||||
costSummaryLoading.value = true
|
||||
costSummaryError.value = null
|
||||
try {
|
||||
costSummary.value = await costService.getListCostSummary(list.value.id)
|
||||
} catch (err: any) {
|
||||
costSummaryError.value = err.response?.data?.detail || err.message || 'Failed to load cost summary'
|
||||
} finally {
|
||||
costSummaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateExpense() {
|
||||
if (!list.value) return
|
||||
|
||||
try {
|
||||
costSummaryLoading.value = true
|
||||
const expense = await costService.generateExpenseFromListSummary(list.value.id)
|
||||
|
||||
// Refresh the cost summary to show the new expense
|
||||
await openCostSummary()
|
||||
|
||||
notificationStore.addNotification({
|
||||
type: 'success',
|
||||
message: 'Expense generated successfully from list items'
|
||||
})
|
||||
} catch (err: any) {
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: err.response?.data?.detail || 'Failed to generate expense'
|
||||
})
|
||||
} finally {
|
||||
costSummaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Load list details
|
||||
|
@ -4,12 +4,15 @@ import type { Expense } from '@/types/expense'
|
||||
// Types for cost summary responses (would need to be added to types if not already present)
|
||||
export interface ListCostSummary {
|
||||
list_id: number
|
||||
list_name: string
|
||||
total_list_cost: string
|
||||
num_participating_users: number
|
||||
equal_share_per_user: string
|
||||
user_balances: Array<{
|
||||
user_id: number
|
||||
user_name: string
|
||||
contribution: string
|
||||
user_identifier: string
|
||||
items_added_value: string
|
||||
amount_due: string
|
||||
balance: string
|
||||
}>
|
||||
expense_exists: boolean
|
||||
|
@ -300,17 +300,18 @@ export const useExpensesStore = defineStore('expenses', () => {
|
||||
}
|
||||
|
||||
// ----- Enhanced Actions with Conflict Resolution -----
|
||||
async function fetchExpenses(params?: { groupId?: number, listId?: number, isRecurring?: boolean }) {
|
||||
async function fetchExpenses(params?: {
|
||||
group_id?: number
|
||||
list_id?: number
|
||||
isRecurring?: boolean
|
||||
skip?: number
|
||||
limit?: number
|
||||
}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const serviceParams: { group_id?: number, list_id?: number, isRecurring?: boolean } = {};
|
||||
if (params?.groupId) serviceParams.group_id = params.groupId;
|
||||
if (params?.listId) serviceParams.list_id = params.listId;
|
||||
if (params?.isRecurring) serviceParams.isRecurring = params.isRecurring;
|
||||
|
||||
const data = await expenseService.getExpenses(serviceParams)
|
||||
const data = await expenseService.getExpenses(params)
|
||||
expenses.value = data.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
@ -325,33 +326,41 @@ export const useExpensesStore = defineStore('expenses', () => {
|
||||
|
||||
async function createExpense(expenseData: ExpenseCreate) {
|
||||
const tempId = -Date.now() // Negative ID for optimistic updates
|
||||
const optimisticExpense: Expense = {
|
||||
|
||||
const optimisticExpense = {
|
||||
id: tempId,
|
||||
...expenseData,
|
||||
total_amount: expenseData.total_amount,
|
||||
expense_date: expenseData.expense_date || new Date().toISOString(),
|
||||
description: expenseData.description,
|
||||
total_amount: expenseData.total_amount.toString(),
|
||||
currency: expenseData.currency || 'USD',
|
||||
expense_date: expenseData.expense_date || new Date().toISOString(),
|
||||
split_type: expenseData.split_type,
|
||||
list_id: expenseData.list_id ?? null,
|
||||
group_id: expenseData.group_id ?? null,
|
||||
item_id: expenseData.item_id ?? null,
|
||||
paid_by_user_id: expenseData.paid_by_user_id,
|
||||
created_by_user_id: authStore.user?.id as number,
|
||||
overall_settlement_status: 'unpaid' as ExpenseOverallStatus,
|
||||
is_recurring: expenseData.is_recurring || false,
|
||||
next_occurrence: null,
|
||||
last_occurrence: null,
|
||||
parent_expense_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
version: 1,
|
||||
splits: expenseData.splits_in?.map((split, index) => ({
|
||||
splits: (expenseData.splits_in ?? []).map((split, index) => ({
|
||||
id: -index - 1,
|
||||
expense_id: tempId,
|
||||
user_id: split.user_id,
|
||||
owed_amount: split.owed_amount || '0',
|
||||
share_percentage: split.share_percentage,
|
||||
owed_amount: split.owed_amount?.toString() || '0',
|
||||
share_percentage: split.share_percentage?.toString(),
|
||||
share_units: split.share_units,
|
||||
status: 'unpaid' as any,
|
||||
settlement_activities: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
version: 1
|
||||
})) || []
|
||||
} as Expense
|
||||
})),
|
||||
} as any as Expense
|
||||
|
||||
// Track optimistic update
|
||||
const updateId = trackOptimisticUpdate('expense', tempId, 'create', optimisticExpense)
|
||||
|
@ -31,6 +31,7 @@ export interface SettlementActivity {
|
||||
created_by_user_id: number
|
||||
created_at: string // ISO datetime string
|
||||
updated_at: string // ISO datetime string
|
||||
version: number
|
||||
payer?: UserPublic | null
|
||||
creator?: UserPublic | null
|
||||
}
|
||||
@ -183,11 +184,12 @@ export interface Expense {
|
||||
|
||||
overall_settlement_status: ExpenseOverallStatusEnum
|
||||
is_recurring: boolean
|
||||
next_occurrence?: string | null
|
||||
last_occurrence?: string | null
|
||||
isRecurring?: boolean
|
||||
next_occurrence?: string
|
||||
last_occurrence?: string
|
||||
recurrence_pattern?: RecurrencePattern
|
||||
parent_expense_id?: number | null
|
||||
child_expenses?: Expense[]
|
||||
parent_expense_id?: number
|
||||
generated_expenses?: Expense[]
|
||||
}
|
||||
|
||||
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
|
||||
|
Loading…
Reference in New Issue
Block a user