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:
mohamad 2025-06-30 08:16:09 +02:00
parent 66daa19cd5
commit ab526ac254
11 changed files with 1167 additions and 158 deletions

File diff suppressed because it is too large Load Diff

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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();

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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';